JEPA4Japan · 教程

第 5 章:给像素世界建立档案库

4,894字 15分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

定义稳定 ID、场景图、形状/资产/绑定记录,以及独立于渲染器的领域适配器边界。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

拿一张全家福,然后把照片和一叠家庭登记卡放在桌上。照片可以告诉你:“这里有三个人。”而卡片记录的是每个人的姓名、一个永不改变的编号、谁属于哪个家庭、谁站在谁前面,以及谁牵着谁的手。现在从照片中剪掉一个人,然后问:他们的姓名和家庭关系会自动从卡片上消失吗?把照片换成一幅水彩画。他们的编号需要改变吗?

先预测一下:如果两个孩子都叫“小蓝”,仅凭姓名能可靠地找到同一个人吗?如果一张卡片上写着“我的父节点是 B”,而 B 的卡片上写着“我的父节点是 A”,当你不断向上追溯父节点时,会发生什么?

  1. 分配唯一编号身份不随外观改变
  2. 记录关系父子、顺序、连接
  3. 检查登记簿拒绝孤儿节点和循环
  4. 选择一种投影Canvas 或 DOM
照片只展示今天的外观;登记簿回答“这是谁?”

本章唯一的真理是:文档模型(Document Model)是事实来源;渲染器(Renderer)只是一种投影。 像素不包含稳定 ID、父子关系、权限、连接或业务身份。

将这些玩具对应到 Canvas 工程

家庭登记游戏Canvas Lab职责
整本登记簿DocumentRecord文档版本和页面入口点
每一页家庭登记PageRecord一个场景及其根 Shape 顺序
人员卡片ShapeRecord稳定 ID、类型、父节点、局部变换和属性
家庭照片/附件AssetRecord图像等资源的元数据
牵手BindingRecordConnector 与目标之间的语义连接
父母与子女父/子场景图局部变换沿祖先链转换为世界变换
前排与后排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。
  • 派生状态和会话状态不会写入持久文档。
  • 渲染器/几何注册表可以独立替换,并且对缺失实现具有明确的兼容性行为。
  • 已经注入并恢复了删除父项、循环、未知形状、顺序冲突和渲染器缺失等情况。

向五岁小孩解释

不要使用“文档”“渲染器”“场景图”“注册表”或“派生状态”这些词,回答:为什么把一张全家福换成卡通画,不应该改变孩子身份证上的号码?为什么不应该把“我今天指着谁”写进永久的家庭名册?

展开一个不含术语的好答案

照片和卡通画只是同一个家庭的两种样子。孩子仍然是同一个孩子;他们的姓名、号码和家庭关系不会随着绘画工具而改变。指着某个人只是在描述你此刻正在做什么,明天由另一个人翻开这本册子时,不应该继承你的手势。我们把不会随着绘画风格改变的身份和关系写在永久卡片上,把今天指向谁写在另一张临时便笺上。这样,无论更换画家、合上册子还是重新打开它,我们都不会把一个人误认成另一个人。