课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
找两块饼干、一颗草莓和一张纸。第一次做“笑脸蛋糕”时,随意摆好所有东西,然后请朋友把盘子清空。现在只告诉他:“把刚才那一点放回去。”他并不知道“那一点”应该放在哪里。第二次先写一份配方:擦干净盘子;把两块饼干放在 (30, 20) 和 (70, 20);把草莓放在 (50, 55);最后画出嘴巴。清空盘子后,把配方交给另一位朋友,他也能重新做出笑脸。
先预测:如果“倒入半透明糖浆”这一步没有写明“之后换回普通糖浆”,下一块饼干会怎样?如果“只在圆盘内部撒糖”没有结束标记,盘子外的装饰又会怎样?
- 读取完整配方对象与相机
- 擦干净盘子不要依赖旧颜色
- 按层摆放背景 → 形状 → 提示
- 比较结果每次都相同
本章唯一的核心事实是:**可靠的 Canvas 渲染器必须能够根据数据重建完整场景。**目标接口是 render(model, camera, context),而不是“用户拖动了,所以在旧像素上再涂一块”。
把玩具翻译成 Canvas 工程
| 蛋糕游戏中的概念 | Canvas 工程 | 用途 |
|---|---|---|
| 配料清单 | 文档模型 | 保存事实 |
| 摆盘视角 | 相机 | 决定世界如何投影 |
| 配方 | 纯渲染器 | 相同输入产生相同绘制命令 |
| 擦干净盘子 | 清除/背景阶段 | 移除旧位图 |
| 模具轮廓 | Path / Path2D | 描述几何边界 |
| 糖浆与描边 | Fill / Stroke | 为路径内部或边缘着色 |
| 戴上和脱下手套 | save() / restore() | 隔离 Transform、Alpha、Clip 与样式 |
| 各司其职的厨师 | 渲染阶段 | 确定背景、网格、形状与覆盖层顺序 |
这个类比在这里失效:Canvas 当前路径并不完全属于保存状态栈,因此不要以为 save() 会清除 beginPath() 之前创建的路径。Path2D 是可复用的几何容器,却不是文档中拥有稳定 ID 的业务对象。浏览器字体、图像解码和抗锯齿也意味着不同平台上的像素未必逐位相同。因此,确定性必须同时在“命令顺序/模型结果”层面,以及“允许误差范围内的视觉结果”层面验证。
先消灭具有误导性的直觉
- “拖动时,擦掉旧矩形再画一个新矩形最快。” 旧矩形下面可能有网格、线条或透明层。没有严谨的脏区算法,局部修补会留下残影。教学内核先重建完整场景。
- “
save()会自动与restore()配对。” 不会。异常或提前返回会泄漏状态,所以要使用try/finally。 - “每个 Shape 都可以随意修改 Context。” 一个 Shape 的透明度、裁剪、变换或 lineJoin 会污染之后的所有 Shape。Registry 必须建立隔离边界。
- “看起来一样,就是确定性的。” 随机数、当前时间、尚未加载的字体/图片,以及没有稳定顺序的对象,都可能改变下一次结果。
- “Path 就是 Shape。” Path 只包含几何信息,没有业务身份、父子关系、权限或持久化语义。
生产工程背包
前置契约
前提是第 2 章的 CanvasHost。每当位图尺寸改变,它会恢复基础 DPR Transform 并调用渲染器。文档是只读输入,渲染器不修改它。相机是显式输入。z 值相同时,使用稳定 ID 打破平局。时间和图像资源通过显式参数传入。
正式知识
Canvas 2D 路径可以使用当前 Path(beginPath/moveTo/lineTo/...)或 Path2D 构建。fill() 填充内部,stroke() 沿边缘绘制。lineCap 控制开放线段端点,lineJoin 控制拐角。两者都会影响可见边界,因此后续几何章节必须与它们保持一致。
clip() 会把之后的绘制限制在某个区域,并且只能通过恢复之前的状态来结束,所以要用 save/restore 包裹。globalCompositeOperation 决定源像素如何与已有目标像素组合,globalAlpha 随后调整整体透明度。两者任一泄漏,都会让“之后绘制的对象消失或变淡”。Gradient 由 Context 创建并包含色标;Pattern 会重复图像或 Canvas。它们是渲染资源,不应塞进序列化文档。drawImage() 可以绘制 HTMLImageElement、ImageBitmap 等来源,资产章节会处理解码状态和 CORS。通过 fillText() 绘制的基础文字适合预览标签,却不提供光标、选择、输入法或富文本编辑。
渲染器应使用固定阶段。Background 完整建立底色;Grid 绘制与相机相关的参考线;Shape 按 z 顺序绘制;Overlay 绘制选择框和提示,但不写入文档。为每个阶段和每个 Shape 设置 withSavedContext 边界,使上下文状态隔离可以审计。这里的“纯渲染器”并不意味着数学意义上的无副作用——它必然写入位图。它的含义是:除目标 Context 外,不修改模型、不读取隐藏全局状态,并且相同输入产生相同命令序列。
证据与兼容性
- MDN:Canvas 2D 绘制形状 / Path2D
- MDN:应用样式与颜色
- MDN:合成与裁剪
- MDN:CanvasRenderingContext2D.drawImage
- MDN:save() 与 restore()
- WHATWG HTML:二维渲染上下文
以上来源核查于 2026-08-29。请检查目标浏览器对 roundRect() 和部分 Path2D 用法的支持。不支持时,使用 moveTo/lineTo/quadraticCurveTo 构建同样的轮廓。视觉回归测试必须固定字体、DPR、色彩空间和资源解码时点。
本章的工程增量
**起点:**第 2 章 Host 中的示例回调直接调用 fillRect。**终点:**固定四个阶段;矩形、椭圆、折线、图片占位符和基础文本标签全部根据文档重建。
canvas-lab/src/lab/ch03/
model.ts
renderer.ts
renderer.test.ts
完整的模型与渲染器,关键逻辑没有隐藏省略:
export type Camera = { x: number; y: number; zoom: number };
export type Shape =
| {
id: string;
type: 'rectangle';
x: number;
y: number;
w: number;
h: number;
fill: string;
z: number;
}
| {
id: string;
type: 'ellipse';
x: number;
y: number;
rx: number;
ry: number;
fill: string;
z: number;
}
| {
id: string;
type: 'polyline';
points: readonly { x: number; y: number }[];
stroke: string;
z: number;
}
| {
id: string;
type: 'image';
x: number;
y: number;
w: number;
h: number;
label: string;
z: number;
}
| { id: string; type: 'text'; x: number; y: number; text: string; color: string; z: number };
export type DocumentModel = {
background: string;
shapes: readonly Shape[];
selectedIds: ReadonlySet<string>;
};
function isolated(ctx: CanvasRenderingContext2D, draw: () => void): void {
ctx.save();
try {
draw();
} finally {
ctx.restore();
}
}
function shapeBounds(shape: Shape): { x: number; y: number; w: number; h: number } {
if (shape.type === 'rectangle' || shape.type === 'image') return shape;
if (shape.type === 'ellipse')
return { x: shape.x - shape.rx, y: shape.y - shape.ry, w: shape.rx * 2, h: shape.ry * 2 };
if (shape.type === 'text')
return { x: shape.x, y: shape.y - 16, w: shape.text.length * 10, h: 20 };
if (shape.points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
const xs = shape.points.map((p) => p.x),
ys = shape.points.map((p) => p.y);
return {
x: Math.min(...xs),
y: Math.min(...ys),
w: Math.max(...xs) - Math.min(...xs),
h: Math.max(...ys) - Math.min(...ys),
};
}
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape): void {
isolated(ctx, () => {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
if (shape.type === 'rectangle') {
const path = new Path2D();
path.rect(shape.x, shape.y, shape.w, shape.h);
ctx.fillStyle = shape.fill;
ctx.fill(path);
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1;
ctx.stroke(path);
return;
}
if (shape.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(shape.x, shape.y, shape.rx, shape.ry, 0, 0, Math.PI * 2);
ctx.fillStyle = shape.fill;
ctx.fill();
return;
}
if (shape.type === 'polyline') {
const [first, ...rest] = shape.points;
if (!first) return;
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (const point of rest) ctx.lineTo(point.x, point.y);
ctx.strokeStyle = shape.stroke;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
return;
}
if (shape.type === 'image') {
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
ctx.strokeStyle = '#64748b';
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
ctx.beginPath();
ctx.moveTo(shape.x, shape.y);
ctx.lineTo(shape.x + shape.w, shape.y + shape.h);
ctx.moveTo(shape.x + shape.w, shape.y);
ctx.lineTo(shape.x, shape.y + shape.h);
ctx.stroke();
ctx.fillStyle = '#334155';
ctx.fillText(shape.label, shape.x + 8, shape.y + 18);
return;
}
ctx.fillStyle = shape.color;
ctx.font = '16px system-ui';
ctx.textBaseline = 'alphabetic';
ctx.fillText(shape.text, shape.x, shape.y);
});
}
export function renderDocument(
model: DocumentModel,
camera: Camera,
ctx: CanvasRenderingContext2D,
viewport: { width: number; height: number; dpr: number },
trace: (event: string) => void = () => undefined,
): void {
const ordered = [...model.shapes].sort((a, b) => a.z - b.z || a.id.localeCompare(b.id));
trace('pass:background');
isolated(ctx, () => {
ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
ctx.fillStyle = model.background;
ctx.fillRect(0, 0, viewport.width, viewport.height);
});
trace('pass:grid');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1 / camera.zoom;
const left = Math.floor(camera.x / 20) * 20,
top = Math.floor(camera.y / 20) * 20;
for (let x = left; x < camera.x + viewport.width / camera.zoom; x += 20) {
ctx.beginPath();
ctx.moveTo(x, camera.y);
ctx.lineTo(x, camera.y + viewport.height / camera.zoom);
ctx.stroke();
}
for (let y = top; y < camera.y + viewport.height / camera.zoom; y += 20) {
ctx.beginPath();
ctx.moveTo(camera.x, y);
ctx.lineTo(camera.x + viewport.width / camera.zoom, y);
ctx.stroke();
}
});
trace('pass:shape');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
for (const shape of ordered) {
trace(`shape:${shape.id}`);
drawShape(ctx, shape);
}
});
trace('pass:overlay');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2 / camera.zoom;
ctx.setLineDash([6 / camera.zoom, 4 / camera.zoom]);
for (const shape of ordered) {
if (!model.selectedIds.has(shape.id)) continue;
trace(`selection:${shape.id}`);
const b = shapeBounds(shape);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
});
}
这里有意把 viewport.width/height 定义为 CSS 像素,并让每次 setTransform() 都包含 dpr。第 2 章的 Host 会先建立 DPR Transform,但渲染器自己调用 setTransform() 时会替换它。如果这里写成单位矩阵,第 2 章设定的高分辨率位图就只会在左上角区域绘制。trace 是可选的测试探针,它不会进入文档,也不会改变绘制结果。
Background 阶段可以用 createLinearGradient() 创建 Gradient,用 createPattern(decodedImage, 'repeat') 创建 Pattern。真实资源使用 drawImage(),并必须由显式的 Resource Registry 提供。资源尚未解码时,应绘制上面实现的占位符;绝不要让异步回调偷偷给旧位图打补丁。
测试通过状态快照证明隔离,并用重复的命令记录证明顺序具有确定性:
import { describe, expect, it, vi } from 'vitest';
import { renderDocument, type DocumentModel } from './renderer';
describe('deterministic renderer', () => {
it('restores caller state and emits the same ordered draw trace', () => {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 400;
const ctx = canvas.getContext('2d')!;
const model: DocumentModel = {
background: '#fff',
selectedIds: new Set(['b']),
shapes: [
{ id: 'b', type: 'rectangle', x: 20, y: 20, w: 60, h: 40, fill: '#bfdbfe', z: 2 },
{ id: 'a', type: 'text', x: 30, y: 45, text: 'Order', color: '#111827', z: 2 },
],
};
const setTransform = vi.spyOn(ctx, 'setTransform');
ctx.globalAlpha = 0.37;
ctx.setLineDash([9, 3]);
const first: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => first.push(event),
);
expect(first).toEqual([
'pass:background',
'pass:grid',
'pass:shape',
'shape:a',
'shape:b',
'pass:overlay',
'selection:b',
]);
expect(setTransform).toHaveBeenCalledWith(2, 0, 0, 2, 0, 0);
expect(ctx.globalAlpha).toBe(0.37);
expect(ctx.getLineDash()).toEqual([9, 3]);
ctx.clearRect(0, 0, 640, 400);
const second: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => second.push(event),
);
expect(second).toEqual(first);
expect(model.shapes.map((s) => s.id)).toEqual(['b', 'a']);
});
});
运行 npx vitest run src/lab/ch03/renderer.test.ts --environment jsdom,预期结果为 1 passed。在浏览器视觉测试中,先清空 Canvas,再提供相同的 Document/Camera;截图必须在定义的容差内匹配。回到配方:渲染器每次都会擦干净盘子,再执行固定流程。任何饼干都不依赖“它上次大概在这里”。
故意让它出错
故意删除 drawShape 外层的 restore(),然后在第一个 Shape 中设置一个变换、globalAlpha=0.2、圆形裁剪和 lineJoin='bevel'。
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复方式 |
|---|---|---|---|---|---|
| Transform 泄漏 | 后续对象偏移/旋转 | 记录 getTransform() 与截图 | save + finally restore | 调用前后的变换相等 | 恢复隔离器 |
| Alpha 泄漏 | 之后的每个对象都变淡 | 渲染后的 globalAlpha 与原值不同 | 显式隔离每个 Shape | Alpha 哨兵断言 | 移除注入 |
| Clip 泄漏 | 圆形区域外的对象消失 | 后续 Shape 命令存在,但像素不存在 | 把 clip 保留在配对状态内 | 截图证明裁剪后的对象仍可见 | 清空并完整重绘 |
| Line Style 泄漏 | 折线端点/拐角错误 | lineCap/Join 快照 | 在 Shape 内设置并在结束时恢复 | 测试 Shape 顺序的每种排列 | 恢复默认顺序 |
| 随机数/时间进入渲染 | 相同输入得到不同截图 | 对比两次命令追踪 | 把时钟/种子设为显式输入 | 固定种子可完全重放 | 移除全局读取 |
每次注入都遵循同一个循环:启用一个故障开关 → 记录症状 → 保存命令/状态/截图证据 → 修复隔离边界 → 运行单元与视觉回归测试 → 清空 Canvas,仅根据数据恢复。不要用“把有问题的 Shape 移到最后”来隐藏污染。
用证据验收
| 验收门槛 | 自动/手动 | 证据 |
|---|---|---|
| 位图可以完全清空 | 自动 | 测试显式调用 clearRect 后依然能重建 |
| 文档是唯一场景输入 | 自动 | 渲染器不读取历史像素/全局 store |
| 阶段顺序稳定 | 自动 | 命令追踪为 background→grid→shape→overlay |
| Shape 状态隔离 | 自动 | Transform/Alpha/Clip/Line Style 故障回归 |
| 五种 Shape 完整 | 手动 | 同一张截图包含矩形、椭圆、折线、图片占位符与文本标签 |
- 我能解释 Path/Path2D、fill/stroke 以及 lineCap/lineJoin。
- 我把 clip、合成、alpha、gradient、pattern、drawImage 和基础文本放在了正确层次。
- 每个
save()都有异常安全的restore()。 - 渲染器不修改文档、不读取随机时间,且顺序稳定。
- 完全清空 Canvas 后,我仅凭文档、相机和资源状态就能重画相同结果。
给五岁孩子讲明白
不要使用“渲染器”“位图”“上下文”“阶段”或“确定性”这些词,回答:为什么“在旧盘子上加一点”不可靠?怎样保证另一位厨师也能做出相同的笑脸?
展开一个不用术语的好答案
旧盘子上的东西可能已经被拿走、盖住,或被糖浆抹花。只说“加一点”,无法告诉我们下面原来有什么。我们需要完整清单和固定配方:擦干净盘子,铺好背景,放上每一种食物,最后再加提示。每当用完一种特殊糖浆或模具,都在这一步结束时把它收好。这样一来,即使盘子彻底空了,另一个人也能按照清单重建,而且结果不会依赖上次留下的碎屑。