English | 简体中文

本文解释 DocumentStore、Camera、SpatialIndex、WorkingSet、RendererPortCommandBus 如何共同工作。它面向两类读者:准备从零构建无限画布的人,以及希望把 运行时逐层接入现有 Canvas/Konva 项目的人。

1. 最小数据模型

CanvasNode 是框架与渲染器无关的持久节点:

interface CanvasNode<Payload extends JsonObject = JsonObject> {
  id: string
  kind: string
  bounds: { minX: number; minY: number; maxX: number; maxY: number }
  rotation?: number
  zIndex?: number
  assetIds?: string[]
  payload: Payload
}

设计约束:

  • id 在文档生命周期内稳定且非空。
  • kind 由宿主定义;核心包不包含业务节点枚举。
  • bounds 使用世界坐标并且不能反转;空间索引只依赖它。
  • payload 必须是 JSON 数据,不应放 DOM、Konva Node、ImageBitmap、函数或类实例。
  • assetIds 只保存稳定资源标识,不保存 Blob URL、视频播放器或纹理句柄。

节点数据能够 structuredClone,是撤销、持久化、Worker 传递和 Agent 上下文裁剪能够共用 同一份模型的前提。

2. 三个坐标空间

画布至少涉及三个空间:

空间 用途 例子
World 保存节点与相机位置 节点 boundscamera.centerX
Viewport 浏览器中的 CSS 像素 指针位置、可视区域宽高
Device pixel 实际位图像素 Canvas2D backing store、DPR

核心包处理 World 与 Viewport 的互换。以 X 轴为例:

viewportX = (worldX - camera.centerX) * zoom + viewport.width / 2
worldX    = (viewportX - viewport.width / 2) / zoom + camera.centerX

对应函数是 worldToViewportviewportToWorld。宿主应先把 PointerEvent 的 clientX/clientY 转成容器内坐标,再调用 viewportToWorld,不能直接把屏幕坐标写进节点。

createViewState 默认:

  • 最小缩放 0.02
  • 最大缩放 5
  • Viewport 外扩 240px 作为 overscan;
  • overscan 会除以 zoom 后再换算成世界距离。

因此,缩放越小,世界空间中的预加载范围越大。这能降低快速拖动画布时的闪烁,但也会 增加候选节点数。项目应根据节点密度与设备能力调整 overscanPixels

3. DocumentStore:唯一持久真相

MemoryDocumentStore 保存当前节点 Map 和单调递增的 revision。所有修改都通过一次 commit 完成:

const commit = document.commit({
  transactionId: 'move-card-42',
  label: 'Move card',
  expectedRevision: document.revision,
  changes: [{
    id: before.id,
    before,
    after: {
      ...before,
      bounds: { minX: 20, minY: 40, maxX: 220, maxY: 160 },
    },
  }],
})

一次提交会按以下顺序执行:

  1. 检查 expectedRevision 是否等于当前 revision。
  2. 检查 transaction id 非空且 changes 非空。
  3. 在临时 Map 上逐项校验 before 是否仍与当前节点相同。
  4. 校验所有 after 节点与 id。
  5. 只有全部通过后才替换文档 Map,并把 revision 增加 1。
  6. 向订阅者发送一份 commit。

这意味着提交是全有或全无的。中间某个节点冲突时,不会出现前半批已修改、后半批失败的 状态。revision 不匹配或 before 已过期都会抛出 RevisionConflictError

getvaluessnapshot 和 commit 结果均返回克隆。调用者修改返回对象不会绕过事务 系统污染内部真相。

冲突应该怎样处理

不要自动用旧 before 覆盖新状态。常见策略是:

  1. 捕获 RevisionConflictError
  2. 重新读取最新节点和 revision;
  3. 根据交互语义重新计算命令;
  4. 让用户确认无法自动合并的冲突;
  5. 使用新的 action id 再提交。

4. SpatialIndex:只回答“谁可能可见”

SpatialIndex 的职责只有 bounds 查询:

interface SpatialIndex<Node extends CanvasNode> {
  load(nodes: readonly Node[]): void
  upsert(node: Node): void
  remove(id: string): void
  search(bounds: Bounds): string[]
  clear(): void
  readonly size: number
}

默认 LinearSpatialIndex 是 O(n) 扫描,适合教学、测试和小文档。节点达到数千并且频繁移动 视口时,使用可选的 RBushSpatialIndex

import { RBushSpatialIndex } from '@jason-huang/infinite-canvas-agent/rbush'

const kit = createCanvasRuntime({
  renderer,
  spatialIndex: new RBushSpatialIndex(),
})

空间索引不是文档副本。Runtime 订阅 Document commit,只增量 upsert/remove bounds。 如果自行实现索引,不要把节点业务 payload 或渲染对象作为索引真相。

5. WorkingSet:可见不等于立即销毁

空间索引返回候选 id 后,WorkingSet.reconcile 把它们转换成渲染生命周期差量:

集合 含义 Renderer 动作
enter 上一帧未挂载,本帧需要 hydrate(node)
stay 连续留在工作集 update(node)
pendingExit 已离开,但处于退出宽限期 暂时保留
exit 宽限期已到 dehydrate(id)
active 本帧仍挂载的总集合 进入 draw(frame)

默认退出宽限期为 180ms。它吸收触控板抖动和边界来回穿越,避免同一节点连续创建/销毁。 节点重新进入候选集时,退出计时会被取消。

runtime.setPinned(ids) 可以让节点即使离开视口仍保持挂载,适用于正在拖拽、编辑或播放的 对象。Runtime 会忽略文档中已经不存在的 pinned id。setSelection 只把选中信息传给 Renderer,不自动 pin;如果交互要求选中节点常驻,宿主应同时设置 pinned。

6. RendererPort:把可回收对象留在适配器里

interface RendererPort<Node extends CanvasNode> {
  setView(view: ViewState): void
  hydrate(node: Node): void
  update(node: Node): void
  dehydrate(id: string): void
  draw(frame: RenderFrame<Node>): void
  getObjectCount(): number
  destroy(): void
}

每个方法的资源责任:

  • setView:同步 Stage/Canvas 尺寸、变换和 DPR。
  • hydrate:创建当前视口需要的渲染对象;若涉及异步图片,应持有一个可释放 lease。
  • update:只更新已挂载对象,不应偷偷补建整个文档。
  • dehydrate:销毁渲染对象、释放 lease、停止媒体、撤销事件和 Blob URL。
  • draw:按 frame.nodes 的 zIndex 顺序提交当前帧。
  • getObjectCount:供观测和容量验收使用,不应永远等于文档节点数。
  • destroy:释放整个 Renderer;多次调用最好保持安全。

一个最小适配器骨架:

class ExistingRenderer implements RendererPort<MyNode> {
  #objects = new Map<string, ExistingShape>()

  setView(view: ViewState) {
    stage.setCamera(view.camera, view.viewport)
  }

  hydrate(node: MyNode) {
    this.#objects.set(node.id, stage.mount(node))
  }

  update(node: MyNode) {
    this.#objects.get(node.id)?.update(node)
  }

  dehydrate(id: string) {
    this.#objects.get(id)?.destroy()
    this.#objects.delete(id)
  }

  draw() { stage.draw() }
  getObjectCount() { return this.#objects.size }
  destroy() { for (const id of this.#objects.keys()) this.dehydrate(id) }
}

dehydrate 只回收运行时资源,绝不能删除 Document 节点。用户回到区域后,同一节点可从 Document 再次 hydrate。

7. CanvasRuntime:一帧发生什么

const frame = kit.refresh(camera, {
  width: container.clientWidth,
  height: container.clientHeight,
})

一次 refresh

  1. clamp zoom 并计算 exact/expanded bounds;
  2. 查询 SpatialIndex;
  3. 将候选与 pinned id 送入 WorkingSet;
  4. setView,再处理 enter/stay/exit;
  5. 从 Document 读取 active nodes,并按 zIndex ?? 0 排序;
  6. 调用 draw
  7. 返回 frame 指标。
interface RuntimeFrame {
  view: ViewState
  workingSet: WorkingSetDiff
  documentNodeCount: number
  indexedNodeCount: number
  renderedObjectCount: number
}

生产环境建议记录后三个 count 与工作集 enter/exit。当 renderedObjectCount 随长期漫游只增 不减时,通常是 Renderer 的 dehydrate 或资源 release 缺失。

Document commit 会增量更新索引;如果已存在 view,Runtime 会自动用上次 camera/viewport 刷新。宿主不需要在一次 CommandBus 提交后再手工调用第二次 refresh,但动画中的 camera 变化仍需由宿主逐帧驱动。

8. CommandBus:同一事务承载人工与 Agent 修改

CommandBus.execute 接受 create | update | delete 命令,并把它们先应用到克隆快照,全部 合法后才生成 Document commit。

const commit = kit.commands.execute({
  actionId: crypto.randomUUID(),
  label: 'Create card',
  expectedRevision: kit.document.revision,
  commands: [{ type: 'create', node }],
})

关键语义:

  • 同一 actionId 加完全相同的计划重复执行,会返回第一次 commit,不会重复创建节点。
  • 同一 actionId 配不同内容会抛出 ActionIdConflictError
  • 新 execute 会清空 redo 栈。
  • undo/redo 本身也是 revision-checked commit,因此 Document revision 会继续递增,不会倒退。
  • 默认最多保留 100 个历史 transaction、约 8 MiB JSON 字节;任一上限超过就从最旧记录裁剪。

History 可以独立导出与恢复,但只能恢复进空的 CommandBus,且 history.documentRevision 必须与当前 Document revision 完全相同。这条门禁防止把旧撤销栈 套到新文档上。

9. 创建、恢复与销毁

const kit = createCanvasRuntime<MyNode>({
  renderer,
  initialSnapshot,
  initialHistory,
  runtime: {
    overscanPixels: 320,
    exitGraceMs: 220,
    minimumZoom: 0.05,
    maximumZoom: 8,
  },
  history: {
    maxHistoryTransactions: 80,
    maxHistoryBytes: 4 * 1024 * 1024,
  },
})

不能同时传 documentinitialSnapshot。需要接入既有 Store 时传 document;需要从持久化 恢复时传 snapshot。destroy() 会取消 Document 订阅、dehydrate 当前 WorkingSet、销毁 Renderer 并清空 SpatialIndex,而且 kit 对重复 destroy 做了幂等保护。

销毁后不要再次 refresh,也不要复用已经被 Renderer 销毁的 Stage/Layer。

10. 容量调优顺序

遇到大画布卡顿时,按下面顺序定位,而不是直接换渲染技术:

  1. 比较 documentNodeCountrenderedObjectCount,确认工作集是否真的有界。
  2. 调低 overscan 或退出宽限期,观察闪烁与对象数的折中。
  3. 节点量较大时把线性索引替换成 RBush。
  4. 检查 Renderer 的 dehydrate 是否释放图片、视频、纹理、事件和 URL。
  5. 给解码资源引入 ResourcePool,而不是只限制原始文件缓存。
  6. 将重节点按 zoom 选择 LOD 变体。

相关内容:缓存与媒体资源持久化旧项目接入

Runtime 压力门禁

执行 pnpm bench:runtime:gate 会压测 50,000 个逻辑节点、RBush 查询和视口 WorkingSet 切换。 命令会用自实现的 naive oracle 校验查询正确性,并在 RBush 或 WorkingSet p95 超过一帧 16.7 ms、活动集超过 500 个节点、查询退化为全文档扫描,或实测堆增长超过 512 MiB 时失败。 这些是 Runtime 验收阈值,不代表宿主渲染器已与其他 Canvas 产品达到相同性能;渲染 FPS 和媒体压力 仍属于宿主端验收。