JEPA4Japan · 教程

第 13 章:时间机器与旧箱子

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

用事务实现撤销/重做,用验证和幂等迁移处理版本化数据,并将文档、会话与在线状态分离。

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

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

本章唯一的真相:History 处理当前游戏会话内的时间;Migration 处理旧盒子不同版本之间的时间。

准备一辆玩具车、三张桌面照片和三个纸板盒。首先,让孩子把车从 A 推到 B,再从 B 推到 C。每一步都用照片记录,因此按一次“返回”就能让车从 C 回到 B;这就是当前会话内的 Undo。接下来,分别给盒子贴上 v1、v2 和 v3 标签:v1 盒子只写着“红色汽车”,v2 还记录了它的位置,v3 则用结构化的涂装标签取代颜色。如果三年后找到一个 v1 盒子,绝不能把其中缺失的字段直接交给今天的游戏规则。先检查它,再按版本逐个替换盒子。

继续之前先预测答案:如果孩子连续把车从 A 拖到 B,手经过了 87 个点,那么一次 Undo 应该让车后退一个像素,还是一路回到 A?它应该回到 A,因为一个手势就是一个 Transaction。现在再预测一下:把“光标正悬停在汽车上方”拍成照片,并在以后重新打开盒子时恢复这一状态,是否合理?不合理;那只是桌面上一闪而过的状态。

  1. 完成一个动作从 A 推到 B
  2. 保留可逆记录每个手势拍一张照片
  3. 检查旧盒子的标签拒绝损坏的数据
  4. 按顺序替换每个盒子v1 → v2 → v3
  5. 仅在完成后更换锁具原子保存
先说结论:如果迁移进行到一半时断电,旧盒子还能打开吗?能。在新槽位成功写入之前,绝不要覆盖旧槽位。

这个游戏有两条时间线。照片栈属于页面打开后的操作历史;版本号属于数据格式的历史。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;可以保存在本地
挥动的手所在的位置PresenceCursor 和在线状态;过期后可以安全丢弃

类比到此为止:真正的 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 缺少 xRenderer 收到 NaNINVALID_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”这些词,解释以下问题:为什么把小汽车从桌子左边连续拖到右边后,按一次返回就应该让它回到开始的位置?为什么不能把一个三年前的盒子直接倒在今天的游戏桌上?为什么不应该把你的朋友此刻挥手的位置封进永久保存的盒子里?

一个不使用术语的好答案

一次完整的推动只有一个目的,所以当你松手后,我们会拍一张“之前和之后”的照片。返回一次,小汽车就会回到这次推动前的位置。旧盒子的隔间和今天的不一样,所以我们会先检查有没有损坏的东西,再把所有东西逐个盒子搬过去,并且只有在新盒子完整之后才替换原来的盒子。共享的玩具说明应该长期保存;你自己的视角可以放进个人背包;而你朋友的手此刻所在的位置变化得很快,所以他们断开连接后,直接忘掉它就可以了。