renderer-switch框架无关的真正含义是:Core 依赖一个稳定 Port,Canvas2D、Konva 或未来 Renderer 各自实现 Adapter;不是把所有框架能力抹平成最低级 API。
学习目标
- 区分 Port、Adapter、完整编辑器 Bridge;
- 对比 Canvas2D 即时绘制与 Konva 保留场景树;
- 设计支持 Working Set 生命周期的 RendererPort;
- 在不改变 Document、History 和 AgentPlan 的情况下切换 Renderer;
- 判断 tldraw/Excalidraw 为什么不是普通 Renderer Adapter。
1. Port 是 Core 需要的插座
interface RendererPort<Node> {
setView(view: ViewState): void
hydrate(node: Node): void
update(node: Node): void
dehydrate(nodeId: string): void
draw(frame: RuntimeFrame<Node>): void
inspect(): { objectCount: number }
destroy(): void
}
Core 不使用 instanceof Konva.Node,也不判断 Canvas2D API。Adapter 内部可以充分利用框架能力,只要不把私有对象泄漏回 Document、History 或 Agent。
2. 两类 Renderer 的成本模型不同
Canvas2D
没有稳定的逐节点场景对象
每帧清空有限 Viewport
遍历当前 Working Set / Render Registry
按 World → Canvas 投影绘制
优点是边界清楚、运行时轻;代价是命中、文本编辑、Transformer、事件和局部更新需要自己实现。
Konva
Stage / Layer / Group / Shape 构成保留场景树
hydrate 时创建节点
update 时按 ID 同步属性
dehydrate 时真实 destroy
draw 时 batchDraw
优点是交互、节点、Transformer、图片和导出成熟;代价是如果为全量 Document 常驻 Node,就会让场景树与作品规模绑定。
3. Konva 的无限不是巨大 Stage
Stage 始终保持 Viewport 大小。节点使用 World 坐标,Camera 变换一次应用到根 Group:
const offsetX = viewport.width / 2 - camera.centerX * camera.zoom
const offsetY = viewport.height / 2 - camera.centerY * camera.zoom
worldGroup.position({ x: offsetX, y: offsetY })
worldGroup.scale({ x: camera.zoom, y: camera.zoom })
离开 Working Set 后不能只 visible(false):需要解绑事件、清 cache、解除图片引用并 destroy,否则对象和资源仍然驻留。
4. 切换 Renderer 时什么不能变化
保持:Document、revision、Camera、SpatialIndex、Working Set
保持:CommandBus、History、Persistence、AgentPlan
替换:Renderer Adapter 和它拥有的 runtime handles
切换流程:
旧 Adapter.destroy()
→ 运行时对象与资源引用归零
→ 创建新 Adapter
→ setView(currentView)
→ 对 currentWorkingSet hydrate
→ draw(currentFrame)
Renderer 切换不是导出再导入,也不应该生成作品 revision。
5. 输入事件如何回到 Core
Adapter 可以上报语义化输入:
pointerDown(nodeId, worldPoint)
drag(nodeId, worldDelta)
dragEnd(nodeId)
textEditCommit(nodeId, text)
Core 将它转成 Command/Transaction。Adapter 不应在 Konva dragend 后独自保存 History,否则切到 Canvas2D 会出现第二套状态。
6. tldraw 和 Excalidraw 为什么不是普通 Adapter
tldraw 已拥有 Store、Camera、History、空间索引和工具状态机;Excalidraw 也有 Scene、AppState、History 和渲染循环。把它们塞进 draw() 会形成双真源:
Our Document ↔ tldraw Store / Excalidraw Scene
Our Camera ↔ editor Camera
Our History ↔ editor History
这属于 Editor Bridge 或整体迁移,需要定义 ID、schema、Camera、Undo 和 Asset 的所有权,不是 Canvas2D/Konva 同级的轻量 Renderer Adapter。
它们仍然值得借鉴:tldraw 的 Store/RBush/Asset/History,Excalidraw 的双层 Canvas、交互和开放格式;但不能把完整编辑器包装成“无状态绘图后端”。
7. Adapter 合约测试
同一组测试应对每个 Adapter 运行:
hydrate 同一 ID 两次不产生重复对象
update 后对象数不变且属性同步
dehydrate 后对象数下降且 Document 不变
destroy 后对象/事件/资源引用归零
同一 RuntimeFrame 在不同 Adapter 产生相同结构语义
像素不要求完全相同,但节点数量、World Bounds、z-order、选择语义和导出内容应符合共同契约。
8. 操作 Demo
- 在 Canvas2D 中创建并排列节点;
- 记录 Document revision、History 步数和 Working Set;
- 切换到 Konva,确认同一节点 ID 和位置;
- 用 Konva 拖动一个节点,观察 CommandBus Transaction;
- 切回 Canvas2D并 Undo,确认历史仍有效;
- 检查非活跃 Renderer object count 为 0;
- 把节点移出视口,确认两个 Adapter 都只消费 Working Set。
9. 验收标准
切换不改变 Document revision
AgentPlan/Command/History 不包含 Renderer 私有类型
旧 Adapter.destroy 后 runtime object count = 0
新 Adapter handle count ≈ Working Set
同一文档的 ID、Bounds、层级和资产引用一致
Renderer 输入最终通过 CommandBus 回写 Document
常见错误
- 把框架无关理解为不用任何框架;
- Port 直接暴露 Konva 类型;
- Stage 随 World 无限增大;
- Konva 全量挂载后只隐藏离屏节点;
- Renderer 自建 Document 和 History;
- 把完整编辑器误称为 Renderer Adapter;
- 切换时序列化场景树而不是复用 Document。
作业
实现一个 SVG 或 DOM Adapter。要求不修改 CanvasNode、CommandBus、History 与 Agent 类型,并复用同一套 Adapter 合约测试。解释它是即时绘制还是保留对象,以及 Dehydrate 释放哪些资源。