requestAnimationFrame 详解:告别掉帧,拥抱 60fps

说实话,我刚入行那会儿,做动画第一反应就是 setInterval。那时候觉得这玩意儿多简单啊,每隔 16ms 执行一次,不就是 60fps 吗?结果呢?页面一卡一卡的,用户反馈说「你这动画跟幻灯片似的」。后来我才明白,浏览器里的时间,从来就不是我们以为的那样。

这一章,我们就来彻底搞懂 requestAnimationFrame——它为什么是浏览器动画的「正统」方案,以及怎么用它写出真正稳定的 60fps 循环。

setTimeout/setInterval 的「假」帧率

先说说为什么 setInterval 不靠谱。你想想看,setInterval(fn, 16) 真的能保证 16ms 执行一次吗?

不能。原因有三:

  • 任务队列排队:JavaScript 是单线程的。如果主线程上有其他任务在跑(比如事件处理、DOM 解析),你的回调就得等着。等多久?不确定。
  • 没有垂直同步:显示器刷新是有固定节奏的(通常是 60Hz)。setInterval 不管这些,它可能在两次刷新之间执行,导致画面撕裂或丢帧。
  • 后台标签页不暂停:即使用户切到其他标签页,setInterval 还在跑。白白浪费 CPU 和电池。

核心结论:setTimeout/setInterval 只保证「延迟时间到了之后尽快执行」,不保证「在正确的帧时间点执行」。这是两个完全不同的概念。

requestAnimationFrame 的执行时机

requestAnimationFrame(简称 rAF)就不一样了。它的回调会在浏览器下一次重绘之前执行。说白了,浏览器自己会算:什么时候该画下一帧了,我就叫你。

这个时机非常精准:

  • 显示器 60Hz → 每 16.67ms 调用一次
  • 显示器 120Hz → 每 8.33ms 调用一次
  • 显示器 30Hz → 每 33.33ms 调用一次

而且,如果用户切到后台标签页,rAF 会自动暂停。不会浪费一丝性能。这一点我在做移动端 H5 动画时深有体会——用户切出去回个,回来动画还在原地,不会出现「时间跳跃」的 bug。

小技巧:rAF 返回一个 ID,你可以用 cancelAnimationFrame(id) 取消它。这比 clearInterval 优雅多了。

对比表格:一图胜千言

特性 setTimeout/setInterval requestAnimationFrame
执行时机 延迟后尽快执行 下次重绘前执行
帧率控制 手动指定间隔 自动匹配显示器刷新率
后台行为 继续执行 自动暂停
精度 低(受任务队列影响) 高(与 VSync 同步)
电池优化 无 有(后台暂停)
回调参数 无时间戳 自动传入高精度时间戳

回调机制:时间戳的秘密

rAF 的回调函数会自动接收一个参数——DOMHighResTimeStamp。这个时间戳是页面加载开始到当前回调触发的时间,精度到微秒级。

为什么要给你这个时间戳?

因为你不能假设每次回调之间的间隔是固定的。即使 rAF 很稳定,但偶尔的 GC(垃圾回收)或用户操作可能会让某一帧延迟。如果你用计数器累加的方式做动画,时间一长就会漂移。

正确的做法是:用时间戳计算 deltaTime。

let lastTime = 0;

function loop(timestamp) {
  // 计算两帧之间的时间差(秒)
  const delta = (timestamp - lastTime) / 1000;
  lastTime = timestamp;

  // 用 delta 更新动画位置
  element.style.transform = `translateX(${position += speed * delta}px)`;

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

这样,不管帧率是 60fps 还是 30fps,动画的物理速度都是一致的。不会出现「掉帧后突然加速」的诡异现象。

注意:第一次回调时,lastTime 是 0,所以第一次 delta 会非常大。记得初始化 lastTime = timestamp 或者在循环开始前跳过第一帧。

实现一个稳定的 60fps 循环

好了,理论说完了。我们来写一个真正能用的 60fps 循环。我个人习惯把它封装成一个工具类,方便复用。

class AnimationLoop {
  constructor(callback) {
    this.callback = callback;
    this.isRunning = false;
    this.lastTime = 0;
    this.frameId = null;
  }

  start() {
    if (this.isRunning) return;
    this.isRunning = true;
    this.lastTime = performance.now();
    this._tick(this.lastTime);
  }

  stop() {
    this.isRunning = false;
    if (this.frameId) {
      cancelAnimationFrame(this.frameId);
      this.frameId = null;
    }
  }

  _tick = (timestamp) => {
    if (!this.isRunning) return;

    const delta = (timestamp - this.lastTime) / 1000;
    this.lastTime = timestamp;

    // 防止 delta 过大(比如用户从后台切回来)
    const safeDelta = Math.min(delta, 0.1); // 最多 100ms

    this.callback(safeDelta, timestamp);

    this.frameId = requestAnimationFrame(this._tick);
  }
}

// 使用示例
const loop = new AnimationLoop((delta, timestamp) => {
  // 你的动画逻辑
  ball.x += ball.vx * delta;
  ball.y += ball.vy * delta;
});

loop.start();

这里我加了一个 safeDelta 的限制。为什么?

我曾经遇到过一个 bug:用户把浏览器标签页挂起了一分钟,切回来之后,delta 变成了 60 秒。结果动画里的物体瞬间飞到了屏幕外面。加上 Math.min(delta, 0.1) 之后,最多只跳 100ms 的动画,视觉上几乎察觉不到。

知识体系:一张图看懂

下面这张 SVG 图总结了 rAF 的核心逻辑和与旧方案的对比。你可以把它当作本章的「思维导图」。

requestAnimationFrame 核心知识体系 requestAnimationFrame setTimeout / setInterval ❌ 不与 VSync 同步 ❌ 后台不暂停 ❌ 精度受任务队列影响 rAF 核心优势 ✅ 自动匹配显示器刷新率 ✅ 后台自动暂停 ✅ 回调自带高精度时间戳 稳定 60fps 实现要点 用 deltaTime 计算 限制最大 delta 封装可复用的 Loop 💡 核心原则:永远不要假设帧间隔固定,永远用时间戳驱动动画

避坑指南:我踩过的几个坑

最后分享几个实战中容易翻车的地方:

  1. 不要用 rAF 做「一次性延迟」:rAF 是循环调用的,如果你只想延迟一次,用 setTimeout 更合适。别杀鸡用牛刀。
  2. 注意 this 指向:rAF 回调里的 this 是 window。如果你用类方法做回调,记得 bind 或者用箭头函数。我因为这个 bug 排查了半小时。
  3. 不要嵌套 rAF 太深:如果你在 rAF 回调里又调了 requestAnimationFrame,理论上没问题。但如果你在回调里同步执行了耗时操作(比如大量 DOM 操作),下一帧就会延迟。保持回调轻量。
  4. 帧率降了怎么办?:如果用户设备性能差,rAF 会自动降帧(比如降到 30fps)。这时候你的 deltaTime 机制就派上用场了——动画速度不变,只是看起来「跳」了一点。比直接卡死强多了。

一句话总结:requestAnimationFrame 是浏览器动画的「亲儿子」。它知道什么时候该画,什么时候该歇。你只需要把每一帧要干的事告诉它,剩下的交给浏览器。