课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从五岁孩子也能理解的游戏开始
本章唯一需要牢记的事实:一张看起来正确的 Canvas 截图,只能证明那一刻的像素相似。模型、交互、视觉效果、无障碍能力和生产环境行为,都必须分别得到验证。
拿出一辆漂亮的玩具消防车。触碰它之前,先预测五件事:车轮能转吗?从桌上掉下来会不会散架?闭着眼能找到刹车吗?不用拖拽、只用一只手能打开车门吗?如果老师离开后它坏了,你能知道它何时坏的吗?鲜红的车身无法回答其中任何一个问题。
现在建立五个检查站。第一站拆开消防车,检查齿轮规则;第二站按固定顺序按下按钮;第三站在不同桌面和不同光照下驾驶;第四站戴上眼罩,只用键盘替代遥控器;第五站安装一个黑匣子,它只记录“发生了哪一类故障,以及持续了多久”。每一站会产生不同的故障证据,也需要不同的修复方式。
- 部件规则无论怎样转动,规则都成立
- 按钮顺序取消绝不会误提交
- 真实桌面浏览器和像素
- 不用鼠标任务仍能完成
- 使用中测量温度绝不窥探私密内容
测试金字塔并不意味着越高的层次就“越高级”。底层规则运行得快,可以探索大量情况。浏览器测试验证真实输入和平台行为,视觉测试验证外观,性能回归测试验证预算。无障碍能力不是最后贴上的标签;它是进入同一份 Document 的另一条路径,必须可以获得焦点、可以描述,也能用键盘操作。
把玩具对应到 Canvas
| 玩具消防车检查 | Canvas Lab 证据 | 它回答的问题 |
|---|---|---|
| 齿轮尺寸量规 | Geometry / Model Unit Test | 数学和不变量成立吗? |
| 随机转动车轮 | Property-Based Test | 在许多有效输入中,属性仍然成立吗? |
| 按钮动作脚本 | Event Replay / State Assertion | 执行了哪个 Transition? |
| 可控秒表 | Deterministic Clock | 双击、长按和动画不依赖真实时间吗? |
| 在真实桌面试驾 | Browser Interaction Test | Pointer、IME 和 Clipboard 真的能工作吗? |
| 从相同角度比较照片 | Visual Regression | DPR、字体或绘制是否意外改变? |
| 戴眼罩触摸凸起标记 | Fallback Semantics / Focus Mapping | Screen Reader 能理解并定位对象吗? |
| 用方向键遥控 | Keyboard Move / Resize | 不用 Drag 能完成任务吗? |
| 更大的按钮 | Target Size / Non-Drag Alternative | 低精度输入仍能工作吗? |
| 黑匣子 | Privacy-Safe Telemetry | 生产环境在哪里失败,影响有多大? |
这个类比也有边界:真实辅助技术并不像“戴着眼罩使用”那么简单。用户可能在视觉、运动、认知方面存在差异,也可能同时存在多种差异。单一体验无法代表 Screen Reader、键盘、语音和高对比度需求。黑匣子同样不能记录画布文字、截图、精确轨迹或素材 URL。Correlation ID 只用于连接同一条故障链,不能拿来追踪个人。
先消灭错误直觉
- “我手动点过一次,所以测试完成了。” 人无法稳定重放 Pointer Cancel、竞态、DPR 和 IME 组合输入。手动探索很重要,却不能替代可重复的证据。
- “100% 覆盖率就代表正确。” 执行过一行代码,不能证明 Matrix Round Trip、Command Inversion 或 Migration 属性成立。
- “截图匹配就说明交互正确。” 即使 History 损坏、Focus 损坏,或动作不可逆,最终像素仍可能完全相同。
- “Canvas 无法无障碍,所以一个
aria-label就够了。” 一个覆盖整个黑匣子的标签,无法让用户选中 shape-7、理解它的连接、移动它或调整大小。 - “支持键盘就是用许多次 Pointer Move 模拟 Drag。” 键盘应发出相同的领域 Command,而不是伪造浏览器事件。这样 History、权限和审计才能保持一致。
- “Telemetry 越详细,调试就越容易。” 原始文字、图像、完整坐标序列和用户身份都会泄露业务内容。只收集类别、持续时间、计数、粗粒度环境数据和随机 correlation ID。
- “测试只需要在 Chromium 中运行。” Clipboard、IME、字体、触摸和图形路径在不同浏览器之间存在差异。应根据真实用户和风险选择 Browser Matrix。
生产级背包
前置条件
第 8 章的 GeometryKernel 不能依赖 Canvas Context。第 9 章的 ToolMachine 必须接受 Fake Input Stream。第 13 章的 Command、History、Migration 和 Persistence 需要纯函数边界。如果删除 Renderer 后这些模块就无法导入,说明架构把事实粘在了像素上。添加测试前,应先分离这条边界。
正式知识
单元测试和属性测试。 Matrix Round Trip 检查 inverse(transform(p)) ≈ p。Geometry Invariants 检查 Bounds 包含其几何图形、距离非负,以及相交关系对称。Transformed Bounds 覆盖旋转、负缩放和 epsilon。Command Inversion 必须满足 apply(invert(apply(doc, c))) = doc,同时忽略合理的版本元数据。Migration 测试覆盖夹具、幂等性和损坏隔离。Serialization 测试稳定字段,而不是偶然的对象属性顺序。Property-Based Testing 会生成大量有限数、有效 Document 和事件序列,再把一次失败收缩成最小反例。
状态与事件测试。 Event Replay 把第 7 章中的归一化事件数组发送给 ToolMachine。State Transition Assertions 同时验证 {from,event,to,effect},而不是只检查最终像素。Pointer Cancel、Lost Capture、Blur、被远程删除的对象和 Undo 请求会形成 Race Sequences。测试手动推进 Deterministic Clock,避免不稳定的 setTimeout(300) 失败。Fake Input Stream 不构造 DOM Events,因此可以在 Node 中运行。
浏览器测试。 Playwright 或等效工具验证真实的 Pointer、Keyboard、Clipboard 权限、IME Composition、Drag/Drop 和 Export 下载。Visual Regression 固定浏览器版本、字体、viewport、DPR、配色方案和动画时钟。每个差异阈值都必须可以解释;不要每逢测试失败就更新基准。Browser Matrix 应依据用户数据,至少覆盖 Chromium、Firefox 和 WebKit;无法可靠模拟的路径还应使用真实触摸与手写笔设备。
无障碍能力。 在 Canvas 外构建 DOM Inspector,作为 Alternate Interface。每个可操作 Shape 都有语义 role、名称、关系描述和稳定的 data-shape-id。Focus Mapping 双向同步 Canvas 选择与 DOM 行;远程更改不能抢走焦点。Keyboard Selection 支持 Tab、方向键和多选。Keyboard Move/Resize 发出与 Pointer 输入相同的 Commands。用数值输入或按钮提供 Non-Drag Alternative。把可点击 Handle 的视觉尺寸与交互 Target Size 分开。Screen-Reader Description 应解释类型、名称、位置、连接和状态,而不是朗读像素。Reduced Motion 会停止非必要插值。High Contrast 不能只用颜色表示 Selection。
可观测性。 捕获 Uncaught Error、未处理的 rejection、Render Time、Slow Interaction 和 Save/Migration/Asset Failure,并同时记录 Object Count、Visible Count、Zoom bucket、Input Device、Browser Version 和 Correlation ID。Long Task 条目与自定义性能标记,把一次交互分为 input、model、geometry、render 和 present 阶段。Privacy-Safe Telemetry 禁止 Shape 文字、截图、完整 URL、用户名和精确轨迹。采样率、保留期限和退出机制需要产品策略。
完整 Test Pyramid 按反馈速度排序:Geometry/Model Unit → State Machine → Engine Integration → Browser Interaction → Visual Regression → Performance Regression。它不是只靠不断向上叠层建成的。每个生产故障都应在能够尽早、可靠复现它的最低层获得一项回归测试。
证据与兼容性(验证于 2026-08-29)
关于 Canvas 后备内容和无障碍注意事项,请遵循 WHATWG Canvas element。键盘操作和可见焦点请参见 WCAG 2.2 Keyboard 与 Focus Visible;目标尺寸请参见 WCAG 2.2 Target Size (Minimum)。Reduced Motion 使用 prefers-reduced-motion。运行时指标可以通过 PerformanceObserver 获取,但要先对每种条目类型做特性检测。自动化无障碍扫描不能替代 Screen Reader 和任务级手动测试。
本章工程增量
起点: 第 13 章已经拥有可恢复的 Document,但验证仍只是开发者拖动几个对象并查看截图。终点: 建立六层自动化证据和一个 DOM Inspector;用户不用鼠标也能选择、移动、调整大小和删除;生产故障带有保护隐私的阶段指标。
添加以下文件和接口:
src/engine/testing/FakeInputStream.ts和ManualClock.ts:确定性事件;src/engine/__tests__/properties.test.ts:matrix、geometry、Command 和 Migration 属性;src/ui/CanvasAccessibility.ts:DOM Inspector 和 Focus Mapping;tests/browser/editor-a11y.spec.ts:Keyboard、Clipboard、IME 和 DPR;tests/visual/canvas.spec.ts:固定环境中的截图;src/observability/telemetry.ts:阶段标记、错误类别和脱敏。
下面的 HTML/TypeScript 为 Canvas 提供了可操作的替代接口。它不会把每个像素都转换成 DOM,而是把 Document 中的业务对象投影为列表。按钮直接发出领域 Commands,因此 Pointer 和 Keyboard 共享 History 与权限。请注意,构建工具会编译 TypeScript,并将其作为外部模块加载;不能把 type Shape = ... 直接放进浏览器原生 <script>:
<main class="canvas-shell">
<canvas id="canvas" tabindex="0" aria-label="Workflow canvas" aria-describedby="canvas-help"></canvas>
<p id="canvas-help">Press Tab to enter the object list. Use arrow keys to move, Shift to move faster, +/- to resize, and Delete to remove.</p>
<section aria-labelledby="objects-title">
<h2 id="objects-title">Canvas objects</h2>
<ul id="object-list"></ul>
<p id="canvas-status" role="status" aria-live="polite"></p>
</section>
</main>
<script type="module" src="/src/ui/CanvasAccessibility.ts"></script>
type Shape = {
id: string;
type: 'rect' | 'text';
name: string;
x: number;
y: number;
w: number;
h: number;
};
type Command =
| { type: 'MoveShape'; id: string; dx: number; dy: number }
| { type: 'ResizeShape'; id: string; dw: number; dh: number }
| { type: 'DeleteShape'; id: string };
const shapes: Shape[] = [
{ id: 'shape-start', type: 'rect', name: 'Start', x: 20, y: 40, w: 120, h: 64 },
{ id: 'shape-review', type: 'text', name: 'Manual review', x: 220, y: 40, w: 160, h: 64 },
];
const selected = new Set<string>(),
undoStack: Shape[][] = [];
const list = document.querySelector<HTMLUListElement>('#object-list')!;
const status = document.querySelector<HTMLElement>('#canvas-status')!;
const canvas = document.querySelector<HTMLCanvasElement>('#canvas')!;
function execute(command: Command) {
const i = shapes.findIndex((s) => s.id === command.id);
if (i < 0) return;
const before = structuredClone(shapes);
if (command.type === 'DeleteShape') {
shapes.splice(i, 1);
selected.delete(command.id);
} else if (command.type === 'MoveShape')
shapes[i] = { ...shapes[i], x: shapes[i].x + command.dx, y: shapes[i].y + command.dy };
else
shapes[i] = {
...shapes[i],
w: Math.max(24, shapes[i].w + command.dw),
h: Math.max(24, shapes[i].h + command.dh),
};
undoStack.push(before);
status.textContent = `${command.type} completed; you can undo it`;
renderInspector(command.type === 'DeleteShape' ? undefined : command.id);
drawCanvas();
}
function undo() {
const before = undoStack.pop();
if (!before) return;
const focusedId = (document.activeElement as HTMLElement | null)?.dataset.shapeId;
shapes.splice(0, shapes.length, ...before);
status.textContent = 'Undid the previous action';
renderInspector(focusedId);
drawCanvas();
}
function description(s: Shape) {
return `${s.type === 'text' ? 'Text' : 'Rectangle'} “${s.name}”, position ${s.x}, ${s.y}, size ${s.w} × ${s.h}`;
}
function renderInspector(preferredFocusId?: string) {
const focusedId =
preferredFocusId ?? (document.activeElement as HTMLElement | null)?.dataset.shapeId;
list.replaceChildren(
...shapes.map((s) => {
const li = document.createElement('li'),
button = document.createElement('button');
button.type = 'button';
button.dataset.shapeId = s.id;
button.textContent = s.name;
button.setAttribute('aria-pressed', String(selected.has(s.id)));
button.setAttribute('aria-label', description(s));
button.onclick = () => {
selected.clear();
selected.add(s.id);
renderInspector(s.id);
drawCanvas();
};
button.onkeydown = (event) => {
const step = event.shiftKey ? 10 : 1;
const delta: Record<string, [number, number] | undefined> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
if (delta[event.key]) {
event.preventDefault();
const [dx, dy] = delta[event.key]!;
execute({ type: 'MoveShape', id: s.id, dx, dy });
} else if (event.key === 'Delete') {
event.preventDefault();
execute({ type: 'DeleteShape', id: s.id });
} else if (event.key === '+' || event.key === '=') {
event.preventDefault();
execute({ type: 'ResizeShape', id: s.id, dw: step, dh: step });
} else if (event.key === '-') {
event.preventDefault();
execute({ type: 'ResizeShape', id: s.id, dw: -step, dh: -step });
}
};
li.append(button);
return li;
}),
);
if (focusedId)
list.querySelector<HTMLElement>(`[data-shape-id="${CSS.escape(focusedId)}"]`)?.focus();
}
function drawCanvas() {
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of shapes) {
ctx.strokeStyle = selected.has(s.id) ? '#2563eb' : '#334155';
ctx.strokeRect(s.x, s.y, s.w, s.h);
ctx.fillText(s.name, s.x + 8, s.y + 24);
}
}
addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
undo();
}
});
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', drawCanvas);
renderInspector();
drawCanvas();
回到玩具比喻:DOM 列表就是消防车上那组可触摸的凸点和有名称的按钮,而不是另一辆消防车。它操作相同的 shapes,因此视力用户和 Screen Reader 用户不会进入两套逐渐分叉的业务逻辑。
属性测试与浏览器测试分别验证数学和任务:
import fc from 'fast-check';
import { expect, test } from 'vitest';
test('every invertible translation/rotation/scale in the pure math kernel round-trips', () => {
fc.assert(
fc.property(
fc.record({
x: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
y: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
}),
fc.double({ noNaN: true, min: 0.01, max: 100 }),
fc.double({ noNaN: true, min: -Math.PI, max: Math.PI }),
(point, scale, angle) => {
const c = Math.cos(angle),
s = Math.sin(angle);
const m = { a: c * scale, b: s * scale, c: -s * scale, d: c * scale, e: 37, f: -19 };
const apply = (p: { x: number; y: number }, n: typeof m) => ({
x: n.a * p.x + n.c * p.y + n.e,
y: n.b * p.x + n.d * p.y + n.f,
});
const det = m.a * m.d - m.b * m.c;
const inverse = {
a: m.d / det,
b: -m.b / det,
c: -m.c / det,
d: m.a / det,
e: (m.c * m.f - m.d * m.e) / det,
f: (m.b * m.e - m.a * m.f) / det,
};
const screen = apply(point, m),
roundTrip = apply(screen, inverse);
expect(Math.abs(roundTrip.x - point.x)).toBeLessThan(1e-6);
expect(Math.abs(roundTrip.y - point.y)).toBeLessThan(1e-6);
},
),
{ numRuns: 2_000 },
);
});
import { expect, test } from '@playwright/test';
test('selects and moves an object with keyboard only while preserving focus and History', async ({ page }) => {
await page.goto('/lab');
const start = page.getByRole('button', { name: /Rectangle “Start”/ });
await start.focus();
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
await expect(start).toHaveAttribute('aria-label', /position 21, 50/);
await page.keyboard.press('ControlOrMeta+z');
await expect(start).toHaveAttribute('aria-label', /position 21, 40/);
await page.keyboard.press('ControlOrMeta+z');
await expect(start).toHaveAttribute('aria-label', /position 20, 40/);
await expect(start).toBeFocused();
});
Telemetry 也必须通过运行时白名单。TypeScript 类型无法阻止 as any、旧客户端或第三方 SDK。下面的序列化器只接受类别、持续时间、计数和粗粒度环境数据,并拒绝未知字段。这样可以防止 shapeText、Screenshot、URL、坐标和用户 ID 为了“方便”而混入:
const allowed = new Set([
'name',
'correlationId',
'durationMs',
'objectCount',
'visibleCount',
'zoomBucket',
'inputDevice',
'browserFamily',
'browserMajor',
]);
const eventNames = new Set([
'uncaught_error',
'slow_interaction',
'save_failure',
'migration_failure',
'asset_failure',
]);
export function serializeTelemetry(raw: unknown) {
if (!raw || typeof raw !== 'object') throw new Error('INVALID_TELEMETRY');
const event = raw as Record<string, unknown>;
for (const key of Object.keys(event))
if (!allowed.has(key)) throw new Error(`SENSITIVE_OR_UNKNOWN_FIELD:${key}`);
if (!eventNames.has(String(event.name)) || !/^[a-f0-9-]{36}$/.test(String(event.correlationId)))
throw new Error('INVALID_TELEMETRY');
for (const key of ['durationMs', 'objectCount', 'visibleCount', 'browserMajor']) {
const value = event[key];
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
throw new Error('INVALID_TELEMETRY_NUMBER');
}
return JSON.stringify(event);
}
correlationId 是为每条故障链生成的随机短期 ID。它绝不能从账户、文档或 Shape ID 派生。模式测试必须验证 { ...safe, shapeText: 'confidential' }、screenshot、url、points 和 userId 全部遭到拒绝。服务器必须再次强制实施同一白名单,并应用采样、保留、访问控制和删除策略。
运行 npm exec vitest run src/engine。预期全部 2,000 个 Matrix/Geometry 属性样本、Command Inversion、Migration 和 Serialization 测试通过。运行 npm exec playwright test --project=chromium --project=firefox --project=webkit。预期 Pointer、Keyboard、拒绝 Clipboard 访问、IME、Drag/Drop、Export 和无障碍任务全部通过。运行 npm run test:visual 时,预期基准差异为零;任何更新基准的 PR 都必须说明视觉原因。npm run benchmark 应报告 p50/p95/p99,并与预算比较。
故意破坏它
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 拔掉鼠标 | 无法选择或移动 | 键盘任务停滞 | DOM Inspector + 同源 Command | 纯键盘 Playwright | 保留 Focus 和 Selection |
| 浏览器缩放到 200% | Handle/Overlay 错位 | 截图 + 边界框 | 统一 Screen/CSS 坐标 | 100/200/400% 项目 | 重新计算 overlay |
| Reduced Motion | Camera 仍会飞行动画 | 媒体查询和时钟轨迹 | 把非必要动画时长设为 0 | 模拟测试 | 直接跳到最终状态 |
| 字体缺失 | 换行与 Selection 偏移 | 字体状态 + 视觉差异 | 显式回退;字体就绪后重排 | 阻断字体路由 | 确定性回退 |
| 改变测试 DPR | 像素模糊或快照漂移 | backing size 报告 | 感知 DPR 的 resize | DPR 1/2/3 截图 | 重建位图 |
| Clipboard 权限被拒 | 未处理的 Promise | clipboard_denied telemetry | 提供文字/按钮回退 | 权限拒绝测试 | 保持 Document 不变 |
| Migration Payload 损坏 | 空白页面 | migration_failure + 版本 | 隔离并打开恢复 UI | 损坏夹具 | 读取旧的已提交状态 |
| Screen Reader 只看到 Canvas | 只播报“canvas” | 无障碍树 | 可聚焦对象列表和描述 | VoiceOver/NVDA 任务 | Focus Mapping |
用证据通过
| 自动化证据 | 手动证据 | 通过条件 |
|---|---|---|
| Unit、Property、Replay、Browser、Visual 和 Performance CI | VoiceOver/NVDA 键盘任务、200% Zoom、High Contrast 和触摸目标检查 | 每个自动化层都通过;不用鼠标即可完成 Select/Move/Resize/Delete 手动任务 |
| Telemetry Schema 测试和敏感字段拒绝测试 | 在测试环境检查一条真实 Correlation 链 | 可以定位阶段和故障类别,且不含文字、截图、精确路径或身份 |
| 层次 | 通过证据 | 无法替代它的证据 |
|---|---|---|
| Model/Geometry | 属性不变量和最小反例 | 一张截图 |
| State Machine | Replay 中的每个 Transition | 最终 Shape 位置 |
| Browser | 真实 Pointer/IME/Clipboard/Drag | 使用手写 DOM Events 的单元测试 |
| Visual | 固定环境中的像素差异 | “我看着没问题” |
| Accessibility | 完整的键盘和 Screen Reader 任务 | 自动化扫描零错误 |
| Production | 分阶段 p95、分类错误和相关故障链 | 开发机器控制台 |
- 删除 Renderer 后,Geometry、Document、History、Migration、State Machine 和 Commands 测试仍能导入并运行。
- Pointer Cancel、Race Sequence、Fake Input Stream 和 Deterministic Clock 都有测试。
- Keyboard 可以 Select、Move、Resize 和 Delete,并且提供 Non-Drag Alternative。
- 重绘或远程更新时,Focus 不会无故丢失;Selection 与 DOM Inspector 保持同步。
- Reduced Motion、High Contrast、200% Zoom 和合理的 Target Size 能通过手动任务。
- Telemetry 不包含 Shape 文字、截图、完整 URL、精确轨迹或身份信息。
向五岁孩子解释
不要使用“单元测试”“无障碍”或“Telemetry”这些词。解释为什么消防车的一张漂亮照片,不能证明车轮和刹车能正常工作,也不能证明看不见的人也能使用它。为什么任何一个检查站都不能被移除?如果消防车在真实学校里坏了,它怎样才能留下有用线索,却不读取孩子的秘密?
一个不使用术语的好答案
照片只能告诉我们那一刻外壳的样子。我们必须分别测量各个部件,按下每种按钮,并在真实桌面上试驾。还要保证不会用鼠标或看不见屏幕的人,仍然能找到有名字的东西并执行相同动作。如果消防车在学校变慢或损坏,只记录“哪个部件花了多长时间、发生了哪类故障,以及哪些记录属于同一事故”——绝不能记录孩子画了什么或写了什么。这样,每一种“真正正确”都有属于自己的证据。