绘制性能陷阱
1. 成本排序(热路径上从贵到便宜)
| 操作 | 为什么贵 | 替代 |
|---|---|---|
getImageData / putImageData 全画布 | GPU→CPU 读回;未声明时可能整表面降到软件光栅 | 几何命中;处理型表面单独 willReadFrequently |
shadowBlur 大半径 | 按绘制命令做模糊,不是 CSS 那种层效果 | 预烘焙成位图;静态阴影进缓存层 |
filter(blur() 等) | 整次绘制的中间缓冲 | 同上 |
每帧 drawImage 未解码的 HTMLImageElement | 解码 + 上传 | ImageBitmap,提前 createImageBitmap |
| 复杂 path 每帧重建 | JS 分配 + 光栅化 | 缓存 Path2D,只改 CTM |
measureText + 换 font | 字体 shaping | 按 font 字符串做 metrics cache |
clip() 深栈 | 与后续绘制求交 | 少用;能用 path fill 就不用 clip |
save/restore 本身 | 相对便宜 | 不要在粒子内层每粒一对;批量设状态 |
fillRect 清屏在大 backing store 上也不免费。整层替换用 globalCompositeOperation = "copy" + drawImage 往往更省。见 04。
2. 分层:一张 canvas 模拟 Photoshop 是反模式
interface RenderLayer {
id: string;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
stale: boolean;
paint: (ctx: CanvasRenderingContext2D) => void;
}
export function compositeLayers(
view: CanvasRenderingContext2D,
layers: readonly RenderLayer[],
dpr: number,
): void {
view.save();
view.setTransform(dpr, 0, 0, dpr, 0, 0);
view.globalCompositeOperation = "copy";
let first = true;
for (const layer of layers) {
if (layer.stale) {
layer.ctx.save();
layer.ctx.setTransform(1, 0, 0, 1, 0, 0);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
layer.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
layer.paint(layer.ctx);
layer.ctx.restore();
layer.stale = false;
}
view.globalCompositeOperation = first ? "copy" : "source-over";
first = false;
view.drawImage(layer.canvas, 0, 0, layer.canvas.width / dpr, layer.canvas.height / dpr);
}
view.restore();
}
典型切分:
- grid / 地图 / 已提交笔迹:几乎不变,缓存。
- 选中框 / 光标 / 拖拽预览:每帧清。
- HUD:能用 DOM 就用 DOM,别画进 canvas——可访问、CSS 动画更便宜。
层数不是越多越好。每张都是 css × dpr × 4 字节,移动端 4 层全屏就能把内存打满。超过 3 张全屏层之前,先问能不能合并静态层。
3. 过绘和状态抖动
同一像素被 fill 多次,成本线性涨。半透明叠层尤其明显。能合并的 Path2D 一次 fill,不要 200 个 arc 各 fill 一次。
状态抖动:
// 反模式:每颗粒子改 fillStyle
for (const p of particles) {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
// 按颜色分桶,同色一次 fill
const byColor = new Map<string, Path2D>();
for (const p of particles) {
let path = byColor.get(p.color);
if (!path) {
path = new Path2D();
byColor.set(p.color, path);
}
path.moveTo(p.x + p.r, p.y);
path.arc(p.x, p.y, p.r, 0, Math.PI * 2);
}
for (const [color, path] of byColor) {
ctx.fillStyle = color;
ctx.fill(path);
}
beginPath 在循环里重建当前路径,既慢又容易和 save/restore 误解耦。生产粒子用 Path2D 或(真到这一步)WebGL。
4. 怎么测
export function measurePaint(
label: string,
paint: () => void,
): number {
const t0 = performance.now();
paint();
const t1 = performance.now();
performance.measure(label, {
start: t0,
end: t1,
});
return t1 - t0;
}
这只覆盖 JS 调用到光栅器返回 的同步部分。还要:
- Chrome:Performance → 勾选 Rendering,看每帧 Main 是否超过 8/16ms。
chrome://flags无关。用 Rendering 面板的 FPS meter、Layer borders 看 canvas 是否被提升为独立层。- 对比
willReadFrequently: true/false各跑同一段绘制 + 一次getImageData(0,0,1,1)。很多「偶发卡」其实是第一次读回把表面踢下 GPU。
不要用「我感觉 60fps」当结论。高刷屏上掉到 40 也很难用肉眼在简单场景里看出来,但输入延迟已经坏了。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 第一次点击卡一下 | 隐式读回或第一次编译光栅路径 |
| 越画越慢 | path 无界增长、阴影每帧打在全场景 |
| 分层后更卡 | 全屏层过多,blit 带宽打满 |
| Worker 化以后主线程仍卡 | 视图 canvas 仍在主线程 drawImage 大图,或 postMessage 传了像素拷贝而不是 bitmap transfer |
权威资料
核对日期:2026-08-26