Canvas2D / Konva 适配器切换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

  1. 在 Canvas2D 中创建并排列节点;
  2. 记录 Document revision、History 步数和 Working Set;
  3. 切换到 Konva,确认同一节点 ID 和位置;
  4. 用 Konva 拖动一个节点,观察 CommandBus Transaction;
  5. 切回 Canvas2D并 Undo,确认历史仍有效;
  6. 检查非活跃 Renderer object count 为 0;
  7. 把节点移出视口,确认两个 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 释放哪些资源。