一个节点可以长期存在于作品里,却暂时没有任何 Canvas、Konva 或 DOM 对象。理解这一点,是实现大画布、保存、Undo 和 Agent 的共同前提。

学习目标

  • 设计可序列化、可迁移的 CanvasNode
  • 区分 Document、Spatial Entry、Renderer Handle 与 Resource;
  • 理解为什么选择态、Camera 和播放器不一定属于作品数据;
  • 证明销毁或切换 Renderer 后作品仍能恢复;
  • 避免“场景树就是文档”的常见架构陷阱。

1. 同一个节点有四种形态

Document Record   作品事实,必须保存
Spatial Entry     查询加速数据,可重建
Renderer Handle   当前显示投影,可销毁
Decoded Resource  重型运行时资源,可再加载

以图片节点 A 为例:

interface CanvasNode {
  id: string
  kind: 'image' | 'text' | 'rectangle'
  bounds: { minX: number; minY: number; maxX: number; maxY: number }
  zIndex: number
  assetRefs?: Array<{ assetId: string; role: 'source' | 'poster' }>
  payload: Record<string, unknown>
}

Document 表达“A 在 World 的哪里、是什么、引用哪个素材”。它不表达此刻是否已解码、是否有 Konva Group、是否在 GPU 中。

2. 什么应该进入 Document

判断标准不是“当前代码用不用”,而是:重新打开作品、换一台机器或换一个 Renderer 时,是否仍然必须知道它。

数据 进入 Document 原因
稳定 ID、类型、World Bounds 标识和几何真相
层级、关系、assetId 重建作品所需
文本、颜色、业务允许的 payload 可序列化内容
Camera、当前选择 通常进入 Session 是本地视图状态
Konva Node、DOM Element 不能作为跨 Renderer 真相
ImageBitmap、Texture、Object URL 可重建且生命周期短
视频 currentTime 按产品决定 常是播放会话,不是作品内容

Document payload 应通过 schema 和版本迁移管理。Record<string, unknown> 是框架扩展点,不代表可以塞进任意运行时对象。

3. 为什么场景树不能直接当 Document

如果作品只存在 stage.children 或 Canvas 像素里,会出现:

  • 保存时要反向猜测业务数据;
  • Canvas2D 没有稳定的逐节点对象;
  • 切换 Renderer 时 ID、层级和关系丢失;
  • Undo 只能保存框架对象或整张快照;
  • Agent 被迫学习 Konva、Canvas API 或 DOM 细节;
  • 节点离屏销毁后,作品也跟着消失。

正确依赖方向是:

Document → Core 派生状态 → Renderer

Renderer 可以向 Core 上报输入意图,但不能成为作品的第二个真源。

4. Renderer 是怎样重建的

节点进入 Working Set
  → 根据 nodeId 读取 Document
  → 根据 assetId 取得合适资源
  → RendererPort.hydrate(node)
  → 保存 Map<nodeId, handle>

节点离开 Working Set
  → RendererPort.dehydrate(nodeId)
  → 释放 handle 和资源引用
  → Document 仍存在

Document 有 6,000 个节点时,Renderer 完全可以只有 60 个 handle。目录有 6,000 件商品,不等于展台同时摆 6,000 件。

5. 一次拖动的正确数据流

错误路径:

Konva dragmove → konvaNode.x/y 改了 → 结束

节点一旦 dehydrate,再次 hydrate 会读取旧 Document,于是“跳回原位”。保存、协作和 Agent 也仍然看到旧坐标。

正确路径:

Renderer 输入事件
  → Canvas Local 反算 World
  → CommandBus 更新 Document
  → 更新 Spatial Entry
  → 更新当前 Renderer Handle
  → 手势结束记录一个 History Transaction

拖动期间可以实时更新 Document,但不必为每个 pointermove 创建一条 History。实时状态与历史粒度是两个问题。

6. Document、Session 与 Presence

公开框架可以进一步区分:

Document:节点、资产引用、关系、层级,可保存/同步
Session:Camera、选择、当前工具,本地恢复但不一定共享
Presence:协作者光标和临时选择,可同步但不进入作品

这个划分避免“我缩放一下画布,所有协作者的作品 revision 都改变”。也为未来多人协作和 Agent 审计留下清晰边界。

7. Renderer 切换实验

必须通过的恢复链:

加载同一份 Document
  → 创建 Canvas2D Renderer
  → 记录节点 id、World Bounds、revision
  → destroy()
  → 创建 Konva Renderer
  → 对当前 Working Set hydrate
  → 比较 Document 与 History

预期:

  • 节点 ID、位置、层级、assetId 不变;
  • Document revision 不因切换而增加;
  • History 仍能 Undo/Redo;
  • 旧 Renderer 的对象数归零;
  • 新 Renderer 只创建 Working Set 对象。

8. Demo 讲解建议

  1. 先在 Canvas2D 中创建三个节点;
  2. 展示 Document JSON,而不是只看屏幕;
  3. 切换到 Konva,比较同一批 ID 与 bounds;
  4. 销毁 Konva,再切回 Canvas2D;
  5. Undo 一次,证明 History 属于 Core;
  6. 把一个节点移出视口再移回,证明 dehydrate 不等于 delete。

讲课时可以临时制造错误:只改 Renderer handle,不改 Document。让节点离屏后再回来,学生会直观看到它为什么跳回去。

9. 常见错误

  • 把 Konva toJSON() 当成业务文档格式;
  • blob: URL 保存成永久素材地址;
  • 在 Document 中保存选择框、hover 或播放器对象;
  • Renderer 自己维护第二套 History;
  • destroy 只移除 DOM,却不解除事件和资源引用;
  • 保存时遍历当前 Working Set,导致离屏节点丢失。

10. 最小验收

serialize(Document) 不包含 DOM/Konva/ImageBitmap/ObjectURL
destroy(Renderer) 后 Document 与 History 不变
recreate(Renderer) 后作品结构一致
renderedObjectCount ≈ workingSetSize,而不是 documentNodeCount
业务修改最终都能在 Document diff 中观察到

思考题

如果两个图片节点引用同一个 assetId,其中一个离屏,Renderer 应该销毁什么?哪些共享资源不能立即释放?答案会在第 5 课的 Resource Lease 中展开。