English | 简体中文
本文解释 DocumentStore、Camera、SpatialIndex、WorkingSet、RendererPort
和 CommandBus 如何共同工作。它面向两类读者:准备从零构建无限画布的人,以及希望把
运行时逐层接入现有 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 | 保存节点与相机位置 | 节点 bounds、camera.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
对应函数是 worldToViewport 与 viewportToWorld。宿主应先把 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 },
},
}],
})
一次提交会按以下顺序执行:
- 检查
expectedRevision是否等于当前 revision。 - 检查 transaction id 非空且 changes 非空。
- 在临时 Map 上逐项校验
before是否仍与当前节点相同。 - 校验所有
after节点与 id。 - 只有全部通过后才替换文档 Map,并把 revision 增加 1。
- 向订阅者发送一份 commit。
这意味着提交是全有或全无的。中间某个节点冲突时,不会出现前半批已修改、后半批失败的
状态。revision 不匹配或 before 已过期都会抛出 RevisionConflictError。
get、values、snapshot 和 commit 结果均返回克隆。调用者修改返回对象不会绕过事务
系统污染内部真相。
冲突应该怎样处理
不要自动用旧 before 覆盖新状态。常见策略是:
- 捕获
RevisionConflictError; - 重新读取最新节点和 revision;
- 根据交互语义重新计算命令;
- 让用户确认无法自动合并的冲突;
- 使用新的 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:
- clamp zoom 并计算 exact/expanded bounds;
- 查询 SpatialIndex;
- 将候选与 pinned id 送入 WorkingSet;
- 先
setView,再处理 enter/stay/exit; - 从 Document 读取 active nodes,并按
zIndex ?? 0排序; - 调用
draw; - 返回 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,
},
})
不能同时传 document 和 initialSnapshot。需要接入既有 Store 时传 document;需要从持久化
恢复时传 snapshot。destroy() 会取消 Document 订阅、dehydrate 当前 WorkingSet、销毁
Renderer 并清空 SpatialIndex,而且 kit 对重复 destroy 做了幂等保护。
销毁后不要再次 refresh,也不要复用已经被 Renderer 销毁的 Stage/Layer。
10. 容量调优顺序
遇到大画布卡顿时,按下面顺序定位,而不是直接换渲染技术:
- 比较
documentNodeCount与renderedObjectCount,确认工作集是否真的有界。 - 调低 overscan 或退出宽限期,观察闪烁与对象数的折中。
- 节点量较大时把线性索引替换成 RBush。
- 检查 Renderer 的 dehydrate 是否释放图片、视频、纹理、事件和 URL。
- 给解码资源引入
ResourcePool,而不是只限制原始文件缓存。 - 将重节点按 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 和媒体压力
仍属于宿主端验收。