课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
本章唯一的真相:History 处理当前游戏会话内的时间;Migration 处理旧盒子不同版本之间的时间。
准备一辆玩具车、三张桌面照片和三个纸板盒。首先,让孩子把车从 A 推到 B,再从 B 推到 C。每一步都用照片记录,因此按一次“返回”就能让车从 C 回到 B;这就是当前会话内的 Undo。接下来,分别给盒子贴上 v1、v2 和 v3 标签:v1 盒子只写着“红色汽车”,v2 还记录了它的位置,v3 则用结构化的涂装标签取代颜色。如果三年后找到一个 v1 盒子,绝不能把其中缺失的字段直接交给今天的游戏规则。先检查它,再按版本逐个替换盒子。
继续之前先预测答案:如果孩子连续把车从 A 拖到 B,手经过了 87 个点,那么一次 Undo 应该让车后退一个像素,还是一路回到 A?它应该回到 A,因为一个手势就是一个 Transaction。现在再预测一下:把“光标正悬停在汽车上方”拍成照片,并在以后重新打开盒子时恢复这一状态,是否合理?不合理;那只是桌面上一闪而过的状态。
- 完成一个动作从 A 推到 B
- 保留可逆记录每个手势拍一张照片
- 检查旧盒子的标签拒绝损坏的数据
- 按顺序替换每个盒子v1 → v2 → v3
- 仅在完成后更换锁具原子保存
这个游戏有两条时间线。照片栈属于页面打开后的操作历史;版本号属于数据格式的历史。Undo 无法取代 Migration,而 Migration 也不应凭空制造出一串可以撤销的用户操作。
将玩具映射到 Canvas
| 玩具世界 | Canvas Lab | 确切职责 |
|---|---|---|
| 推动汽车的意图 | Command | 诸如 MoveShape 和 ResizeShape 的领域动作 |
| 手实际经过的点 | Operation / 瞬时预览 | 不会逐项进入 History 的高频执行细节 |
| 前后照片之间的差异 | Patch | 可以应用和反向应用的数据变更 |
| 整个桌面的一张照片 | Snapshot | 某个修订版本的完整 Document |
| 一次从按下到松开的手势 | Transaction | 一次原子提交和一个 Undo Step |
| 同时移动三辆汽车 | Batch | 全部成功或全部失败的多个 Command |
| 合并 87 张进行中的照片 | Coalescing | 将一次连续 Drag 变成一条 History 记录 |
| 只撤销这个孩子的操作 | Local Undo / Undo Scope | 不会抹除远程用户后来所做的更改 |
v1 盒子标签 | Schema Version | 持久化格式,而非应用程序版本 |
| 盒子检查器 | Validation | 在不可信输入进入运行时之前对其进行验证 |
| 盒子替换流水线 | Up Migration | 每次转换一个版本,将旧格式转换为当前格式 |
| 返回旧盒子 | Down Migration | 仅在明确可逆且经过测试时提供 |
| 桌旁的背包 | Session | 当前页面、Camera 和 Selection;可以保存在本地 |
| 挥动的手所在的位置 | Presence | Cursor 和在线状态;过期后可以安全丢弃 |
类比到此为止:真正的 Patch 不是照片。它必须处理稳定 ID、引用、并发和失败。真正的 IndexedDB Transaction 也不是业务层的 Undo Transaction;二者只不过都要求“要么全部成功,要么全部失败”。Snapshot 易于恢复,但体积很大。Operation Log 易于增量同步,但需要重放、压缩和带版本的协议。在这个类比中,盒子可以换回旧版本,但这并不意味着每个生产环境中的 Migration 都能向下执行:拆分、删除或加密数据可能不可逆。
先破除错误直觉
- “对每个
pointermove都调用history.push是最忠实的做法。” 一个用户意图会变成几十个 Undo 步骤,同时内存占用和协作流量急剧增长。以高频率预览更改;只在松开时提交。 - “保存
JSON.stringify(engineState)就足够了。” 引擎状态混杂了 Camera、Hover、DOM 引用和缓存。持久化 Document、本地 Session 和临时 Presence 具有不同的生命周期和权限。 - “有 TypeScript 类型就意味着 JSON 不需要验证。” 类型会在编译后消失。来自 IndexedDB、服务器、剪贴板和旧客户端的值全都不可信。
- “Migration 可以直接修改刚刚读到的对象。” 执行到一半时出现异常,会留下一个半新半旧的对象。每一步都应是纯函数:保留输入,并再次验证输出。
- “始终删除 Unknown Records。” 旧客户端可能会悄无声息地处理新客户端写入的记录。要么在保留原始字节的同时隔离它们,要么拒绝以可写模式打开文档。
- “Autosave 成功就意味着 Asset 也成功了。” 第 12 章中的二进制资产拥有独立于 Document Records 的生命周期;引用需要一套
ready/pending/missing协议。 - “Undo 只是恢复旧 Snapshot。” 在多人协作中,这也会抹除远程更改。Local Undo 必须反向执行一个本地 Transaction,并依据当前权威状态重新验证它。
生产级背包
前置条件
第 5 章提供稳定 ID 以及 Document/Page/Shape/Asset/Binding 记录。第 9 章定义 Preview 与 Commit 之间的 Transaction Boundary。第 12 章提供 Asset 状态和安全导入。History 只接受经过验证的 Command,绝不读取 Renderer 像素。持久化边界只接受当前的 DocumentV3;每个外部 Payload 都要先经过 unknown → validate envelope → migrate → validate current。
形式化知识
Command 表达用户想做什么,Operation 表达执行层面的最小变更,Patch 保留变更前后的值,而 Snapshot 保留完整状态。Create、Move、Resize 和 Delete 都分别实现 apply 和 invert。Batch 先验证所有子命令,然后只递增一次修订版本。Transaction 在 Pointer Down 时捕获基线,在移动期间只更新 Preview,并在 Pointer Up 时提交。Coalescing 会合并共享同一 transactionId 的连续更新。成功执行新的本地 Command 后,会清空 Redo 栈。
必须明确 Undo Scope:作用于一个页面还是整个 Document、是否跨越 Page Switch,以及是否随操作一并恢复 Selection。Canvas Lab 的选择是:“全局 Document History;Page Switch 不进入 History;Undo 后,Session 导航到受影响的页面,并选中受影响的 ID。”Hover、Cursor、相机动画、对齐参考线和未提交文本都属于 Transient State。Selection History 只存储稳定 ID 提示,在删除后过滤这些提示,绝不把 Session 混入 Document Patch。
Autosave 使用 Debounce 吸收时间上相近的提交,但在页面隐藏或关闭前进行 flush 只能尽力而为;beforeunload 并不是可靠的数据库。Crash Recovery 使用两个 IndexedDB 槽位:committed 指针始终指向上一个完整的 Envelope。在 staging 完整写入并通过验证后,必须写入新的修订版本,并且要在同一个 readwrite transaction 内切换指针。如果 transaction 中止或进程崩溃,就继续读取旧指针;绝不要先覆盖唯一的旧副本。如果两个标签页并发保存,还需要带修订版本条件的写入或单写入者协议。IndexedDB 的 transaction 原子性并不会自动防止业务层面的更新丢失。随后,Server Persistence 使用 {documentId, revision, schemaVersion, checksum} 执行条件写入。冲突绝不能悄无声息地变成 Last-Write-Wins。
Schema 包含记录判别器、必填字段、数值范围、引用规则和 schemaVersion。验证会同时检查结构和语义:ID 唯一、Parents 无环、Pages 存在,以及 Asset 引用有效。一次向上迁移只跨越一个版本,例如 v1→v2 或 v2→v3。每一步都应具有确定性,只接受其声明的源版本,保留输入,并验证输出。这里要准确理解幂等性:v1ToV2(v1) 本身不是一个可以应用两次的函数,因为第二次的输入已不再是 v1。必须具有幂等性的是恢复入口点——当前版本再次通过 loadDocument 时不会发生变化,而且崩溃后从最初已提交的 Payload 重新运行会产生相同结果。默认并不承诺支持向下迁移。当必须回滚某个版本时,要识别哪些新记录无法表示,并拒绝写入降级后的数据。处理向前兼容性时,应采用只读方式打开,将 Unknown Records 隔离,并备份原始 Payload;绝不能假装理解它。
Document 是共享、持久且可迁移的事实来源。Session 是某位用户在一台设备上的 Camera、当前 Page、Selection 和 Tool;它可以单独保存在本地。Presence 是带有 TTL 的 Cursor、Viewport 和在线状态;可以广播它,但绝不能将其放入恢复快照。三者都可以表示为 Records,但它们的 scope、存储位置、权限和清理策略必须保持不同。
证据与兼容性(已于 2026-08-29 验证)
IndexedDB 在浏览器中提供事务式对象存储;请通过 MDN IndexedDB API 验证其 API 和可用性限制。页面生命周期和后台冻结不能仅依赖卸载事件;请参阅 Chrome 页面生命周期。structuredClone() 可以复制许多结构,但它并不是 Schema Validation;有关支持的类型,请参阅 MDN 结构化克隆文档。服务器仍必须验证权限、版本和语义。客户端验证只能更早地报告错误;它不是信任边界。
本章的工程增量
**起点:**第 12 章的 Document 只能手动导出为 JSON,而且 Drag 会在进行过程中直接修改记录。**完成标准:**Create/Move/Resize/Delete 均可撤销;一次连续 Drag 只对应一次 Undo;v1/v2 可安全迁移到 v3;Autosave 可从崩溃中恢复;Session 单独存储。
添加以下接口和文件:
src/engine/history/History.ts:Transaction、Batch、Coalescing、Undo/Redo;src/engine/persistence/schema.ts:带版本的 Envelope 和运行时验证;src/engine/persistence/migrations.ts:纯 v1→v2→v3 函数;src/engine/persistence/DocumentRepository.ts:双槽 Autosave 和 Crash Recovery;src/engine/session/SessionRepository.ts:仅存储 Page/Camera/Selection;src/engine/persistence/__tests__/migrations.test.ts:独立的 Migration 证据。
下面是一个可执行的最小核心。它没有用省略号隐藏关键的迁移逻辑:
type ShapeV1 = { id: string; type: 'rect'; x: number; y: number; color?: string };
type DocV1 = { schemaVersion: 1; shapes: ShapeV1[] };
type ShapeV2 = ShapeV1 & { w: number; h: number };
type DocV2 = { schemaVersion: 2; pageId: string; shapes: ShapeV2[] };
export type ShapeV3 = Omit<ShapeV2, 'color'> & { fill: { kind: 'solid'; color: string } };
export type DocV3 = {
schemaVersion: 3;
pages: Array<{ id: string; shapeIds: string[] }>;
shapes: ShapeV3[];
};
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null;
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const validId = (v: unknown): v is string => typeof v === 'string' && /^[a-z]+-[a-z0-9-]+$/.test(v);
function assertV1(v: unknown): asserts v is DocV1 {
if (!object(v) || v.schemaVersion !== 1 || !Array.isArray(v.shapes))
throw new Error('INVALID_V1');
for (const s of v.shapes)
if (!object(s) || !validId(s.id) || s.type !== 'rect' || !finite(s.x) || !finite(s.y))
throw new Error('INVALID_V1_SHAPE');
}
function assertV2(v: unknown): asserts v is DocV2 {
if (!object(v) || v.schemaVersion !== 2 || !validId(v.pageId) || !Array.isArray(v.shapes))
throw new Error('INVALID_V2');
for (const s of v.shapes)
if (
!object(s) ||
!validId(s.id) ||
s.type !== 'rect' ||
!finite(s.x) ||
!finite(s.y) ||
!finite(s.w) ||
!finite(s.h)
)
throw new Error('INVALID_V2_SHAPE');
}
export function assertV3(v: unknown): asserts v is DocV3 {
if (!object(v) || v.schemaVersion !== 3 || !Array.isArray(v.pages) || !Array.isArray(v.shapes))
throw new Error('INVALID_V3');
const ids = new Set<string>();
for (const s of v.shapes) {
if (
!object(s) ||
!validId(s.id) ||
ids.has(s.id) ||
s.type !== 'rect' ||
!finite(s.x) ||
!finite(s.y) ||
!finite(s.w) ||
!finite(s.h) ||
!object(s.fill) ||
s.fill.kind !== 'solid' ||
typeof s.fill.color !== 'string'
)
throw new Error('INVALID_V3_SHAPE');
ids.add(s.id);
}
const pageIds = new Set<string>(),
placed = new Set<string>();
for (const p of v.pages) {
if (!object(p) || !validId(p.id) || pageIds.has(p.id) || !Array.isArray(p.shapeIds))
throw new Error('INVALID_V3_PAGE');
pageIds.add(p.id);
for (const id of p.shapeIds) {
if (typeof id !== 'string' || !ids.has(id) || placed.has(id))
throw new Error('INVALID_V3_PAGE');
placed.add(id);
}
}
if (placed.size !== ids.size) throw new Error('INVALID_V3_ORPHAN_SHAPE');
}
export function v1ToV2(input: DocV1): DocV2 {
return {
schemaVersion: 2,
pageId: 'page-main',
shapes: input.shapes.map((s) => ({ ...s, w: 120, h: 80 })),
};
}
export function v2ToV3(input: DocV2): DocV3 {
const shapes = input.shapes.map(({ color = '#64748b', ...s }) => ({
...s,
fill: { kind: 'solid' as const, color },
}));
return {
schemaVersion: 3,
pages: [{ id: input.pageId, shapeIds: shapes.map((s) => s.id) }],
shapes,
};
}
export function loadDocument(raw: unknown): DocV3 {
if (!object(raw) || !Number.isInteger(raw.schemaVersion)) throw new Error('INVALID_ENVELOPE');
let current: unknown = structuredClone(raw);
if ((current as { schemaVersion: number }).schemaVersion === 1) {
assertV1(current);
current = v1ToV2(current);
}
if ((current as { schemaVersion: number }).schemaVersion === 2) {
assertV2(current);
current = v2ToV3(current);
}
assertV3(current);
return current;
}
type Patch = { id: string; before: ShapeV3 | null; after: ShapeV3 | null };
type Step = { transactionId: string; patches: Patch[]; pageId: string; selectedIds: string[] };
export class History {
private undoStack: Step[] = [];
private redoStack: Step[] = [];
commit(step: Step) {
this.undoStack.push(structuredClone(step));
this.redoStack.length = 0;
}
undo(applyAtomically: (patches: Patch[]) => void): Step | undefined {
const step = this.undoStack.at(-1);
if (!step) return;
applyAtomically(step.patches.map((p) => ({ id: p.id, before: p.after, after: p.before })));
this.undoStack.pop();
this.redoStack.push(step);
return step;
}
redo(applyAtomically: (patches: Patch[]) => void): Step | undefined {
const step = this.redoStack.at(-1);
if (!step) return;
applyAtomically(step.patches);
this.redoStack.pop();
this.undoStack.push(step);
return step;
}
}
回到玩具的例子:中间的 87 个位置仅仅说明孩子的手经过了哪里。commit({ transactionId }) 才是拍摄可逆照片的东西。loadDocument 是盒子检查员;它绝不会把 v1 直接交给今天的 Renderer。applyAtomically 不只是一个令人安心的函数名:实现必须先针对一个不可见的副本验证整个 Patch 组,然后一次性替换 Document。如果它可能修改到一半再抛出异常,那么即使将 Step 保留在 History 栈中,也无法挽救一个只写了一半的 Document。
Migration 和 History 必须分别测试:
import { describe, expect, it } from 'vitest';
import { History } from '../../history/History';
import { loadDocument, v1ToV2, v2ToV3 } from '../migrations';
describe('versioned persistence', () => {
const v1 = {
schemaVersion: 1 as const,
shapes: [{ id: 'shape-a', type: 'rect' as const, x: 1, y: 2, color: '#f00' }],
};
it('migrates v1 → v3 deterministically and does not change when loaded again', () => {
const once = loadDocument(v1);
expect(loadDocument(once)).toEqual(once);
expect(v2ToV3(v1ToV2(v1))).toEqual(once);
});
it('commits one reversible step for a continuous drag', () => {
const history = new History();
const applied: unknown[] = [];
history.commit({
transactionId: 'drag-1',
pageId: 'page-main',
selectedIds: ['shape-a'],
patches: [
{
id: 'shape-a',
before: loadDocument(v1).shapes[0],
after: { ...loadDocument(v1).shapes[0], x: 90 },
},
],
});
history.undo((p) => applied.push(p));
expect(applied).toHaveLength(1);
expect((applied[0] as Array<{ after: { x: number } }>)[0].after.x).toBe(1);
});
it('rejects a broken reference instead of sending it to the Renderer', () => {
expect(() =>
loadDocument({
schemaVersion: 3,
pages: [{ id: 'page-main', shapeIds: ['shape-missing'] }],
shapes: [],
}),
).toThrow('INVALID_V3_PAGE');
});
});
运行 npm exec vitest run src/engine/persistence src/engine/history。预期所有 Migration、恢复入口幂等性、损坏隔离、Command Inversion,以及“applyAtomically 抛出异常但不移动栈”的测试都通过。在浏览器中运行 npm exec playwright test tests/crash-recovery.spec.ts。分别在写入 staging、写入 revision 以及切换 committed 指针时强制重新加载。恢复结果只能是之前已提交的状态或完整的新提交状态——绝不能是半个 Document。然后打开两个标签页,从同一个基础 revision 保存。其中一个必须收到冲突,而不是悄无声息地覆盖另一个。
故意破坏它
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 在 staging 进行到一半时崩溃 | Shapes 或 JSON 不完整 | 双槽 revision/checksum | 仅在验证完成后以原子方式提升为 committed | 重新加载故障测试 | 丢弃 staging 并读取旧槽 |
v1 缺少 x | Renderer 收到 NaN | INVALID_V1_SHAPE | 在入口处进行结构验证 | 缺失字段夹具 | 隔离 Payload 并提供恢复选项 |
| 运行恢复入口两次 | 尺寸增长两次 | 幂等快照差异 | 每一步只接受其声明的源版本;当前版本直接返回 | load(load(v1)) | 从原始备份重新运行 |
| 在 v2→v3 过程中抛出异常 | 一半记录有 fill;另一半有 color | 输入哈希和缺失的输出 | 在纯函数中构建新对象,然后进行验证 | 在第 N 条记录处注入异常 | 保留已提交的 v2 |
| 跨 Page Switch 执行 Undo | 看起来什么也没发生 | step.pageId 与 Session page 不同 | Undo 后导航到受影响的对象并将其聚焦 | 双页面重放 | 保持 Document 一致 |
| Asset 在仍被引用时被删除 | 导出或渲染失败 | 语义 missing asset 验证 | 使用墓碑/回退;绝不能假装它已就绪 | 悬空 Asset 夹具 | 显示缺失的 Asset |
| 旧客户端打开由新客户端保存的数据 | Unknown Record 被消费 | schemaVersion/protocol 日志 | 旧客户端采用只读模式或明确拒绝 | 向前版本夹具 | 保留原始字节并要求升级 |
用证据验收
| 自动化证据 | 手动证据 | 通过条件 |
|---|---|---|
| Migration 夹具、幂等性测试、Command Inversion、双槽崩溃重载 | 在恢复 UI 中打开旧 revision,并核对通知、选项和 Document 内容 | 所有自动化测试均通过;用户能够识别恢复来源,且不存在任何不完整记录 |
| 静态作用域审计和 Store 集成测试 | 切换页面、刷新并使用两个标签页,同时观察 Session 和 Presence 的生命周期 | Document、Session 和 Presence 绝不会相互持久化 |
| 要证明的属性 | 权威证据 |
|---|---|
| Create/Move/Resize/Delete 可逆 | 对每个 Command 进行 apply→invert 属性测试 |
| Drag 只占一个步骤 | 每个 transactionId 对应一个 History Step |
| 旧数据绝不会直接进入运行时 | loadDocument(unknown): DocV3 是唯一入口点 |
| Migration 独立可靠 | v1/v2 夹具、幂等性测试和故障注入测试 |
| 崩溃/并发绝不会造成部分写入或静默覆盖 | 双槽重载、单事务指针切换、双标签页 revision 冲突测试以及 checksum |
| 三种状态作用域没有混用 | 审计 Document/Session/Presence Store 的作用域 |
- Migration 有独立测试;输入得到保留,输出经过验证,重复加载会返回相同结果。
- Document 和 Session 使用不同的键、Schemas 和 Repositories;Presence 不进行持久写入。
- Hover、对齐参考线、瞬态 Preview 和 Cursor 绝不会进入 Undo 或 Autosave。
- 旧客户端绝不会静默删除 Unknown Record 并将结果写回。
- 新 Command 会清空 Redo;失败的 Batch 不会递增 revision。
- 恢复 UI 会说明恢复了哪个 revision,而不只是说“出了点问题”。
给五岁小孩解释
不要使用“Command”“Transaction”或“Migration”这些词,解释以下问题:为什么把小汽车从桌子左边连续拖到右边后,按一次返回就应该让它回到开始的位置?为什么不能把一个三年前的盒子直接倒在今天的游戏桌上?为什么不应该把你的朋友此刻挥手的位置封进永久保存的盒子里?
一个不使用术语的好答案
一次完整的推动只有一个目的,所以当你松手后,我们会拍一张“之前和之后”的照片。返回一次,小汽车就会回到这次推动前的位置。旧盒子的隔间和今天的不一样,所以我们会先检查有没有损坏的东西,再把所有东西逐个盒子搬过去,并且只有在新盒子完整之后才替换原来的盒子。共享的玩具说明应该长期保存;你自己的视角可以放进个人背包;而你朋友的手此刻所在的位置变化得很快,所以他们断开连接后,直接忘掉它就可以了。