从 setTimeout 到 rAF:为什么精度 matters
早期 Web 动画依赖 setTimeout 或 setInterval 驱动,但这两个 API 与浏览器渲染流水线完全脱节。它们既不知道显示器何时刷新,也不关心页面是否处于可视状态。结果是:动画可能在两次屏幕刷新之间更新多次(浪费 CPU),也可能错过刷新窗口导致画面撕裂(jank)。
requestAnimationFrame(rAF)的设计初衷就是解决这个断层。浏览器将 rAF 回调安排在下一次重绘之前执行,天然与显示器的 VSync 信号对齐。在 60Hz 显示器上,rAF 约每 16.67ms 触发一次;在 120Hz 或 144Hz 高刷设备上,它会自动适配到 8.33ms 或 6.94ms 的周期。
更关键的是,现代浏览器会在标签页隐藏时自动暂停 rAF 回调,切回后再恢复,这为后台资源管理提供了原生支持。
固定时间步长:物理更新与渲染分离
直接用 rAF 驱动动画逻辑会遇到一个根本问题:帧时间不稳定。即使显示器固定 60Hz,主线程可能被阻塞,导致两次 rAF 回调间隔从 16ms 跳到 34ms 甚至更长。如果按实际帧时间推进动画,速度就会出现肉眼可见的波动。
游戏引擎的经典解决方案是 ** 固定时间步长(Fixed Timestep)** 模式:
- 设定一个固定的逻辑更新间隔(如 16.667ms 对应 60Hz)
- 使用累加器(accumulator)累积实际经过的时间
- 每满一个固定步长,执行一次物理 / 逻辑更新
- 剩余不足一个步长的时间用于插值渲染
这种分离保证了物理模拟的确定性和可重复性,同时让渲染保持流畅。
掉帧补偿:Clamp 与多步更新
当主线程繁忙或设备性能不足时,单帧实际耗时可能远超固定步长。此时简单的累加会导致时间螺旋:一次更新太多步,进一步拖慢渲染,形成恶性循环。
工程上的防御策略是Clamp 最大帧时间:
const MAX_FRAME_TIME = 250; // ms,超过视为异常,截断处理
const frameTime = Math.min(now - lastTime, MAX_FRAME_TIME);
同时,累加器可能累积多个步长,需要循环执行更新直到累加器小于步长:
while (accumulator >= STEP) {
updatePhysics(currentState, STEP);
accumulator -= STEP;
}
如果一次跳过了 5 个步长,物理会连续更新 5 次,确保逻辑不丢失。视觉上的平滑则由接下来的插值环节保证。
平滑插值:Alpha 混合与双状态管理
固定步长更新后,累加器中通常剩余不足一个步长的时间(如 0.3 个步长)。此时如果直接渲染当前状态,物体会出现 "跳跃" 感 —— 它实际上应该处于两个逻辑状态之间的某个位置。
解决方案是维护双状态:previousState 和 currentState。每次固定步长更新后,将当前状态复制到前一状态,再推进当前状态。渲染时计算插值系数:
const alpha = accumulator / STEP; // 0.0 ~ 1.0
const renderState = interpolate(previousState, currentState, alpha);
render(renderState);
线性插值适用于位置、缩放、透明度等连续属性。但对于角度,需要处理环绕(wrap-around)问题,例如从 350° 插值到 10° 应该顺时针短路径过渡,而非逆时针绕一大圈。
可落地的代码骨架
以下是一个完整的动画循环骨架,可直接嵌入 Canvas 或 WebGL 项目:
const STEP = 1000 / 60; // 16.667ms
const MAX_FRAME_TIME = 250; // 防跳变阈值
let lastTime = performance.now();
let accumulator = 0;
let previousState = createState();
let currentState = createState();
function loop(now) {
// 1. 计算帧时间并 Clamp
const frameTime = Math.min(now - lastTime, MAX_FRAME_TIME);
lastTime = now;
accumulator += frameTime;
// 2. 固定步长更新(可能多次)
while (accumulator >= STEP) {
previousState = clone(currentState);
updatePhysics(currentState, STEP / 1000); // 转为秒
accumulator -= STEP;
}
// 3. 插值渲染
const alpha = accumulator / STEP;
const renderState = interpolate(previousState, currentState, alpha);
draw(renderState);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
边界情况与监控点
标签页切换:rAF 在后台暂停,返回前台后 performance.now() 的差值可能累积数秒。Clamp 机制在此处发挥作用,防止物体瞬移。
高刷显示器适配:代码中的 STEP 固定为 60Hz,但在 120Hz 设备上 rAF 会以更高频率调用。由于累加器每次只增加 8ms 左右,while 循环通常只执行 0 或 1 次,渲染插值让画面依然平滑。如需利用高刷优势,可将 STEP 缩小到 8.33ms。
性能监控:建议在生产环境埋点追踪 frameTime 的 P99 值,超过 33ms(2 帧)的占比应控制在 1% 以内。
资料来源
- MDN Web Docs: Window.requestAnimationFrame () —— 浏览器刷新同步机制官方说明
- "Fix Your Timestep!" —— Gaffer on Games 经典游戏循环模式
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。