跳到主要内容

绘制性能陷阱

1. 成本排序(热路径上从贵到便宜)

操作为什么贵替代
getImageData / putImageData 全画布GPU→CPU 读回;未声明时可能整表面降到软件光栅几何命中;处理型表面单独 willReadFrequently
shadowBlur 大半径按绘制命令做模糊,不是 CSS 那种层效果预烘焙成位图;静态阴影进缓存层
filterblur() 等)整次绘制的中间缓冲同上
每帧 drawImage 未解码的 HTMLImageElement解码 + 上传ImageBitmap,提前 createImageBitmap
复杂 path 每帧重建JS 分配 + 光栅化缓存 Path2D,只改 CTM
measureText + 换 font字体 shapingfont 字符串做 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