课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
准备一张白纸、一张透明塑料片、一盒积木和一块可以擦掉重画的小黑板。在纸上写下“订单 A”,在塑料片上画一根箭头,把积木摆成两个方块,最后在黑板上涂一片蓝色。
继续之前,先预测四件事:哪种材料上的文字最容易编辑?哪一种能让你看到它下面的文字?哪一种可以直接用手移动?擦掉之后,哪一种最难知道那里原来有什么?亲手试一次,然后把“订单 A”里的 A 改成 B。你会发现:“它们都能表示同一幅画”,并不意味着“在每一种材料上完成同一操作的成本都相同”。
- 在纸上写字可阅读,也可选择
- 在薄膜上画线缩放后依然清晰
- 摆放积木每块积木都有身份
- 给黑板上色留下的只有像素
这就是本章唯一的核心事实:无限画布是一种产品交互模型,用户可以在其中平移、缩放和放置对象;它不是一种渲染技术。 DOM、SVG、Canvas 2D、GPU,或几者的组合,都可以实现无限画布。
把玩具翻译成 Canvas 工程
| 玩具材料 | 对应的工程技术 | 真正需要提出的问题 |
|---|---|---|
| 白纸上的文字 | DOM | 用户是否需要输入、选择、听取内容,或让内容自动布局? |
| 透明薄膜上的线条 | SVG | 是否需要矢量几何和可单独寻址的节点? |
| 可擦黑板上的颜色 | Canvas 2D 位图 | 是否需要大量自定义二维像素绘制? |
| 许多厨师同时摆盘 | WebGL / WebGPU | 是否有证据证明批处理或并行计算能够消除瓶颈? |
| 贴在黑板上的纸 | DOM/Canvas 混合方案 | 在 DOM 中保留文本编辑,把图形绘制交给 Canvas |
| 贴在 GPU 显示屏上的纸 | DOM/GPU 混合方案 | 在 DOM 中保留语义层,把高吞吐渲染交给 GPU |
| “一张可以无限移动的桌面” | 无限画布 | 一种不限定底层材料的产品行为 |
这个类比到这里就会失效:物理材料一次只能占据一个位置,而浏览器可以把 DOM 叠在 Canvas 上。SVG 也不是真正的透明薄膜,浏览器最终仍会把它光栅化。GPU 更不是一位永远更快的魔法厨师:数据上传、着色器、设备丢失和无障碍都要付出成本。类比能帮助我们先问对问题,却不能替代测量、规范和兼容性测试。
先消灭具有误导性的直觉
- “无限画布就应该使用
<canvas>。” 错。无限画布只承诺相机与对象交互。当产品包含大量文字、适量对象,并且语义很重要时,DOM/SVG 往往更合适。 - “对象很多就一定要用 Canvas。” 错。总共有十万个对象、但屏幕上只显示十个,与两千个带阴影和图片的可见对象,成本完全不同。先测量可见数量、变化频率和像素面积。
- “Three.js 是 Canvas 2D 的升级版。” 错。Three.js 解决的是场景、相机、网格、材质和深度等三维问题。如果普通二维白板不需要这些能力,Three.js 带来的只会是另一套更复杂的约束。
- “tldraw 用 Canvas 2D 绘制每个 Shape。” 错。tldraw 的官方 Shape 契约要求
component()返回一个 React 组件,而导出路径会生成 SVG。库的名字和产品外观都不能证明它使用何种渲染器。这里描述的是 Shape 的主要渲染架构,并不否认个别组件内部可能使用<canvas>。
生产工程背包
前置契约
本章不要求你了解矩阵或命中测试。你只需要能够运行浏览器、TypeScript 和 Vitest。首先固定产品需求:Canvas Lab 是一个业务流程图编辑器。卡片需要原生标题编辑,连接线缩放时必须保持清晰,网格会频繁移动,导出必须包含 PNG 和结构化 JSON,并且键盘和屏幕阅读器访问都不能缺席。
正式知识:按维度选择,而不是凭信念选择
| 渲染器 | 优势 | 主要成本 | 它在 Canvas Lab 中的位置 |
|---|---|---|---|
| DOM | 原生文本、CSS 布局、语义、焦点和自动化测试 | 大量频繁变化的节点会增加样式与布局成本 | 工具栏、表单、文本编辑和检查器 |
| SVG | 矢量、可寻址的几何节点、CSS 与清晰导出 | 大量持续变化的节点需要测量 DOM 成本 | 小规模连接线或导出的候选方案 |
| Canvas 2D | 直接控制二维绘制、成熟 API,并适合频繁重绘 | 没有保留对象、原生语义或文本编辑 | 教学内核中的网格、形状与覆盖层 |
| WebGL | 成熟的 GPU 管线、批处理与着色器 | 必须自行管理缓冲区、纹理、拾取与上下文丢失 | 分析提供证据后的替换候选 |
| WebGPU | 更现代的 GPU 计算与渲染接口 | 支持范围、设备/管线管理与降级成本 | 渐进增强实验,而非基线 |
| DOM/Canvas | 语义 UI 与高频像素层各司其职 | 两层之间的坐标、焦点和堆叠必须保持同步 | 当前选择 |
| DOM/GPU | 原生 UI 加高吞吐渲染 | 两套生命周期和更高的工程门槛 | 规模很大且 GPU 收益得到证明时 |
评审必须记录每一个维度:原生文本编辑、DOM 语义、无障碍、可见对象数量、对象总数、每秒变化次数、像素效果、视觉与语义导出要求、渲染器替换成本、多人协作模型、目标浏览器,以及团队能否维护几何/GPU 代码。协作本身并不能决定渲染器;它同步的是文档操作,不是像素。
证据与兼容性
浏览器将 <canvas> 定义为一种可以绑定 2d、webgl、webgl2 和 webgpu 等上下文的元素。这个事实本身就说明,该元素并不是某一种具体产品。发布前,应检查目标浏览器的兼容性表并在真实设备上做基准测试;不要把“Baseline”当成性能保证。
以上来源核查于 2026-08-29。WebGPU 的可用性与能力必须在运行时检测;本教程不会把某个浏览器版本号冻结成永久事实。
本章的工程增量
**起点:**一个空页面和一份业务数据。**终点:**DOM、SVG 与 Canvas 2D 实现展示同一张“订单 → 发货”业务图,项目生成第一份 ADR。计划中的文件:
canvas-lab/
src/lab/ch01/render-three-ways.ts
src/lab/ch01/index.html
src/lab/ch01/renderer-choice.ts
src/lab/ch01/renderer-choice.test.ts
docs/adr/0001-canvas-2d-teaching-kernel.md
完整的 HTML 容器:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Canvas Lab / Renderer</title>
</head>
<body>
<main>
<section>
<h1>DOM</h1>
<div id="dom-demo"></div>
</section>
<section>
<h1>SVG</h1>
<svg id="svg-demo" viewBox="0 0 360 140" aria-label="Order process diagram"></svg>
</section>
<section>
<h1>Canvas</h1>
<canvas id="canvas-demo" width="360" height="140">Order to shipping</canvas>
</section>
</main>
<script type="module" src="./render-three-ways.ts"></script>
</body>
</html>
完整的 TypeScript:三条路径使用同一份数据,因此没有任何业务对象被锁定到某个渲染器。
type Card = { id: string; label: string; x: number; y: number };
type Diagram = { cards: readonly Card[]; edge: readonly [string, string] };
export const diagram: Diagram = {
cards: [
{ id: 'order', label: 'Order', x: 20, y: 40 },
{ id: 'ship', label: 'Shipping', x: 240, y: 40 },
],
edge: ['order', 'ship'],
};
function boxStyle(card: Card): string {
return `position:absolute;left:${card.x}px;top:${card.y}px;width:100px;height:56px;border:2px solid #2563eb;border-radius:10px;display:grid;place-items:center`;
}
export function renderDOM(root: HTMLElement, model: Diagram): void {
root.replaceChildren();
root.style.cssText = 'position:relative;width:360px;height:140px';
const line = document.createElement('div');
line.style.cssText =
'position:absolute;left:120px;top:68px;width:120px;border-top:2px solid #64748b';
root.append(line);
for (const card of model.cards) {
const article = document.createElement('article');
article.dataset.shapeId = card.id;
article.style.cssText = boxStyle(card);
article.textContent = card.label;
root.append(article);
}
}
export function renderSVG(root: SVGSVGElement, model: Diagram): void {
const ns = 'http://www.w3.org/2000/svg';
root.replaceChildren();
const line = document.createElementNS(ns, 'line');
Object.entries({
x1: '120',
y1: '68',
x2: '240',
y2: '68',
stroke: '#64748b',
'stroke-width': '2',
}).forEach(([key, value]) => line.setAttribute(key, value));
root.append(line);
for (const card of model.cards) {
const group = document.createElementNS(ns, 'g');
group.dataset.shapeId = card.id;
const rect = document.createElementNS(ns, 'rect');
Object.entries({
x: String(card.x),
y: String(card.y),
width: '100',
height: '56',
rx: '10',
fill: 'white',
stroke: '#2563eb',
'stroke-width': '2',
}).forEach(([key, value]) => rect.setAttribute(key, value));
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', String(card.x + 50));
text.setAttribute('y', String(card.y + 34));
text.setAttribute('text-anchor', 'middle');
text.textContent = card.label;
group.append(rect, text);
root.append(group);
}
}
export function renderCanvas(canvas: HTMLCanvasElement, model: Diagram): void {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D unavailable');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(120, 68);
ctx.lineTo(240, 68);
ctx.stroke();
for (const card of model.cards) {
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.beginPath();
ctx.roundRect(card.x, card.y, 100, 56, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#0f172a';
ctx.textAlign = 'center';
ctx.font = '16px system-ui';
ctx.fillText(card.label, card.x + 50, card.y + 34);
}
}
renderDOM(document.querySelector('#dom-demo')!, diagram);
renderSVG(document.querySelector('#svg-demo')!, diagram);
renderCanvas(document.querySelector('#canvas-demo')!, diagram);
决策接口与测试为 ADR 提供可验证的输入:
export type ProductNeeds = Readonly<{
nativeSceneText: boolean;
semanticScene: boolean;
visibleDynamicShapes: number;
changesPerSecond: number;
benchmark?: Readonly<{ domP95Ms: number; canvasP95Ms: number; frameBudgetMs: number }>;
}>;
type SceneChoice = 'dom' | 'canvas2d' | 'prototype-required';
export function chooseLayers(n: ProductNeeds): { ui: 'dom'; scene: SceneChoice } {
if (n.nativeSceneText || n.semanticScene) return { ui: 'dom', scene: 'dom' };
if (!n.benchmark) return { ui: 'dom', scene: 'prototype-required' };
const canvasWins =
n.benchmark.canvasP95Ms <= n.benchmark.frameBudgetMs &&
n.benchmark.canvasP95Ms < n.benchmark.domP95Ms;
return { ui: 'dom', scene: canvasWins ? 'canvas2d' : 'dom' };
}
import { describe, expect, it } from 'vitest';
import { chooseLayers } from './renderer-choice';
describe('renderer ADR rule', () => {
it('never moves semantic product UI into the bitmap', () => {
expect(
chooseLayers({
nativeSceneText: true,
semanticScene: true,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}),
).toEqual({ ui: 'dom', scene: 'dom' });
});
it('refuses to turn an object count into an unmeasured renderer verdict', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}).scene,
).toBe('prototype-required');
});
it('permits Canvas only when the measured scene meets its frame budget', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
benchmark: { domP95Ms: 24, canvasP95Ms: 8, frameBudgetMs: 12 },
}).scene,
).toBe('canvas2d');
});
});
ADR 必须说明:Canvas 2D 被选作教学内核,是为了让你亲自实现位图生命周期、渲染器、几何系统与调度机制;工具栏、表单和文本编辑仍留在 DOM 中。替换触发条件包括:“当原生文本与语义占据主导时,回到 DOM/SVG”,以及“当基准测试证明 Canvas 2D 无法满足 p95 帧预算,同时 GPU 原型确实带来改善时,再迁移到 GPU”。兼容性、团队能力、导出、协作和退出成本也必须记录。
visibleDynamicShapes 和 changesPerSecond 会进入实验场景与 ADR,但它们不是适用于所有产品的通用阈值。没有基准数据时返回“需要原型验证”,可以防止示例数字伪装成普遍规律。运行 npm run dev,浏览器应显示三张内容相同的图,其中 DOM 文本可以选择。运行 npx vitest run src/lab/ch01/renderer-choice.test.ts,预期结果为 3 passed。
回到开头的游戏:我们并没有争论纸张和黑板谁“更先进”。我们把可编辑文字写在纸上,把需要反复重绘的场景放在黑板上。
故意让它出错
把标题和普通表单也画进 Canvas,然后从页面中移除真正的 <h1>、<label> 和 <input> 元素。
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复方式 |
|---|---|---|---|---|---|
| 像素标题 | 标题无法选择,也不在文档大纲中 | 开发者工具无障碍树中没有标题 | 使用真正的 <h1>;Canvas 只绘制场景 | getByRole('heading') 成功 | 恢复 DOM 标题 |
| 像素输入框 | Tab 无法进入;屏幕阅读器不知道它的标签和值 | 键盘录制与无障碍树 | 使用 <label><input> DOM 覆盖层 | 只用键盘即可输入并提交 | 删除伪造的输入框 |
| Canvas 布局的表单 | CSS Grid、换行与验证都要手工重写 | 比较实现体积和浏览器测试数量 | 产品 UI 留在 DOM | 缩放到 200% 时无重叠 | 恢复原布局 |
| 坐标断言测试 | 字体或 DPR 改变就让测试变得脆弱 | 换一个操作系统后测试失败 | 查询 DOM role;测试 Canvas 模型与截图 | Node 单元测试加浏览器视觉测试 | 删除硬编码坐标 |
屏幕阅读器、键盘、文本选择、CSS、布局与测试,并不是以后再添加的装饰;它们都是选择渲染器时必须计算的成本。完成故障实验后,恢复真正的 DOM 控件,并重新运行键盘与屏幕阅读器检查。
用证据验收
| 需要证明的结论 | 可接受的证据 | 不可接受的回答 |
|---|---|---|
| 哪一层需要 Canvas | 动态可见对象与帧追踪、ADR 和原型 | “Canvas 更快” |
| 哪一层保留 DOM | 键盘、语义树与文本编辑要求 | “大家都这么做” |
| 渲染器可替换 | 三种实现都使用同一个 Diagram 类型 | 把业务对象存成 DOM 节点 |
| 兼容性受控 | 目标浏览器测试与特性检测 | 只看一张全球支持表 |
- 我能解释无限画布与
<canvas>元素的区别。 - 我比较过 DOM、SVG、Canvas 2D、WebGL、WebGPU 和两种混合方案。
- 我填写了全部十二个选择维度,而不是只看对象数量。
- 三种实现展示同一张业务图,并使用同一份数据。
- Canvas 伪表单故障留下了录屏、语义树证据和测试,并且已经恢复。
- ADR 记录了当前选择、替换条件,以及协作/导出/许可/团队成本。
给五岁孩子讲明白
不要使用“渲染器”“DOM”或“GPU”这些词,回答:为什么一本能够自己朗读的故事书,不应该被完整画成一张照片?再回答:为什么一幅快速移动、色彩丰富的背景,不适合由一万张可编辑的小纸条拼成?
展开一个不用术语的好答案
照片很擅长一次展示许多颜色,却不知道哪一部分是标题,也不知道哪里可以输入。负责朗读书籍的机器同样不知道应该先读什么。因此,文字和按钮应当继续是能够被找到、选择和操作的东西。反过来,如果把一大片不断移动的颜色拆成太多小纸条,每次移动时都重新摆放所有纸条,就会变成大量工作。最好的办法,是让人们必须理解和操作的东西保留各自的身份,再把需要快速重画的部分交给画板——就像我们在游戏里把纸贴到黑板上一样。