3. requestAnimationFrame 详解:与 setTimeout/setInterval 对比,掌握其调度机制与最佳实践

聊到动画刷新,很多人第一反应就是 setTimeout 或者 setInterval。说实话,我刚入行那会儿也是这么干的。直到有一次我做一个 H5 的跑马灯效果,用 setInterval 跑得飞起,结果在低端机上卡成幻灯片,还被测试小姐姐追着改 bug……嗯,从那以后我才真正去研究 requestAnimationFrame(简称 rAF)。

这一节,咱们就把 rAF 的调度机制掰开揉碎,再跟它的两位“前辈”做个彻底对比。

3.1 为什么需要 rAF?

先问一个问题:浏览器什么时候最适合更新动画?

答案是——在屏幕即将刷新的时候。大多数显示器的刷新率是 60Hz,也就是每 16.67ms 刷新一次。如果你在两次刷新之间改了 DOM 样式,浏览器并不会立刻绘制,而是要等到下一个 vsync 信号到来时才统一处理。

我用 setTimeout 做动画时,经常遇到两个坑:

  • 掉帧:任务队列里如果塞了其他同步任务,定时器回调可能被延迟执行,错过 vsync 窗口。
  • 浪费计算:即使屏幕没刷新,定时器依然在疯狂执行,白白消耗 CPU/GPU。

rAF 的出现,就是为了解决这两个问题。它告诉浏览器:“嘿,等你要刷新屏幕的时候再叫我,我保证给你最新的状态。”

核心结论:rAF 的回调会在每次浏览器重绘之前执行,且执行次数严格匹配屏幕刷新率。这是它跟定时器最本质的区别。

3.2 调度机制:rAF 到底怎么工作的?

我画了一张图,帮你理解 rAF 在浏览器渲染流水线中的位置:

requestAnimationFrame 调度流程 JS 执行 样式计算 布局 (Layout) 绘制 (Paint) rAF 回调 💡 rAF 回调在每次重绘前执行,且只执行一次。 如果回调中修改了样式,浏览器会在同一帧内完成样式计算、布局和绘制。

看到没?rAF 回调的执行时机,就在 JS 执行阶段之后、样式计算之前。这意味着你在回调里修改 DOM,浏览器能立刻感知到,并在同一帧完成后续的布局和绘制。

小技巧:如果你在 rAF 回调里读取布局属性(比如 offsetTop),浏览器会强制同步布局(Forced Reflow),反而会拖慢性能。我建议把“读”和“写”分开:先统一读,再统一写。

3.3 与 setTimeout/setInterval 的对比

咱们直接上表格,一目了然:

对比维度 requestAnimationFrame setTimeout / setInterval
执行时机 每次屏幕刷新前(vsync 信号) 任务队列空闲时,不关心刷新周期
执行频率 匹配屏幕刷新率(通常 60fps) 由开发者指定,但可能被延迟
CPU 节能 页面不可见时自动暂停 始终执行,浪费资源
精度 高,与 vsync 同步 低,受事件循环影响
回调参数 自动传入高精度时间戳 无时间戳,需手动计算
适用场景 动画、滚动、Canvas 绘制 延时任务、轮询、非动画定时操作

我个人的经验是:凡是跟视觉更新沾边的,优先用 rAF。定时器更适合做“过一会儿再干”的事,比如 3 秒后弹个提示框。

3.4 最佳实践:怎么写好 rAF 动画?

3.4.1 基础用法

function animate(timestamp) {
  // timestamp 是 rAF 自动传入的高精度时间戳(毫秒)
  // 用它来计算动画进度,比 Date.now() 更精准

  const progress = (timestamp - startTime) / duration;
  if (progress < 1) {
    element.style.transform = `translateX(${progress * 200}px)`;
    requestAnimationFrame(animate);
  }
}

const startTime = performance.now();
const duration = 2000; // 2 秒
requestAnimationFrame(animate);

注意看,我用了 performance.now() 而不是 Date.now()。为什么?因为 performance.now() 返回的是页面启动以来的高精度时间,不受系统时间调整影响,而且精度更高(微秒级)。

3.4.2 控制帧率

有时候你不需要跑满 60fps,比如一个缓慢的呼吸灯效果,30fps 就够了。怎么做?

let lastTime = 0;
const targetFPS = 30;
const interval = 1000 / targetFPS;

function loop(timestamp) {
  const delta = timestamp - lastTime;
  if (delta >= interval) {
    // 执行动画更新
    updateAnimation(delta);
    lastTime = timestamp - (delta % interval); // 对齐时间
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

这里有个细节:我用 delta % interval 做了时间对齐,避免累积误差。如果不做对齐,长时间运行后动画会越来越慢。

避坑指南:千万不要在 rAF 回调里嵌套 setTimeout 或 setInterval。我曾经在一个项目中这么干过,结果回调执行顺序完全乱套,动画出现诡异的“跳帧”。rAF 的回调队列是独立的,跟定时器队列混用会导致不可预测的延迟。

3.4.3 取消动画

rAF 返回一个 ID,你可以用 cancelAnimationFrame(id) 取消它:

let rafId;

function start() {
  rafId = requestAnimationFrame(loop);
}

function stop() {
  if (rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

这个习惯一定要养成。组件卸载、页面隐藏时记得取消,否则回调会一直占用资源。

3.4.4 页面不可见时自动暂停

这是 rAF 的一大亮点。当页面切换到后台标签页时,rAF 会自动停止回调。而 setInterval 不会停,它会在后台疯狂执行,等你切回来时发现 CPU 占用率飙升。

不过要注意:rAF 暂停后,时间戳会“跳变”。比如你切出去 5 秒,回来时 timestamp 直接增加了 5000ms。如果你用 timestamp 做累加,动画会瞬间跳到结束位置。解决办法是用 deltaTime(帧间隔时间)来控制进度:

let lastTimestamp = 0;

function loop(timestamp) {
  if (!lastTimestamp) lastTimestamp = timestamp;
  const delta = timestamp - lastTimestamp;
  lastTimestamp = timestamp;

  // 用 delta 控制动画进度,而不是累计时间
  progress += delta * speed;
  element.style.transform = `translateX(${progress}px)`;

  requestAnimationFrame(loop);
}

这样即使页面切回来,动画也只是“暂停”了一段时间,不会出现跳跃。

3.5 性能对比:一个真实的案例

我之前优化过一个 Canvas 粒子系统,粒子数量 5000 个。用 setInterval(16) 跑,在 Chrome DevTools 的 Performance 面板里看到:

  • 每帧耗时:22-35ms(经常超过 16.67ms 的预算)
  • 掉帧率:约 15%
  • CPU 占用:稳定在 40% 以上

改成 rAF 后:

  • 每帧耗时:12-16ms(稳定在预算内)
  • 掉帧率:< 1%
  • CPU 占用:30% 左右(页面切后台时降到 0%)

差距很明显吧?说白了,rAF 就是让浏览器“自己决定什么时候干活”,而不是你强行命令它。你想想看,浏览器比你更了解自己的渲染管线,为什么不信任它呢?

一句话总结:做动画,忘掉 setInterval,拥抱 requestAnimationFrame。它不仅是性能优化,更是一种“跟浏览器合作”的思维方式。