课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
拿一张方格纸,把第三行第四格涂成蓝色。在旁边贴一张便签,写上“这是 shape-7”。让朋友只拿走方格纸,你自己保留便签。然后问:“这个蓝色方块叫什么?是谁画的?能把它向右移动两格吗?”你的朋友只能看见颜色,无法回答它的名字。现在,把方格纸换成网格更密的一张。原来的蓝色方块也随之消失,所以你必须根据便签上的记录重新画出来。
先预测:如果把一张 10×10 的画纸拉伸到两倍大,你会得到更多方格,还是只会让原有方格变胖?如果直接换成一张新的 20×20 画纸,旧图会自动搬过去吗?亲手试试看。
- 记住对象shape-7 在哪里
- 准备方格纸屏幕有多密
- 按照记录上色变成位图
- 换纸并重画不要依赖旧颜色
本章唯一的核心事实是:Canvas 存储位图,应用程序存储对象。 Canvas 2D 采用即时模式:一次调用会改变像素,浏览器不会替你保留“矩形 shape-7”这个对象。
把玩具翻译成 Canvas 工程
| 玩具世界中的对象或动作 | 浏览器概念 | 结果 |
|---|---|---|
| 外框大小 | CSS 尺寸 | 它在页面布局中占据多少 CSS 像素 |
| 纸上的方格数量 | canvas.width/height | 背景位图的像素尺寸 |
| 网格密度 | devicePixelRatio | 一个 CSS 像素周围可能对应多个物理像素 |
| 更换方格纸 | 设置 width / height | 清空位图并重置上下文状态 |
| 便签上的形状记录 | 文档数据 | 以确定方式重建场景 |
| 测量外框的助手 | ResizeObserver | 容器改变时通知 Host |
| 一盒绘图工具 | CanvasRenderingContext2D | 包含变换、透明度和样式等有状态工具 |
类比也有边界。CSS 像素并不是尺寸固定的物理方格,DPR 也不只由硬件决定;浏览器缩放和窗口跨显示器移动,都可能改变有效比率。ResizeObserver 不是一把同步直尺:浏览器会调度回调批次和布局周期。更换真实纸张不会“重置画笔颜色”,但设置 Canvas 尺寸属性会同时重置上下文状态,因此必须显式重建状态。
先消灭具有误导性的直觉
- “CSS
width: 800px会给我 800 个清晰像素。” CSS 只改变显示尺寸。把默认的 300×150 位图放大,依然会模糊、拉伸。 - “DPR 只需在首次加载时读取。” 浏览器缩放或跨显示器移动之后,DPR 可能改变,因此 Host 每次有效测量都必须重新计算。
- “把相同值赋给
canvas.width不会有影响。” 规范要求尺寸赋值重置 Canvas。即使执行canvas.width = canvas.width,也会清空内容并恢复默认上下文状态。 - “调整尺寸后只要恢复相机就够了。” 变换、裁剪、透明度、lineDash、字体等状态都可能消失。可靠方法是在尺寸变化后执行统一初始化,并完整重绘。
- “Observer 越多,响应越及时。” 重复的 Observer/监听器会造成重复测量、循环和泄漏。一台 Host 只拥有一套资源。
生产工程背包
前置契约
前提是第 1 章建立的边界:产品 UI 留在 DOM,动态场景使用 Canvas 2D。此时 Canvas Lab 只有一个不保存像素的 redraw(viewport) 回调。相机属于应用数据,绝不能被藏进上下文当前的 Transform。容器可能被隐藏,宽度或高度可能为零。销毁后,任何回调都不得再次绘制。
正式知识
<canvas> 是 DOM 元素;getContext('2d', settings) 会绑定渲染上下文。fillRect() 等 Canvas 2D 调用会立即修改输出位图,并不会生成一个可以查询的矩形节点。CSS 尺寸决定布局大小,背景位图尺寸决定有多少像素可用。常见计算方式是 round(cssSize × effectiveDpr),随后执行 ctx.setTransform(dpr, 0, 0, dpr, 0, 0),这样后续绘制仍可使用 CSS 像素作为单位。
物理像素是设备上的显示单元;CSS 像素是浏览器的逻辑单元。devicePixelRatio 是一个比率,不是永久不变的设备标签。像素尺寸必须设置上限,否则大尺寸高 DPR 显示器会分配惊人的内存。如果宽或高为 0,不要创建零尺寸位图并假装操作已经完成。应进入暂停状态,等待下一次非零测量。
按照规范,给尺寸赋值会初始化新位图并重置上下文状态。即使新旧值相同,也不能把赋值视为没有副作用。只有计算得到的像素尺寸确实改变时才赋值。一旦改变,就遵循固定顺序:设置 CSS 尺寸 → 设置背景尺寸 → 初始化上下文 → 根据模型完整重绘。
上下文创建选项只是请求,并非保证。alpha: false 表示不需要透明背景;desynchronized 是降低延迟的提示;willReadFrequently 适合频繁使用 getImageData() 的场景,也可能改变加速策略;colorSpace 可以请求 srgb 或受支持的广色域;unorm8/float16 等 colorType 更需要谨慎检测。应通过 getContextAttributes?.() 报告实际属性;输入设置不能证明能力存在。getContext() 也可能返回 null。
使用 ResizeObserver 观察容器,不要每帧轮询。有些环境提供 devicePixelContentBoxSize,但仍应保留 contentRect × DPR 的降级路径。如果回调只是因为自己改变了 Canvas 尺寸就再次改变被观察容器,便可能形成 Resize 循环。Host 的 CSS 应让 Canvas 跟随容器;位图属性不得参与布局。
证据与兼容性
- WHATWG HTML:Canvas 元素与设置位图尺寸的算法
- MDN:Canvas 基本用法
- MDN:Window.devicePixelRatio
- MDN:ResizeObserver
- MDN:getContext()
- MDN:getContextAttributes()
以上来源核查于 2026-08-29。请对 colorType、device-pixel-content-box 和上下文属性报告进行特性检测。不支持时,应降级到 sRGB、CSS 尺寸 × DPR 与“未知”,而不是阻止基本绘制。
本章的工程增量
**起点:**第 1 章使用固定尺寸直接调用 renderCanvas。**终点:**一个只创建一个 Observer、支持 DPR/零尺寸/销毁、每次重置后都确定性重绘,并能输出能力报告的 CanvasHost。
canvas-lab/
src/lab/ch02/canvas-host.ts
src/lab/ch02/canvas-host.test.ts
src/lab/ch02/demo.html
接口契约:redraw 接收 CSS 视口和实际 DPR。Host 不存储文档,只通知渲染器。下面是完整的关键实现:
export type Viewport = { cssWidth: number; cssHeight: number; dpr: number };
export type CapabilityReport = {
requested: CanvasRenderingContext2DSettings;
actual: Record<string, unknown> | null;
};
export type HostPlatform = {
dpr(): number;
observe(element: Element, callback: () => void): () => void;
watchDpr(callback: () => void): () => void;
};
const browserPlatform: HostPlatform = {
dpr: () => window.devicePixelRatio || 1,
observe(element, callback) {
const observer = new ResizeObserver(callback);
observer.observe(element);
return () => observer.disconnect();
},
watchDpr(callback) {
let media: MediaQueryList | null = null;
const onChange = (): void => {
arm();
callback();
};
const arm = (): void => {
media?.removeEventListener('change', onChange);
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener('change', onChange);
};
arm();
return () => media?.removeEventListener('change', onChange);
},
};
export class CanvasHost {
readonly context: CanvasRenderingContext2D;
readonly capabilities: CapabilityReport;
private stopObserving: (() => void) | null = null;
private stopWatchingDpr: (() => void) | null = null;
private destroyed = false;
private last: Viewport | null = null;
constructor(
private readonly container: HTMLElement,
private readonly canvas: HTMLCanvasElement,
private readonly redraw: (ctx: CanvasRenderingContext2D, viewport: Viewport) => void,
private readonly platform: HostPlatform = browserPlatform,
) {
const requested: CanvasRenderingContext2DSettings = {
alpha: false,
desynchronized: false,
willReadFrequently: false,
colorSpace: 'srgb',
colorType: 'unorm8',
};
const context = canvas.getContext('2d', requested);
if (!context) throw new Error('Canvas 2D context is unavailable');
this.context = context;
const readable = context as CanvasRenderingContext2D & {
getContextAttributes?: () => Record<string, unknown>;
};
this.capabilities = {
requested,
actual: readable.getContextAttributes?.() ?? null,
};
this.canvas.style.display = 'block';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.stopObserving = this.platform.observe(this.container, () => this.measure());
this.stopWatchingDpr = this.platform.watchDpr(() => this.measure());
this.measure();
}
measure(): void {
if (this.destroyed) return;
const rect = this.container.getBoundingClientRect();
const cssWidth = Math.max(0, rect.width);
const cssHeight = Math.max(0, rect.height);
if (cssWidth === 0 || cssHeight === 0) {
this.last = null;
return;
}
const dpr = Math.min(3, Math.max(1, this.platform.dpr()));
const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));
const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));
const bitmapChanged = this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight;
const viewport: Viewport = { cssWidth, cssHeight, dpr };
const viewportChanged =
!this.last ||
this.last.cssWidth !== cssWidth ||
this.last.cssHeight !== cssHeight ||
this.last.dpr !== dpr;
if (!bitmapChanged && !viewportChanged) return;
if (bitmapChanged) {
this.canvas.width = pixelWidth;
this.canvas.height = pixelHeight;
}
this.initializeContext(viewport);
this.last = viewport;
this.redraw(this.context, viewport);
}
private initializeContext(viewport: Viewport): void {
this.context.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
this.context.globalAlpha = 1;
this.context.globalCompositeOperation = 'source-over';
this.context.lineCap = 'butt';
this.context.lineJoin = 'miter';
this.context.setLineDash([]);
this.context.fillStyle = '#ffffff';
this.context.fillRect(0, 0, viewport.cssWidth, viewport.cssHeight);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.stopObserving?.();
this.stopWatchingDpr?.();
this.stopObserving = null;
this.stopWatchingDpr = null;
this.last = null;
}
}
完整的使用页面保留了真正、可访问的标题:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
#stage {
width: 80vw;
height: 50vh;
}
canvas {
display: block;
}
</style>
</head>
<body>
<h1>Canvas Lab</h1>
<div id="stage"><canvas id="scene">Two business cards</canvas></div>
<script type="module">
import { CanvasHost } from './canvas-host.ts';
const host = new CanvasHost(stage, scene, (ctx, view) => {
ctx.fillStyle = '#2563eb';
ctx.fillRect(24, 24, 120, 64);
ctx.fillStyle = '#fff';
ctx.fillText(`${view.dpr}x`, 34, 60);
});
window.addEventListener('pagehide', () => host.destroy(), { once: true });
</script>
</body>
</html>
测试不猜测浏览器的调度时机,而是注入平台,并断言资源所有权与重绘行为:
import { describe, expect, it, vi } from 'vitest';
import { CanvasHost, type HostPlatform } from './canvas-host';
describe('CanvasHost', () => {
it('resizes for both element and DPR changes, then releases both subscriptions once', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 100,
} as DOMRect);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const setTransform = vi.spyOn(ctx, 'setTransform');
const redraw = vi.fn();
const disconnect = vi.fn();
const unwatchDpr = vi.fn();
let notifyResize = () => undefined;
let notifyDpr = () => undefined;
let dpr = 2;
const platform: HostPlatform = {
dpr: () => dpr,
observe: (_element, callback) => {
notifyResize = callback;
return disconnect;
},
watchDpr: (callback) => {
notifyDpr = callback;
return unwatchDpr;
},
};
const host = new CanvasHost(container, canvas, redraw, platform);
expect([canvas.width, canvas.height]).toEqual([400, 200]);
expect(setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 0, 0);
expect(redraw).toHaveBeenCalledTimes(1);
notifyResize();
expect(redraw).toHaveBeenCalledTimes(1);
dpr = 1.5;
notifyDpr();
expect([canvas.width, canvas.height]).toEqual([300, 150]);
expect(setTransform).toHaveBeenLastCalledWith(1.5, 0, 0, 1.5, 0, 0);
expect(redraw).toHaveBeenCalledTimes(2);
host.destroy();
host.destroy();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(unwatchDpr).toHaveBeenCalledTimes(1);
});
it('waits through a zero-sized hidden container', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 0,
height: 0,
} as DOMRect);
const redraw = vi.fn();
const platform: HostPlatform = {
dpr: () => 2,
observe: () => () => undefined,
watchDpr: () => () => undefined,
};
new CanvasHost(container, document.createElement('canvas'), redraw, platform);
expect(redraw).not.toHaveBeenCalled();
});
});
ResizeObserver 只会告诉你元素盒子是否改变。DPR 可能在盒子尺寸不变时独立变化,所以应像 MDN 示例那样,为当前分辨率重新设置 matchMedia 监听器。运行 npx vitest run src/lab/ch02/canvas-host.test.ts --environment jsdom,预期结果为 2 passed。运行 npm run dev,然后改变窗口大小和浏览器缩放。控制台中的能力报告应保持稳定,形状边缘不得拉伸。回到方格纸游戏:Host 根据外框尺寸和密度换纸,并重新准备画笔;真正的图画仍根据便签记录重绘。
故意让它出错
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复方式 |
|---|---|---|---|---|---|
| 浏览器从 100% 缩放到 200% | 旧实现模糊或尺寸错误 | 记录 CSS/位图/DPR 元组与截图 | 每次测量都重新计算 DPR | Playwright 改变 DPR 后像素尺寸匹配 | 恢复缩放 |
| 从普通显示器拖到高 DPI 显示器 | 仍使用旧密度 | devicePixelRatio 已变,位图未变 | Resize/媒体变化触发 measure | 模拟 DPR 1→2 | 拖回并再次验证 |
先 display:none,再显示 | 首次测量为 0,随后永久空白 | redraw 调用次数为 0 | 零尺寸时暂停,在下一次回调恢复 | 0→300×200 时调用一次 | 恢复显示 |
| 尺寸变化后跳过初始化 | 相机/透明度/lineDash 消失 | getTransform() 为单位矩阵且截图错误 | 初始化状态,再根据模型重绘 | 监视顺序:resize→setTransform→render | 删除故障分支 |
| 回调总是写入相同尺寸 | 不断清空、闪烁和重复渲染 | 相同测量仍增加 redraw 次数 | 赋值前比较像素/视口 | 重复通知不会重绘 | 恢复守卫 |
| React 每次渲染都创建 Host | 出现多个 Observer 与循环 | 构造与断开次数不对称 | 生命周期中只拥有一个 Host | 两次挂载/卸载都为 1:1 | 销毁多余实例 |
还要手动测试极小宽度、大尺寸高 DPR 显示器,以及缺少能力报告的情况。恢复不是“刷新后看起来正常”,而是:移除故障注入,Observer 数量回到零,测试重新通过,下一次 Resize 仍能绘制。
用证据验收
| 属性 | 自动/手动 | 证据 |
|---|---|---|
| 不模糊 | 手动 | CSS 尺寸 × DPR 与位图尺寸日志、跨显示器截图 |
| 不拉伸 | 自动 | 比较 CSS 与位图宽高比的断言 |
| 不丢失 Transform/相机 | 自动 | Resize 后的 getTransform/相机渲染测试 |
| 不创建多个 Observer | 自动 | 构造与 disconnect 计数,加重复挂载测试 |
| 重绘可重复 | 自动 | 清空/Resize 后,同一文档得到相同像素快照 |
| 可以优雅降级 | 自动/手动 | 无属性报告、DPR=1 与零尺寸路径测试 |
- 我能区分 CSS 尺寸、背景尺寸、CSS 像素和物理像素。
- 我知道改变
width/height会清空位图并重置上下文状态,即使赋值与原值相同也一样。 - Host 统一处理 Resize、DPR、初始化、能力报告和确定性重绘。
-
alpha/desynchronized/willReadFrequently/colorSpace/colorType被记录为请求,实际能力经过测量。 - 隐藏→显示、缩放、跨显示器移动、零尺寸与 Resize 循环都有证据。
-
destroy()具有幂等性,且不会泄漏 Observer。
给五岁孩子讲明白
不要使用“像素”“DPR”“上下文”或“观察器”这些词,回答:为什么拉伸方格纸不等于换成网格更密的纸?为什么换纸后必须根据记录重画?
展开一个不用术语的好答案
拉伸旧纸只会让原有方格变胖,因此边缘会变得模糊。方格更多的纸可以画出更细的边缘。但新纸一开始是空的:旧纸上的蓝色不会自己跳过去,甚至上一支画笔的颜色也不能再相信。因此,我们会保存一份清单,记录有哪些东西以及它们在哪里。只要纸的尺寸或密度发生变化,就重新准备画笔,再按照清单画出全部内容。便签和方格纸各自有各自的工作。