一个节点可以长期存在于作品里,却暂时没有任何 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 讲解建议
- 先在 Canvas2D 中创建三个节点;
- 展示 Document JSON,而不是只看屏幕;
- 切换到 Konva,比较同一批 ID 与 bounds;
- 销毁 Konva,再切回 Canvas2D;
- Undo 一次,证明 History 属于 Core;
- 把一个节点移出视口再移回,证明 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 中展开。