English | 简体中文
@jason-huang/infinite-canvas-agent 是一组独立端口和模块,不是新的应用壳。老项目可以继续
拥有 React/Vue、Pinia/Redux、Konva Stage、Canvas 输入事件、路由、业务 Schema、持久服务和
模型 Provider,只接入当前真正需要解决的一层。
这份文档给出从“只加缓存”到“完整 Runtime + 宿主 Agent”的迁移路径。每个阶段都能独立验收 和回滚,不要求一次性重写画布。
1. 先做现状盘点
开始改代码前回答这些问题:
| 问题 | 要找的证据 | 影响的模块 |
|---|---|---|
| 作品真相在哪里? | Redux/Pinia/后端/Konva JSON,谁覆盖谁 | DocumentStore |
| 节点是全量挂载吗? | document count 与 scene object count | SpatialIndex + WorkingSet |
| 大图离屏后释放吗? | bitmap/texture/player/object URL 数量 | ResourcePool + Renderer |
| Undo 修改的是什么? | 业务数据还是场景对象快照 | CommandBus |
| 保存是否会乱序? | 旧请求晚返回能否覆盖新 revision | Persistence |
| Agent 能直接动 Stage 吗? | tool 是否暴露 renderer handle | Agent boundary |
| 节点 schema 是否可序列化? | payload 是否含 DOM、class、function | CanvasNode |
迁移前记录基线:初始加载时长、100/1,000/10,000 节点下的 renderer object count、解码内存、 快速漫游 5 分钟后的对象数、打开/关闭页面后的 listener/player 数、保存与撤销成功率。没有基线 就无法证明迁移真的解决问题。
2. 安装最小依赖
pnpm add @jason-huang/infinite-canvas-agent
按 subpath 增加可选 peer:
| 能力 | Import | 额外包 |
|---|---|---|
| Core / linear index | root 或 /core |
无 |
| Runtime kit + CommandBus | root 或 /integration |
无 |
| 解码资源缓存 | /cache |
无 |
| Agent 接入方法 | /agent |
无 |
| Canvas2D renderer | /canvas2d |
无 |
| RBush index | /rbush |
rbush |
| Konva renderer | /konva |
konva |
| IndexedDB | /indexeddb |
无 |
| React lifecycle | /react |
react |
| Vue lifecycle | /vue |
vue |
ESM 与 CommonJS 都通过同一 export map:
import { createCanvasRuntime } from '@jason-huang/infinite-canvas-agent'
const { createCanvasRuntime } = require('@jason-huang/infinite-canvas-agent')
3. 路径选择
flowchart TD
A["当前最痛问题"] --> B{"主要是大图/视频内存?"}
B -->|是| C["Phase 1: Cache only"]
B -->|否| D{"全量场景对象导致卡顿?"}
D -->|是| E["Phase 2: RendererPort + WorkingSet"]
D -->|否| F{"写入/撤销来源混乱?"}
F -->|是| G["Phase 3: Document + CommandBus"]
F -->|否| H{"要跨会话恢复?"}
H -->|是| I["Phase 4: Persistence"]
H -->|否| J["Phase 5: Host Agent"]
可以停在任一阶段。比如一个稳定的老 Canvas 产品完全可以只使用 ResourcePool。
4. Phase 0:先建立适配边界
不要第一天就替换 Store 或 Stage。先写三类纯转换:
function toCanvasNode(legacy: LegacyItem): ProductNode
function fromCanvasNode(node: ProductNode): LegacyItem
function boundsOf(legacy: LegacyItem): Bounds
要求:
- 转换不读取全局变量;
- 同一 legacy item 转换出的 id 稳定;
- bounds 是世界坐标,不受当前 camera 影响;
- renderer handle 不进入 payload;
- 所有业务字段仍由宿主 ProductNode 类型拥有。
- 即使
localBounds已保存归一化边界,仍在 record props 中保留width、height、radiusX、radiusY等显式 legacy 几何字段,确保宿主能够无损回写旧数据。
为转换增加 round-trip 测试。这是以后双写、回滚和数据迁移的安全基础。
5. Phase 1:只接入缓存
保持现有 Document、Canvas 与图片 loader。把解码后的 ImageBitmap/texture 包在
ResourcePool 中:
const pool = new ResourcePool<ImageBitmap>(128 * 1024 * 1024)
async function acquireLegacyImage(assetId: string, projectedEdge: number) {
const tier = selectImageTier(projectedEdge, {
devicePixelRatio: window.devicePixelRatio,
})
return pool.acquire(imageResourceKey(assetId, tier), async () => {
const bitmap = await oldImageLoader(assetId, tier)
return {
value: bitmap,
bytes: bitmap.width * bitmap.height * 4,
dispose: (value) => value.close(),
}
})
}
接入点只有 load/acquire、离屏 release、页面 destroy。不要同时改 Store、Renderer 和输入事件。
验收:
- 两个节点引用同一 asset/tier 时 loader 只执行一次;
- 有 active lease 的图片不会被 eviction;
- 节点离屏并 release 后可以被清理;
- bytes 永远不超过预算;
- 超大单图得到可见的降级/拒绝,不是 OOM。
可运行对照: cache-only。
6. Phase 2:保留现有 Renderer,引入可见工作集
先实现 RendererPort,不要替换 Konva Stage/Layer:
class LegacyKonvaPort implements RendererPort<ProductNode> {
#groups = new Map<string, Konva.Group>()
setView(view: ViewState) {
syncLegacyCamera(stage, contentLayer, view)
}
hydrate(node: ProductNode) {
const group = createLegacyGroup(node)
contentLayer.add(group)
this.#groups.set(node.id, group)
}
update(node: ProductNode) {
const group = this.#groups.get(node.id)
if (group) updateLegacyGroup(group, node)
}
dehydrate(id: string) {
releaseLegacyMedia(id)
this.#groups.get(id)?.destroy()
this.#groups.delete(id)
}
draw() { contentLayer.batchDraw() }
getObjectCount() { return this.#groups.size }
destroy() { /* destroy groups, listeners, layer ownership as agreed */ }
}
最重要的所有权决定:如果 Stage/Layer 由路由或其他模块共享,port 的 destroy 不能销毁不属于它 的对象;如果 port 创建 Stage,则它负责销毁。把这条规则写入 adapter 测试。
小文档先用 LinearSpatialIndex,大文档使用 RBush:
const kit = createCanvasRuntime<ProductNode>({
renderer: new LegacyKonvaPort(stage, contentLayer),
document: legacyDocumentBridge,
spatialIndex: new RBushSpatialIndex<ProductNode>(),
runtime: { overscanPixels: 320, exitGraceMs: 180 },
})
过渡期可以先只读桥接 Document,让旧 Store 仍是写入真相;每次旧 Store 更新,产生可审计的 commit 同步到桥接 Document。不要让 Renderer 同时订阅旧 Store 和新 Runtime,否则容易重复 挂载。
验收:
- 文档有 10,000 节点时,Konva Group 数接近视口工作集而不是 10,000;
- 平移离开再返回,Document 节点数不变;
- pendingExit 吸收边界抖动;
- 正在拖拽的 pinned 节点不会中途销毁;
- destroy 后 Stage 中没有遗留 group/listener/media lease。
可运行对照: legacy-konva。
7. Phase 3:把写入统一到 Document + CommandBus
只有 Phase 2 的可见挂载稳定后,再迁移写路径。按入口逐个替换:
- 新建节点;
- 属性面板更新;
- 拖拽/缩放结束;
- 删除;
- 脚本/批量操作;
- Agent。
交互 preview 可以直接进 Renderer,但 pointerup 时必须产生一次 CommandBus 事务:
renderer.setPreview(previewNode)
function finishDrag(before: ProductNode, after: ProductNode) {
renderer.setPreview()
kit.commands.execute({
actionId: crypto.randomUUID(),
label: 'Move node',
expectedRevision: kit.document.revision,
commands: [{ type: 'update', id: before.id, patch: { bounds: after.bounds } }],
})
}
不要每个 pointermove 都进入 history;长交互应在内存 preview,结束时合并为一个 transaction。
双写期必须明确 canonical owner。推荐新 Document 为真相,旧 Store 作为单向投影;如暂时反过来, 也只能一个方向产生持久写。双向无版本同步会形成回环和 stale overwrite。
验收:
- 所有持久修改都能通过 Document commit 观察;
- Undo/Redo 修改业务节点,不依赖已经 dehydrate 的 Group;
- 同 action id 重试不重复创建;
- 并发 revision 冲突不会覆盖新数据;
- 旧写入口被统计并最终归零。
8. Phase 4:接入持久化
先 load,再创建 Runtime;保存需要串行化或服务端 CAS:
const persistence = new IndexedDbPersistence<ProductNode>({
databaseName: 'my-product-canvas',
})
const workspace = await persistence.loadWorkspace(documentId)
const kit = createCanvasRuntime({
renderer,
initialSnapshot: workspace?.document,
initialHistory: workspace?.history,
})
let pendingSave = Promise.resolve()
const unsubscribe = kit.document.subscribe(() => {
const document = kit.document.snapshot()
const history = kit.commands.exportHistory()
pendingSave = pendingSave.then(() =>
persistence.saveWorkspace(documentId, document, history),
)
})
关闭前 unsubscribe、等待 pending save、destroy kit,再 close persistence。不要保存 SpatialIndex、 WorkingSet 或 Konva JSON 当作第二份作品真相。
验收:刷新后 Document/revision 恢复;history 只在同 revision 恢复;旧保存不能覆盖新保存; Renderer 与 decoded cache 从零重建;quota/error 有明确 UI。
详见持久化。
9. Phase 5:连接宿主 Agent
npm 包不包含 Agent。宿主继续拥有 Planner、Provider、Prompt、API Key、Tool Schema 与业务策略:
const context = buildAgentContext({
revision: kit.document.revision,
nodes: kit.document.values(),
selectedIds,
visibleIds,
maximumNodes: 60,
})
const content = await myBackend.plan({
prompt,
nodes: context.nodes.map((node) => compactNodeForRemoteAgent(node)),
})
const plan = parseRemoteAgentPlan<ProductNode>({
actionId: crypto.randomUUID(),
expectedRevision: context.revision,
nodes: kit.document.values(),
content,
policy: {
allowedKinds: ['product-card', 'product-connector'],
allowedPatchKeys: ['bounds', 'payload', 'zIndex'],
validateNode: assertProductNode,
},
})
kit.commands.execute(plan)
API Key 只在宿主后端或用户自己的安全环境中,不能提交公开仓库。模型永远不拿 Konva Stage、 mutable Store 或 persistence credential。
验收:合法计划只增加一次 revision;policy 拒绝时 revision 不变;Agent 等待期间用户编辑会触发 revision conflict;prompt/context 不包含未授权业务字段;服务端重新做身份与权限检查。
详见 Agent Integration 与 host-agent demo。
10. React 与 Vue 只绑定生命周期
React:
const runtime = useCanvasRuntime(
() => canvasRef.current
? createCanvasRuntime({ renderer: new Canvas2DRenderer(canvasRef.current) })
: null,
[],
)
在事件 handler 中读取 runtime.current。factory dependencies 改变时,旧 kit 会先 destroy。
Vue:
const runtime = useCanvasRuntime(() => canvasRef.value
? createCanvasRuntime({ renderer: new Canvas2DRenderer(canvasRef.value) })
: null)
在 mounted 后读取 runtime.value。两个 binding 都不会取代业务 Store、组件或 camera state。
11. 回滚设计
每个 phase 在上线前准备 feature flag:
| Phase | 回滚开关 | 必须保留的数据 |
|---|---|---|
| Cache | 使用旧 loader | 原始 asset/URL |
| WorkingSet | 回到旧全量挂载 | Document/旧 Store |
| CommandBus | 临时回到旧写入口 | action log + schema converter |
| Persistence | 回到服务端 canonical snapshot | 更高 revision 的 durable state |
| Agent | 禁用 Agent 工具 | 人工编辑能力与 Document |
回滚不能把高 revision 新文档覆盖成旧快照。切换实现时先停止新写入,flush 当前事务,记录 canonical revision,再切换 reader/writer。
12. 完成定义
一次真实迁移至少满足:
- 节点 schema 可序列化,renderer handle 不进入 Document。
- 只有一个 canonical writer,不存在双向无版本同步。
- document count、index count、rendered count 有监控。
- 离屏资源释放,decoded bytes 与 player count 有硬预算。
- 人工、脚本、Agent 最终都经过 revision transaction。
- Undo/Redo 在节点 dehydrate 后仍工作。
- Snapshot 与 History revision 匹配;旧保存不能覆盖新保存。
- API Key、内部字段、私有路径未进入浏览器包、Git 或日志。
- destroy/unmount 后对象、listener、lease 和数据库连接符合预期。
- 可通过 feature flag 回滚且不丢高 revision 数据。
- 独立 consumer 安装通过,不依赖 monorepo 私有路径。
验证完成后再考虑是否替换 Renderer。只要现有 Konva/Canvas2D 适配器能满足交互与容量目标, 保留它通常是更低风险的选择。