课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
拿一张全家福,然后把照片和一叠家庭登记卡放在桌上。照片可以告诉你:“这里有三个人。”而卡片记录的是每个人的姓名、一个永不改变的编号、谁属于哪个家庭、谁站在谁前面,以及谁牵着谁的手。现在从照片中剪掉一个人,然后问:他们的姓名和家庭关系会自动从卡片上消失吗?把照片换成一幅水彩画。他们的编号需要改变吗?
先预测一下:如果两个孩子都叫“小蓝”,仅凭姓名能可靠地找到同一个人吗?如果一张卡片上写着“我的父节点是 B”,而 B 的卡片上写着“我的父节点是 A”,当你不断向上追溯父节点时,会发生什么?
- 分配唯一编号身份不随外观改变
- 记录关系父子、顺序、连接
- 检查登记簿拒绝孤儿节点和循环
- 选择一种投影Canvas 或 DOM
本章唯一的真理是:文档模型(Document Model)是事实来源;渲染器(Renderer)只是一种投影。 像素不包含稳定 ID、父子关系、权限、连接或业务身份。
将这些玩具对应到 Canvas 工程
| 家庭登记游戏 | Canvas Lab | 职责 |
|---|---|---|
| 整本登记簿 | DocumentRecord | 文档版本和页面入口点 |
| 每一页家庭登记 | PageRecord | 一个场景及其根 Shape 顺序 |
| 人员卡片 | ShapeRecord | 稳定 ID、类型、父节点、局部变换和属性 |
| 家庭照片/附件 | AssetRecord | 图像等资源的元数据 |
| 牵手 | BindingRecord | Connector 与目标之间的语义连接 |
| 父母与子女 | 父/子场景图 | 局部变换沿祖先链转换为世界变换 |
| 前排与后排 | Z 轴顺序 | 决定绘制顺序和命中优先级 |
| 摄影师名单 | 渲染器注册表 | 类型 → 如何投影 |
| 身高测量工具 | 几何注册表 | 类型 → 几何查询 |
| 今天正指向谁 | 会话选择状态 | 不属于持久化文档 |
这个类比有其局限。Shape 可能没有现实世界中的“父节点”,而 Group 也不是一个人。多个 Shape 可以引用同一个 Asset,而 Binding 并不是父/子关系。现实中的登记卡很少变更;编辑器中的 Document 则会由命令频繁更新。这个比喻强调的是身份和关系,并不要求将每棵子树都存储为嵌套 JSON。运行时代码使用规范化记录,以便按 ID 进行验证和更新。
先消除那些误导性的直觉
- “只要 Canvas 画出来了,它就已经保存了。” Bitmap 只存储颜色。重新加载、Resize 或导出语义数据时,它无法还原对象。
- “把
CanvasRenderingContext2D放进 Shape 很方便。” Context 无法序列化,而且会将模型与 Renderer 锁死。Shape 只存储领域数据。 - “也把 Selection 保存下来,方便用户回来后继续操作。” Selection、Hover 和相机通常属于 Session;多人协作中的 Presence 又是另一个层级。把它们混入 Document 会污染 Undo、协作和持久化。
- “用数组索引作为对象 ID。” 重新排序、插入和协作都会改变索引。身份必须稳定且不可变。
- “Group 只需存储一个绝对坐标。” 子节点需要局部变换。世界变换由场景图推导得出;同时存储两者会让它们逐渐产生偏差。
- “立即丢弃未知 Shape。” 如果旧客户端打开了来自新客户端的数据,静默删除会破坏文档。应隔离并保留原始记录,同时显示兼容性占位符。
生产级工具包
前置约定
前置条件是第 4 章的 Matrix2D 及其空间命名。Canvas Lab 规定 Document 只包含持久状态。SessionState={selection,camera,activeTool} 与其分离,而 Presence 在本章中更不能进入 Document。每次写入都必须经过命令边界。Renderer 接收只读快照。业务领域对象的 ID 在适配为 Canvas 记录后仍会保留。
形式化知识
稳定 ID 是不可变的身份标识,而不是显示名称。一个 Document 聚合 Pages、Shapes、Assets 和 Bindings。Page 选择一个可见场景。Shape 是带有类型和属性的视觉记录。Group 通过父/子关系构成场景图。Asset 独立管理二进制资源。Binding 表达跨 Shape 的关系,例如 Connector 与端口之间的关系。父节点 ID 与每个 Page 的根节点列表共同构成一棵树或森林,因此必须禁止循环和悬空的跨 Document 引用。
Z 轴顺序不能依赖偶然的对象迭代顺序。它可以使用每个父节点的 childIds 顺序,也可以使用显式的可比较键,但出现并列时必须采用确定性的决胜规则。局部变换存储在记录中;世界变换则是由祖先链产生的派生状态。选择边界、世界边界、可见性以及缓存的 Path 同样是派生状态,不应写回事实来源。
领域模型描述订单、仓库和审批等业务概念。视觉模型描述矩形、文本和连接器端口。适配器位于两者之间:Business Domain Object → Canvas Document Record → Geometry/Renderer → Pixels or DOM。一个业务对象可能生成多个 Shape,而一个 Shape 也可能只是没有业务含义的注释。两者不能直接画等号。
规范化记录在线路格式中可以表示为记录数组或 ID 字典;运行时索引使用 Map。反序列化会先执行模式验证,再构建索引,并检查重复 ID、未知记录、孤儿节点、循环和排序冲突。不可变身份意味着“更改 ID”不是普通的 Update。若要复制,请创建一个新 ID,并显式修复 Bindings。
渲染器注册表映射 shape.type → ShapeRenderer;几何注册表映射 shape.type → ShapeGeometryProvider。即使缺少 Renderer,Geometry 和 Document 仍然可以工作,而且 Renderer 可以返回未知对象占位符。命令边界将“我想移动这个对象”转换为一个 MoveShapeCommand,验证权限和前置条件,并以原子方式提交。这样可以防止 UI 或 Renderer 代码任意修改 Map。
证据与兼容性
这里的核心类型仅依赖 JavaScript 数据,而不依赖 Canvas 或 DOM,因此可以在 Node 测试中运行。线格式仅使用可由 JSON 表示的值。Map、Set、DOMMatrix 和函数无法直接存储为 JSON 文档。即使你使用 structuredClone 创建进程内快照,也不要把它误认为长期持久化格式。
以上来源已于 2026-08-29 核查。长期兼容性应由明确的模式版本和后续迁移来保障。切勿依赖 JS 引擎偶然形成的对象属性顺序或原型。
本章的工程增量
**起点:**第 3 章的临时 Shape[] 混入了 selectedIds。**终点:**持久文档与会话相互分离;五种记录类型、两个注册表、验证机制和命令接口均可在没有 Canvas 的 Node 环境中运行。
canvas-lab/src/lab/ch05/
records.ts
document-store.ts
registries.ts
document-store.test.ts
完整的核心类型和验证器:
export type Transform = Readonly<{
x: number;
y: number;
rotation: number;
scaleX: number;
scaleY: number;
}>;
export type DocumentRecord = Readonly<{
id: string;
type: 'document';
schemaVersion: 1;
pageIds: readonly string[];
}>;
export type PageRecord = Readonly<{
id: string;
type: 'page';
name: string;
rootShapeIds: readonly string[];
}>;
export type ShapeRecord = Readonly<{
id: string;
type: 'shape';
shapeType: string;
pageId: string;
parentId: string | null;
childIds: readonly string[];
transform: Transform;
props: Readonly<Record<string, unknown>>;
businessObjectId: string | null;
}>;
export type AssetRecord = Readonly<{
id: string;
type: 'asset';
kind: 'image';
source: string;
width: number;
height: number;
}>;
export type BindingRecord = Readonly<{
id: string;
type: 'binding';
bindingType: string;
fromShapeId: string;
toShapeId: string;
props: Readonly<Record<string, unknown>>;
}>;
export type AnyRecord = DocumentRecord | PageRecord | ShapeRecord | AssetRecord | BindingRecord;
export type WireDocument = Readonly<{ records: readonly AnyRecord[] }>;
export type RuntimeDocument = Readonly<{
document: DocumentRecord;
pages: ReadonlyMap<string, PageRecord>;
shapes: ReadonlyMap<string, ShapeRecord>;
assets: ReadonlyMap<string, AssetRecord>;
bindings: ReadonlyMap<string, BindingRecord>;
unknown: readonly AnyRecord[];
}>;
export type SessionState = {
selectedShapeIds: ReadonlySet<string>;
camera: { x: number; y: number; zoom: number };
activeTool: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringList(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function assertUnique(label: string, values: readonly string[]): void {
if (new Set(values).size !== values.length) throw new Error(`${label} contains duplicate IDs`);
}
function parseRecords(input: unknown): AnyRecord[] {
if (!isObject(input) || !Array.isArray(input.records))
throw new Error('Document payload must contain a records array');
const records: AnyRecord[] = [];
for (const value of input.records) {
if (!isObject(value) || typeof value.id !== 'string' || value.id.length === 0)
throw new Error('Every record needs a non-empty string id');
if (value.type === 'document') {
if (value.schemaVersion !== 1 || !isStringList(value.pageIds))
throw new Error(`Invalid document record: ${value.id}`);
} else if (value.type === 'page') {
if (typeof value.name !== 'string' || !isStringList(value.rootShapeIds))
throw new Error(`Invalid page record: ${value.id}`);
} else if (value.type === 'shape') {
const parentIsValid = value.parentId === null || typeof value.parentId === 'string';
const businessIdIsValid =
value.businessObjectId === null || typeof value.businessObjectId === 'string';
const transform = value.transform;
const transformIsValid =
isObject(transform) &&
['x', 'y', 'rotation', 'scaleX', 'scaleY'].every((key) => Number.isFinite(transform[key]));
if (
typeof value.shapeType !== 'string' ||
typeof value.pageId !== 'string' ||
!parentIsValid ||
!isStringList(value.childIds) ||
!transformIsValid ||
!isObject(value.props) ||
!businessIdIsValid
)
throw new Error(`Invalid shape record: ${value.id}`);
} else if (value.type === 'asset') {
if (
value.kind !== 'image' ||
typeof value.source !== 'string' ||
!Number.isFinite(value.width) ||
!Number.isFinite(value.height)
)
throw new Error(`Invalid asset record: ${value.id}`);
} else if (value.type === 'binding') {
if (
typeof value.bindingType !== 'string' ||
typeof value.fromShapeId !== 'string' ||
typeof value.toShapeId !== 'string' ||
!isObject(value.props)
)
throw new Error(`Invalid binding record: ${value.id}`);
} else {
throw new Error(`Unknown record type on ${value.id}`);
}
records.push(value as AnyRecord);
}
return records;
}
export function loadDocument(wire: unknown, knownShapeTypes: ReadonlySet<string>): RuntimeDocument {
const records = parseRecords(wire);
const ids = new Set<string>();
for (const record of records) {
if (ids.has(record.id)) throw new Error(`Duplicate id: ${record.id}`);
ids.add(record.id);
}
const documents = records.filter((r): r is DocumentRecord => r.type === 'document');
if (documents.length !== 1) throw new Error('Exactly one document record is required');
const pages = new Map(
records.filter((r): r is PageRecord => r.type === 'page').map((r) => [r.id, r]),
);
const allShapes = records.filter((r): r is ShapeRecord => r.type === 'shape');
const shapes = new Map(allShapes.map((r) => [r.id, r]));
const assets = new Map(
records.filter((r): r is AssetRecord => r.type === 'asset').map((r) => [r.id, r]),
);
const bindings = new Map(
records.filter((r): r is BindingRecord => r.type === 'binding').map((r) => [r.id, r]),
);
assertUnique('document.pageIds', documents[0].pageIds);
const documentPageIds = new Set(documents[0].pageIds);
for (const pageId of documentPageIds)
if (!pages.has(pageId)) throw new Error(`Missing page: ${pageId}`);
for (const page of pages.values()) {
if (!documentPageIds.has(page.id)) throw new Error(`Page ${page.id} is outside the document`);
assertUnique(`Page ${page.id} rootShapeIds`, page.rootShapeIds);
for (const rootId of page.rootShapeIds) {
const root = shapes.get(rootId);
if (!root || root.pageId !== page.id || root.parentId !== null)
throw new Error(`Broken root order ${page.id} -> ${rootId}`);
}
}
for (const shape of allShapes) {
const page = pages.get(shape.pageId);
if (!page || !documentPageIds.has(shape.pageId))
throw new Error(`Shape ${shape.id} has missing page ${shape.pageId}`);
assertUnique(`Shape ${shape.id} childIds`, shape.childIds);
if (shape.parentId) {
const parent = shapes.get(shape.parentId);
if (!parent) throw new Error(`Shape ${shape.id} has missing parent ${shape.parentId}`);
if (parent.pageId !== shape.pageId || !parent.childIds.includes(shape.id))
throw new Error(`Broken parent link ${shape.parentId} -> ${shape.id}`);
if (page.rootShapeIds.includes(shape.id))
throw new Error(`Child shape ${shape.id} also appears in root order`);
} else if (!page.rootShapeIds.includes(shape.id)) {
throw new Error(`Root shape ${shape.id} is missing from page order`);
}
for (const childId of shape.childIds) {
const child = shapes.get(childId);
if (!child || child.parentId !== shape.id || child.pageId !== shape.pageId)
throw new Error(`Broken child link ${shape.id} -> ${childId}`);
}
}
const visiting = new Set<string>(),
visited = new Set<string>();
const visit = (id: string): void => {
if (visiting.has(id)) throw new Error(`Group cycle at ${id}`);
if (visited.has(id)) return;
visiting.add(id);
for (const child of shapes.get(id)?.childIds ?? []) visit(child);
visiting.delete(id);
visited.add(id);
};
for (const id of shapes.keys()) visit(id);
for (const binding of bindings.values()) {
if (!shapes.has(binding.fromShapeId) || !shapes.has(binding.toShapeId))
throw new Error(`Binding ${binding.id} has a missing endpoint`);
}
const unknown = allShapes.filter((shape) => !knownShapeTypes.has(shape.shapeType));
return { document: documents[0], pages, shapes, assets, bindings, unknown };
}
export type DocumentCommand =
| { type: 'move-shape'; shapeId: string; dx: number; dy: number }
| { type: 'delete-shape'; shapeId: string };
export function executeCommand(runtime: RuntimeDocument, command: DocumentCommand): WireDocument {
if (command.type !== 'move-shape')
throw new Error('Delete requires a cascade policy and is not implemented');
const target = runtime.shapes.get(command.shapeId);
if (!target) throw new Error(`Unknown shape: ${command.shapeId}`);
const moved: ShapeRecord = {
...target,
transform: {
...target.transform,
x: target.transform.x + command.dx,
y: target.transform.y + command.dy,
},
};
const records: AnyRecord[] = [runtime.document, ...runtime.pages.values()];
for (const shape of runtime.shapes.values()) records.push(shape.id === moved.id ? moved : shape);
records.push(...runtime.assets.values(), ...runtime.bindings.values());
return { records };
}
parseRecords 是本章最小可运行的模式关卡。未知记录类型、字段缺失和非有限变换都会在创建 Map 之前导致失败;相比之下,未知的 shapeType 会被保留为兼容性记录。生产项目可以用 Zod、Valibot 或 JSON Schema 替换这段代码,但不能移除“先验证,后规范化”这一边界。rootShapeIds/childIds 还承载同级元素的 Z 顺序。必须同时检查唯一性和双向父子一致性,否则“稳定排序”仍只是一厢情愿。
目前有意拒绝删除命令。父项、子项、绑定和资产引用需要采用由 ADR 选定的级联、重新指定父项或拒绝策略;绝不能悄无声息地留下孤立项。这是一条“明确边界”,而不是假装已经完整实现的空壳。
注册表接口让几何逻辑保持独立于 Canvas:
export type Bounds = { x: number; y: number; width: number; height: number };
export interface ShapeGeometryProvider {
localBounds(shape: ShapeRecord): Bounds;
containsLocalPoint(shape: ShapeRecord, point: { x: number; y: number }): boolean;
}
export interface ShapeRenderer<TTarget> {
render(shape: ShapeRecord, target: TTarget): void;
}
export class Registry<T> {
private entries = new Map<string, T>();
constructor(private readonly fallback?: T) {}
register(type: string, implementation: T): void {
if (this.entries.has(type)) throw new Error(`Duplicate registry entry: ${type}`);
this.entries.set(type, implementation);
}
resolve(type: string): T {
const value = this.entries.get(type);
if (value) return value;
if (this.fallback) return this.fallback;
throw new Error(`Missing registry entry: ${type}`);
}
}
渲染器注册表使用兼容性渲染器作为后备方案。几何注册表通常不应捏造几何信息,因此可以不提供后备方案,让能力缺失明确失败。这可以防止将“绘制未知占位符”误解为“这个未知形状已经能够被精确命中”。
测试覆盖重复 ID、循环、恒等性以及渲染器替换:
import { describe, expect, it } from 'vitest';
import { loadDocument, type AnyRecord, type SessionState, type WireDocument } from './records';
import { Registry } from './registries';
const base = (): AnyRecord[] => [
{ id: 'doc', type: 'document', schemaVersion: 1, pageIds: ['page'] },
{ id: 'page', type: 'page', name: 'Main', rootShapeIds: ['order'] },
{
id: 'order',
type: 'shape',
shapeType: 'business-card',
pageId: 'page',
parentId: null,
childIds: [],
transform: { x: 10, y: 20, rotation: 0, scaleX: 1, scaleY: 1 },
props: { label: 'Order' },
businessObjectId: 'order-42',
},
];
describe('document invariants', () => {
it('rejects duplicate immutable identities', () => {
const records = base();
records.push({ ...records[2] });
expect(() => loadDocument({ records } as WireDocument, new Set(['business-card']))).toThrow(
'Duplicate',
);
});
it('rejects a group cycle', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: [] } as AnyRecord;
records[2] = { ...records[2], parentId: 'group', childIds: ['group'] } as AnyRecord;
records.push({
id: 'group',
type: 'shape',
shapeType: 'group',
pageId: 'page',
parentId: 'order',
childIds: ['order'],
transform: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
props: {},
businessObjectId: null,
});
expect(() => loadDocument({ records }, new Set(['business-card', 'group']))).toThrow('cycle');
});
it('rejects duplicate root z-order entries before rendering', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: ['order', 'order'] } as AnyRecord;
expect(() => loadDocument({ records }, new Set(['business-card']))).toThrow('duplicate');
});
it('preserves identity and Session when a renderer is replaced', () => {
const runtime = loadDocument({ records: base() }, new Set(['business-card']));
const session: SessionState = {
selectedShapeIds: new Set(['order']),
camera: { x: 0, y: 0, zoom: 1 },
activeTool: 'select',
};
const before = JSON.stringify([...runtime.shapes.values()]);
const canvas = new Registry<(id: string) => string>(() => 'unknown placeholder');
const dom = new Registry<(id: string) => string>(() => '<div>unknown</div>');
canvas.register('business-card', (id) => `canvas:${id}`);
dom.register('business-card', (id) => `<article data-id="${id}" />`);
expect(canvas.resolve('business-card')('order')).not.toBe(
dom.resolve('business-card')('order'),
);
expect(JSON.stringify([...runtime.shapes.values()])).toBe(before);
expect(runtime.shapes.get('order')?.businessObjectId).toBe('order-42');
expect([...session.selectedShapeIds]).toEqual(['order']);
});
it('round-trips an unknown shape and resolves a compatibility renderer', () => {
const records = base();
records[2] = { ...records[2], shapeType: 'future-card', props: { future: 7 } } as AnyRecord;
const runtime = loadDocument({ records }, new Set(['business-card']));
const renderers = new Registry<(id: string) => string>((id) => `unknown:${id}`);
expect(runtime.unknown.map((record) => record.id)).toEqual(['order']);
expect(runtime.shapes.get('order')?.props).toEqual({ future: 7 });
expect(renderers.resolve('future-card')('order')).toBe('unknown:order');
});
});
运行 npx vitest run src/lab/ch05/document-store.test.ts;预期得到 5 passed。然后重新运行第 3 章的视觉测试:现在渲染器从新的 RuntimeDocument 中读取记录,像素应保持不变。回到注册簿的比喻:更换摄影师或艺术风格不会改变卡片编号,而你今天指向谁,也不会被写入永久档案。
故意破坏它
| 注入项 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 删除父项却不处理子项 | 子形状没有通向世界坐标的路径 | 验证器报告父项缺失 | 定义拒绝/重新指定父项/级联策略 | 测试每种删除策略 | 从事务快照恢复 |
| A→B→A 分组循环 | 递归溢出或卡死 | DFS 的访问中集合检测到循环 | 提交前拒绝循环 | 双节点循环和自循环测试 | 拒绝错误命令 |
| 线格式数组中存在重复 ID | 后面的值悄无声息地覆盖前面的值 | 规范化前后的数量不同 | 构建 Map 前检查 ID | 重复项测试 | 隔离错误载荷 |
| 同一父项下存在重复/冲突顺序 | 绘制顺序不稳定 | 两次渲染跟踪结果不同 | 仅使用一份 childIds 顺序并验证唯一性 | 排列测试 | 重建稳定顺序 |
| 未知形状类型 | 输出空白或崩溃 | 未知项列表非空 | 保留记录、显示占位符、维持只读 | 未知属性往返测试 | 不要删除原始记录 |
| 渲染器缺失 | 只有该类型无法投影 | 注册表 resolve 进入后备方案 | 兼容性渲染器 | 文档/几何测试仍可运行 | 加载后备方案 |
| 将选择状态混入文档 | 保存/协作产生幽灵选择状态 | 模式中包含 selected | 移至 SessionStore | 持久化快照不包含选择状态 | 移除字段并迁移 |
对于每种情况,都遵循“注入 → 观察症状 → 验证错误代码/记录 ID → 修复验证或事务 → 运行 Node 回归测试 → 从最后一个有效文档恢复”的流程。不要让渲染器的 try/catch 吞掉模型损坏;那会把确凿的错误伪装成视觉上的空白。
用证据过关
| 关卡 | 自动/手动 | 证据 |
|---|---|---|
| 文档不依赖渲染器 | 自动 | 记录模块不导入 DOM/Canvas,且 Node 测试通过 |
| 替换渲染器不会改变文档 | 自动 | 使用 Canvas/DOM 实现前后的 JSON 完全相同 |
| 选择状态能够保留且不污染持久化数据 | 自动 | 会话单独保留;文档快照中没有选择状态 |
| 持久化格式无需重写 | 自动 | 不同注册表读取同一个 WireDocument |
| 业务 ID 保持稳定 | 自动/手动 | 替换适配器/渲染器后仍为 order-42 |
- 全部五种记录类型,以及分组、父项/子项、Z 顺序和局部变换,都具有明确契约。
- 领域模型、视觉模型、几何逻辑和渲染器不会相互冒充。
- 规范化记录会在构建索引前检查重复 ID。
- 派生状态和会话状态不会写入持久文档。
- 渲染器/几何注册表可以独立替换,并且对缺失实现具有明确的兼容性行为。
- 已经注入并恢复了删除父项、循环、未知形状、顺序冲突和渲染器缺失等情况。
向五岁小孩解释
不要使用“文档”“渲染器”“场景图”“注册表”或“派生状态”这些词,回答:为什么把一张全家福换成卡通画,不应该改变孩子身份证上的号码?为什么不应该把“我今天指着谁”写进永久的家庭名册?
展开一个不含术语的好答案
照片和卡通画只是同一个家庭的两种样子。孩子仍然是同一个孩子;他们的姓名、号码和家庭关系不会随着绘画工具而改变。指着某个人只是在描述你此刻正在做什么,明天由另一个人翻开这本册子时,不应该继承你的手势。我们把不会随着绘画风格改变的身份和关系写在永久卡片上,把今天指向谁写在另一张临时便笺上。这样,无论更换画家、合上册子还是重新打开它,我们都不会把一个人误认成另一个人。