持久化负责“关掉页面后还能回来”;Working Set、LOD 和资源预算负责“页面开着时不会把内存吃完”。把二者混为一谈,是大画布最常见的误判。
学习目标
- 区分 Document、Workspace、Asset 与运行时对象;
- 设计不依赖浏览器、Electron 或服务器的 Persistence Port;
- 理解 Checkpoint + Journal 与当前 Undo/Redo 的区别;
- 正确处理原子保存、版本迁移和 revision 不一致;
- 按正确顺序恢复大文档,而不是全量 Hydrate。
1. Core 只依赖端口
interface DocumentPersistence {
load(documentId: string): Promise<DocumentSnapshot | null>
saveCheckpoint(documentId: string, snapshot: DocumentSnapshot): Promise<void>
appendTransaction(documentId: string, tx: PersistedTransaction): Promise<void>
}
interface CanvasWorkspacePersistence {
loadWorkspace(documentId: string): Promise<WorkspaceSnapshot | null>
saveWorkspace(documentId: string, value: WorkspaceSnapshot): Promise<void>
}
interface AssetStore {
getManifest(assetId: string): Promise<AssetManifest | null>
putBlob(key: string, blob: Blob): Promise<void>
getBlob(key: string): Promise<Blob | null>
removeAsset(assetId: string): Promise<void>
}
浏览器实现 IndexedDB/OPFS,测试实现 Memory,桌面或服务器实现文件/数据库 Adapter。Core 不应该直接调用某个宿主 API。
2. 到底保存什么
| 数据 | 是否持久化 | 原因 |
|---|---|---|
| Document records + schema version | 是 | 作品真相 |
| 有界 Undo/Redo snapshot | 可选 | 刷新后继续编辑 |
| Checkpoint + transaction journal | 是 | 崩溃与长期恢复 |
| Camera/selection | 可选 Session | 恢复用户视角 |
| Blob + Asset Manifest | 是 | 素材真相 |
| 派生 LOD | 可选 | 可重建缓存 |
| ImageBitmap/Object URL/texture | 否 | 运行时资源 |
| Konva Node/Canvas 像素 | 否 | 可从 Document 重建 |
Object URL 只在当前页面生命周期有效,不能写进 Document 当永久地址。Document 只保存稳定 assetId。
3. IndexedDB 为什么适合作为浏览器默认实现
IndexedDB 支持结构化数据、Blob 和事务,不像 localStorage 那样同步阻塞主线程。OPFS 更适合大文件和流式读写,但兼容性与实现复杂度更高,可以作为 AssetStore 的可选 Adapter。
IndexedDB:Document、History、Manifest、兼容性优先的 Blob
OPFS:可选的大文件 Blob
Memory:测试、隐私模式或存储不可用时降级
生成 LOD 时不要在图片解码的整个异步过程里一直占着数据库事务:先完成计算,再打开短事务写入结果。
4. Workspace 原子保存
若 Document 已保存到 revision 42,但 History 仍对应 revision 41,刷新后恢复历史会把作品带回错误分支。
interface WorkspaceSnapshot {
version: 1
document: DocumentSnapshot
history: {
documentRevision: number
undo: TransactionSnapshot[]
redo: TransactionSnapshot[]
}
}
Document 与 History 应在同一个 IndexedDB transaction 中提交。恢复时:
history.documentRevision === document.revision
→ 可以恢复 History
不一致、损坏或版本不支持
→ 保留 Document
→ 丢弃 History
History 可以丢,Document 真相不能为了迁就撤销栈被覆盖。
5. Undo Stack 与长期 Journal 不同
Undo/Redo:当前编辑会话的用户体验,有步数和字节预算
Journal:Checkpoint 之后的持久化事务,用于崩溃恢复
Checkpoint:周期性的完整 Document 基线
长期恢复:
最近 Checkpoint
→ 按 revision 顺序重放后续 Journal
→ 得到最新 Document
→ 校验独立的 History snapshot
不能从作品诞生开始无限重放日志。达到事务数、字节数或时间阈值后生成新 Checkpoint,并清理已覆盖的旧日志。
6. Schema Migration
每份快照必须带版本。加载旧文档:
读取 raw snapshot
→ 验证基础结构
→ 逐版本 migrate v1 → v2 → v3
→ 校验迁移后的节点 schema
→ 才进入 DocumentStore
Renderer 不负责迁移;否则 Canvas2D 和 Konva 可能得到不同作品。迁移失败应明确报错或只读打开,不能静默丢字段。
7. 大文档的正确恢复顺序
加载并迁移 Document
→ 校验/恢复 History
→ 从全部轻量 bounds 重建 SpatialIndex
→ 恢复 Camera Session
→ 查询 Visible/Overscan
→ 只 Hydrate 当前 Working Set
→ 后台预热必要资源
错误做法是加载 Document 后立即为全部节点创建 Konva Node、解码全部图片,再显示首屏。持久化恢复不能重新引入全量渲染。
8. 写入调度与故障
Document 高频变化不代表每个 pointermove 都写数据库。常见策略:
pointermove:只更新内存 Document
pointerup / transaction commit:进入持久化队列
短 debounce:合并连续低风险写入
页面隐藏/关闭:尽力 flush,但不依赖 beforeunload 才保存
写入队列要保持顺序,并记录 revision。旧 revision 的迟到写入不能覆盖新快照。配额不足时优先清理可重建 LOD,而不是唯一 original。
9. 操作实验
- 用 Memory Adapter 保存 Document 与两条 History;
- 销毁 Runtime 和 Renderer,重新创建;
- 恢复后确认 Document 全量存在,但 Renderer 只挂载当前 Working Set;
- 人为把 History revision 改旧,确认只丢 History;
- 模拟写入失败,确认内存 Document 不被回滚成旧快照;
- 切换 IndexedDB Adapter,刷新页面后继续 Undo/Redo。
10. 验收标准
没有 Adapter 时 Memory 模式仍能运行
Document + History 原子保存或 History 安全丢弃
旧 schema 经过显式 migration
恢复后 renderedObjectCount 不等于 documentNodeCount
Blob/ObjectURL/ImageBitmap 边界正确
Camera/RBush/每帧 draw 热路径不等待持久化 I/O
写入顺序不会让旧 revision 覆盖新 revision
常见错误
- 把 IndexedDB 当作运行时内存优化;
- 把 Base64 或 Blob 塞进 Document JSON;
- 保存
blob:URL; - 分两次保存 Document 和 History;
- 恢复时全量创建 Renderer 对象;
- 在 Camera 变化或每帧 draw 中写数据库;
- 无版本快照直接进入 Core;
- 删除节点时立即清理仍可能被 Undo 引用的素材。
思考题
为什么“Shell 已经保存整幅画布”不能证明图片解码内存已经被治理?请分别指出持久化、Working Set、Decoded Cache 和 Renderer Handle 解决的问题。