3、requestAnimationFrame 详解:与 setTimeout/setInterval 的本质区别,回调时机与帧同步机制,最佳实践

说到动画性能优化,绕不开的就是 requestAnimationFrame(简称 rAF)。

说实话,我早年做动画的时候,也用过 setTimeout 和 setInterval。那时候觉得,不就是每隔多少毫秒执行一次回调嘛,能有多大差别?直到有一次,我在一个移动端 H5 活动页里用 setInterval 做跑马灯动画,结果在低端机上卡成 PPT,还被产品经理当众点名……嗯,从那以后,我彻底把动画的定时器换成了 rAF。

这一章,我就把 rAF 的底层逻辑、和传统定时器的本质区别,以及我踩过的坑,一次性讲清楚。

3.1 本质区别:rAF 不是定时器,而是帧同步器

很多人以为 requestAnimationFrame 是「更精确的定时器」。其实不是。

定时器(setTimeout/setInterval)的核心是「时间」——到了指定时间就执行回调,不管浏览器当前在做什么。

rAF 的核心是「帧」——它告诉浏览器:「下次你准备绘制新帧的时候,叫我一声,我先把动画状态更新好。」

一句话总结:

  • setTimeout 是「到点就执行,不管浏览器忙不忙」
  • requestAnimationFrame 是「浏览器要画新帧了,你赶紧把数据准备好」

你想想看,浏览器每 16.67ms(60fps)会触发一次渲染流程。如果你用 setTimeout(fn, 16.67),回调可能落在两次渲染之间,也可能落在渲染过程中,甚至可能因为事件队列阻塞而延迟。结果就是:掉帧、撕裂、卡顿。

而 rAF 的回调,永远在浏览器「准备绘制新帧之前」执行。说白了,它和浏览器的渲染管线是同步的。

3.2 回调时机与帧同步机制

我们来拆解一下浏览器的一帧里发生了什么。我画了一张图,你看完就明白了。

浏览器一帧内的渲染管线(60fps 场景) 输入事件处理 rAF 回调执行 样式计算 布局 绘制 rAF 回调在「样式计算」之前执行,此时修改 DOM/CSS 会被合并到当前帧的渲染中 0ms ~4ms ~8ms ~12ms ~16ms setTimeout(fn, 16.67) 的回调可能落在任意位置,甚至跨帧 而 rAF 保证回调在「绿色节点」之前执行,与帧同步

看到没?rAF 回调的位置非常关键。它紧挨着「样式计算」之前。这意味着:

  • 你在 rAF 里修改的样式,会被浏览器立即合并到当前帧,不会延迟到下一帧
  • 如果页面切换到后台标签页,浏览器会暂停 rAF 回调,节省 CPU/GPU 资源
  • 而 setInterval 在后台依然会执行,白白浪费性能

一个小技巧:

如果你需要测量动画的 FPS,可以在 rAF 回调里记录时间戳。我习惯这样写:

let lastTime = performance.now();
let frameCount = 0;

function tick(now) {
  frameCount++;
  if (now - lastTime >= 1000) {
    console.log(`FPS: ${frameCount}`);
    frameCount = 0;
    lastTime = now;
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

3.3 与 setTimeout/setInterval 的对比表格

我把三者的核心差异整理成了一张表,方便你对照查阅。

对比维度 requestAnimationFrame setTimeout / setInterval
调度依据 浏览器渲染帧(vsync 信号) 系统时钟(毫秒)
执行时机 每次渲染前,与帧同步 时间到达后,插入任务队列
后台行为 自动暂停,节省资源 继续执行(最小间隔被限制为 1000ms)
精度 与显示器刷新率对齐(60/120/144Hz) 受事件队列阻塞影响,误差可达 4-10ms
掉帧处理 自动跳过积压的回调,只执行最新一次 回调会堆积,导致「回调雪崩」
适用场景 动画、滚动、Canvas 绘制、WebGL 倒计时、轮询、非 UI 定时任务

注意:

我曾经在项目里用 setInterval 做 Canvas 粒子动画,结果用户切到其他标签页再切回来,粒子直接「瞬移」了。原因就是 setInterval 在后台堆积了大量回调,回来时一次性执行,导致位置跳跃。换成 rAF 后,一切正常。

3.4 最佳实践:我总结的 5 条铁律

这些年我经手过不少动画项目,踩坑无数。下面这 5 条,是我认为最实用的 rAF 最佳实践。

3.4.1 用 rAF 驱动动画,而不是 setTimeout

这是最基本的一条。只要你的动画需要更新视觉(DOM、Canvas、WebGL),就用 rAF。别犹豫。

3.4.2 在 rAF 回调里计算时间差,而不是固定步长

很多新手会这样写:

// ❌ 错误:假设每帧间隔固定为 16.67ms
function animate() {
  element.style.transform = `translateX(${x += 10}px)`;
  requestAnimationFrame(animate);
}

为什么不对?因为显示器可能是 120Hz(8.33ms 一帧),或者用户电脑卡顿导致帧间隔变成 30ms。固定步长会让动画在不同设备上速度不一样。

正确的做法是:

// ✅ 正确:基于时间差计算位移
let lastTime = 0;
const speed = 600; // 像素/秒

function animate(time) {
  if (lastTime === 0) lastTime = time;
  const delta = (time - lastTime) / 1000; // 转为秒
  lastTime = time;

  const x = speed * delta;
  element.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

3.4.3 避免在 rAF 里做重计算或同步请求

rAF 回调里不要做这些事:

  • 大数据排序或遍历
  • 同步 AJAX 请求
  • 大量字符串拼接或正则匹配
  • 触发强制回流(如读取 offsetTop 后修改样式)

这些操作会阻塞渲染管线,导致掉帧。如果确实有耗时任务,用 requestIdleCallback 或者 Web Worker 分流。

3.4.4 取消未使用的 rAF 请求

组件卸载或页面隐藏时,记得调用 cancelAnimationFrame。否则回调会一直运行,造成内存泄漏。

let rafId;

function startAnimation() {
  function tick() {
    // 更新动画
    rafId = requestAnimationFrame(tick);
  }
  rafId = requestAnimationFrame(tick);
}

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

3.4.5 善用 rAF 的「自动降频」特性

如果页面切换到后台,rAF 会自动停止。但如果你希望后台也保持某些逻辑(比如音频进度同步),可以结合 document.hidden 和 visibilitychange 事件做降级处理。

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // 页面隐藏,切换到低精度定时器
    startLowPrecisionTimer();
  } else {
    // 页面可见,切回 rAF
    stopLowPrecisionTimer();
    startRAFAnimation();
  }
});

核心原则:

rAF 不是万能的,但只要是「视觉更新」,它就是最优解。把计算逻辑和渲染逻辑分离,计算放 rAF 之外,渲染放 rAF 之内,这是我一直坚持的架构方式。

3.5 一个完整的实战示例

最后,我给你看一个我常用的「平滑滚动进度条」的写法。它结合了 rAF 和基于时间的动画,你可以直接拿去用。

class SmoothProgress {
  constructor(element) {
    this.el = element;
    this.target = 0;
    this.current = 0;
    this.rafId = null;
  }

  setProgress(value) {
    this.target = Math.min(1, Math.max(0, value));
    if (!this.rafId) {
      this.start();
    }
  }

  start() {
    const step = (time) => {
      // 基于时间的缓动
      const diff = this.target - this.current;
      if (Math.abs(diff) < 0.001) {
        this.current = this.target;
        this.el.style.width = `${this.target * 100}%`;
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
        return;
      }

      // 每帧移动剩余距离的 20%,形成缓动效果
      this.current += diff * 0.2;
      this.el.style.width = `${this.current * 100}%`;

      this.rafId = requestAnimationFrame(step);
    };

    this.rafId = requestAnimationFrame(step);
  }

  destroy() {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }
  }
}

这个类的好处是:

  • 动画由 rAF 驱动,与帧同步
  • 基于时间差计算,不依赖固定帧率
  • 目标值变化时自动启动,到达后自动停止
  • 销毁时清理 rAF,防止内存泄漏

嗯,关于 rAF 的核心内容,差不多就这些了。记住一句话:动画用 rAF,定时用 setTimeout,别搞混了。