### [Canvas 动画帧循环与性能优化:rAF 时序、离屏渲染与 GC 控制的实战笔记](https://www.huociguo.com/article/1911) **Published:** 2026-10-05T04:29:18 **Author:** 米了 **Excerpt:** Canvas 动画跑到 60fps 不难,难的是元素上到几千个、画布铺满高分屏之后还能稳住。多数项目掉到 30… Canvas 动画跑到 60fps 不难,难的是元素上到几千个、画布铺满高分屏之后还能稳住。多数项目掉到 30fps 时,第一反应是”要不要换 WebGL”,其实 2D 上下文里还有大量能抠出来的时间,问题在于帧循环写得不干净、状态切换太随意、每帧还在制造垃圾。 ![](https://api.huociguo.com/wp-content/uploads/2026/10/20261005122751720-0504a32afd-1.png "20261005122751720-0504a32afd-1") Canvas 动画帧循环与性能优化 ## ⚙️ 帧循环:时间戳才是可信的时间源 用 `setInterval(fn, 16)` 驱动动画是最常见的起点问题。定时器不跟显示器刷新对齐,回调可能落在两帧之间,画面就会周期性抖一下,术语叫 judder。`requestAnimationFrame` 由浏览器在渲染前统一调度,天然对齐刷新率。 真正需要注意的是 rAF 回调收到的那个 `DOMHighResTimeStamp` 参数,而不是在回调里再调一次 `performance.now()`: ``` let last = 0; function frame(t) { if (!last) last = t; const dt = Math.min(t - last, 50) / 1000; // 切后台回来时 dt 会爆 last = t; update(dt); render(); requestAnimationFrame(frame); } requestAnimationFrame(frame); ``` 那个 `Math.min` 不是可有可无的。标签页切到后台再回来,`t - last` 可能是几十秒,物理积分一步就能把粒子甩到画布外。钳到 50ms 相当于让模拟”慢放”一帧,比炸掉强得多。 累加器式固定步长适合有物理、碰撞的场景,逻辑稳定性比帧率重要: ``` const STEP = 1 / 60; let acc = 0; function frame(t) { const dt = Math.min(t - last, 50) / 1000; last = t; acc += dt; while (acc >= STEP) { update(STEP); acc -= STEP; } render(acc / STEP); // 余数可做插值 } requestAnimationFrame(frame); ``` 逻辑步固定,渲染按余数插值,画面在低刷设备(120Hz、60Hz 混用)上都不会变速。如果做的是纯视觉动效,不做插值也完全够用,别为了严谨把代码复杂度堆上去。 ## 📉 一帧的预算到底花在哪 60fps 意味着 16.6ms 总预算,实际可用更少,浏览器还要做样式计算、合成、光栅化。让人困惑的地方在于:rAF 回调返回不等于画面已经画完。Canvas 2D 的绘制命令通常先入队,真正的光栅化发生在合成阶段,Performance 面板里看到的”长任务”往往只是脚本部分。 所以测的时候要分开记: ``` const t0 = performance.now(); render(); const cost = performance.now() - t0; // 只是脚本侧耗时 ``` 把每帧的 `cost` 塞进一个长度 120 的环形缓冲,看 p95 / p99​ 而不是平均值。平均 6ms 但 p99 25ms,用户感知到的就是每隔几秒卡一下。这个统计方式比肉眼看 FPS 数字靠谱得多。 ## 🧱 状态切换:最容易被忽略的大头 Canvas 2D 的性能陷阱里,`fillStyle`、`font` 这类状态设置的开销排在很前面。每次赋字符串都要解析颜色、匹配字体,同一帧里反复设同一个值纯属浪费。 ``` // 慢:500 次状态切换 + 500 次光栅化for (const p of points) { ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.fill(); } // 快:一次切换,一次 fill ctx.fillStyle = '#3b82f6'; ctx.beginPath(); for (const p of points) { ctx.moveTo(p.x, p.y); ctx.arc(p.x, p.y, p.r, 0, TAU); } ctx.fill(); ``` 同色、同线宽的元素合并成一条路径再一次性提交,实测在几千个粒子的场景下能差出好几倍。颜色必须不同的话,先按颜色分桶,桶内合并。 几个真实很贵的操作,尽量别出现在每帧的主循环里: `shadowBlur` / `shadowColor`:模糊要额外走一遍卷积,粒子多的时候直接拖垮 `ctx.filter = 'blur(4px)'`:比 shadow 还贵,静态内容预渲染到离屏画布上更划算 `createLinearGradient` / `createRadialGradient`:每帧 new 一次既是 GC 压力也是状态开销,缓存起来,尺寸变了再重建 `getImageData`:GPU 到 CPU 的回读,同步阻塞,频繁调用时显式传 `{ willReadFrequently: true }` 让浏览器走 CPU 后端 `save()` / `restore()` 单次很便宜,但循环里几千次地调就是浪费,能用 `setTransform(1,0,0,1,0,0)` 显式复位的地方就别压栈。 ## 🖼️ 离屏画布、分层与脏矩形 预渲染是 2D 上下文最实用的手段:复杂的图标、带阴影的粒子贴图、文字标签,先画到一张离屏 canvas 上,主循环里只做 `drawImage`。一次位图拷贝比重新走一遍路径 + 光栅化便宜得多,而且离屏画布要复用,不要每帧 `createElement('canvas')`。 分层的思路是按变化频率拆画布: 背景层:网格、渐变底、静态装饰,尺寸变化时重画一次 通过对动态的层面化的呈现,如以粒子、连线、波形等为代表的各类动态的视觉元素的每一帧的实时重画,从而使其呈现出更加生动的视觉效果 交互层:tooltip、图例、按钮,直接放 DOM,事件处理和无障碍都省事 UI 用 DOM 覆盖在 canvas 上,比在 canvas 里手搓命中检测省一大截工作量。 脏矩形只清理变化区域,适合元素稀疏的场景: ``` ctx.clearRect(dirty.x, dirty.y, dirty.w, dirty.h); ``` 元素铺满全屏时,维护多个脏区的合并、相交计算反而白搭,这时候整屏 `fillRect` 覆盖反而更直白。不透明画布用 `fillRect` 铺底,需要透明背景才用 `clearRect`。 ## 📱 高分屏与像素填充率 高 DPI 处理写错,性能直接砍半: ``` const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = cssW * dpr; canvas.height = cssH * dpr; canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; ctx.scale(dpr, dpr); ``` 那个 `Math.min(..., 2)` 是刻意加的。3x 屏上像素量是 1x 的 9 倍,填充率压力暴涨,视觉收益却很小。移动端全屏粒子动画在 3x 下掉帧,把 dpr 钳到 2 通常立刻回血。 填充率相关的还有几处:大范围半透明矩形叠加、`globalCompositeOperation = 'lighter'` 铺满全屏、整屏渐变遮罩。背景那类不需要锐利细节的内容,可以按 0.5 倍分辨率画到独立层再放大合成,观感损失小,收益明显。 ## 🧹 GC 抖动:每帧别造垃圾 动画卡顿里有一类是周期性微卡,原因常在垃圾回收。每帧 `new` 几百个粒子对象、数组反复 `push`/`splice`,新生代 GC 就会隔一会儿来一次。 ``` // 粒子数据用 TypedArray 预分配const N = 5000; const px = new Float32Array(N), py = new Float32Array(N); const vx = new Float32Array(N), vy = new Float32Array(N); ``` 结构数组(SoA)比对象数组在这个量级上明显更稳:连续内存、无隐藏类变化、不产生临时对象。数量不定就用对象池,回收时把对象塞回池子而不是丢掉。另外注意闭包——每帧在循环里创建箭头函数、每次 `filter`/`map` 产生新数组,都是隐形分配。 ## 🔀 把绘制搬到 Worker `OffscreenCanvas` 能把整条绘制链挪出主线程,滚动和交互不再被绘制阻塞: ``` const off = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: off }, [off]); ``` 适合绘制重、交互轻的场景(数据可视化大屏、波形流)。要注意的是 DOM 事件得手动转发进 worker,文本测量能力也有限,Safari 的兼容情况按项目支持范围评估。比它更省事的折中方案:只把粒子位置计算放进 worker,用 `SharedArrayBuffer` 或 `postMessage` 传坐标数组,绘制仍在主线程,改动量小很多。 **Categories:** HTML5 ---