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 中保留 widthheightradiusXradiusY 等显式 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 的可见挂载稳定后,再迁移写路径。按入口逐个替换:

  1. 新建节点;
  2. 属性面板更新;
  3. 拖拽/缩放结束;
  4. 删除;
  5. 脚本/批量操作;
  6. 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 Integrationhost-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。

可运行对照:ReactVueVanilla

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 适配器能满足交互与容量目标, 保留它通常是更低风险的选择。