3、requestAnimationFrame详解:浏览器渲染机制、帧率控制、与定时器的性能差异

说到动画,很多新手第一反应就是 setInterval 或者 setTimeout。嗯,我当年也是这么过来的。但后来在做一个 H5 游戏的时候,发现动画总是卡顿,掉帧严重。那时候我才真正去研究浏览器到底是怎么渲染页面的,也就认识了 requestAnimationFrame——这个动画领域的「正规军」。

说白了,requestAnimationFrame 就是浏览器专门为动画提供的 API。它知道什么时候该刷新屏幕,什么时候该让你歇着。你想想看,这比你自己瞎猜一个间隔时间要靠谱得多吧?

浏览器渲染机制:一帧里发生了什么?

要理解 requestAnimationFrame 为什么好,得先知道浏览器一帧里干了啥。我习惯把一帧想象成一个流水线,大概长这样:

  1. JavaScript 执行:处理你的脚本、事件回调
  2. 样式计算:算出每个元素最终该长什么样
  3. 布局:计算元素的位置和大小
  4. 绘制:把像素画出来
  5. 合成:把各个图层拼成一张图

这一整套流程,浏览器希望在 16.67ms 内完成(60fps 的情况下)。如果超过这个时间,下一帧就会延迟,用户就会感觉到卡顿。

关键点requestAnimationFrame 的回调就插在「JavaScript 执行」这个阶段,而且是在浏览器开始准备新一帧的时候。这意味着你的动画代码和浏览器的渲染节奏是同步的。

requestAnimationFrame 的工作原理

它的用法其实很简单。你传一个回调函数进去,浏览器会在下次重绘之前调用它。

function animate() {
  // 更新动画状态
  element.style.transform = `translateX(${pos}px)`;
  pos += 2;

  // 请求下一帧
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

这里有个细节:requestAnimationFrame 会给你传一个时间戳参数。这个时间戳是高精度时间,单位是毫秒。我个人习惯用它来计算动画的进度,而不是自己用 Date.now()

let startTime = null;

function animate(timestamp) {
  if (!startTime) startTime = timestamp;
  const progress = timestamp - startTime;

  // 根据时间进度计算位置
  const pos = Math.min(progress * 0.1, 500);
  element.style.transform = `translateX(${pos}px)`;

  if (pos < 500) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

小技巧:用时间戳来控制动画,可以保证不同刷新率屏幕上的动画速度一致。60Hz 和 144Hz 的屏幕,动画跑完的时间是一样的。

帧率控制:别让动画跑太快

有时候你不想让动画跑满 60fps,比如做一个每秒钟更新 30 次的进度条。这时候怎么办?

我见过有人用 setInterval 设 33ms,但这样会和浏览器的渲染周期错开,反而更卡。更好的做法是用 requestAnimationFrame 配合时间判断:

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

function animate(timestamp) {
  const delta = timestamp - lastTime;

  if (delta >= interval) {
    // 更新动画
    updateAnimation();
    lastTime = timestamp - (delta % interval); // 修正累积误差
  }

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

这样做的好处是,你的更新逻辑依然和浏览器的渲染周期同步,只是跳过了中间的一些帧。我曾经在一个数据可视化项目里用这个技巧,把大量数据点的更新控制在 20fps,CPU 占用直接降了一半。

注意:不要用 requestAnimationFrame 做单纯的计时器。如果页面被隐藏了,浏览器会暂停它的回调,你的计时就不准了。我踩过这个坑,后来改用 setTimeout 做后台计时,requestAnimationFrame 只负责渲染。

与定时器的性能差异

咱们来做个对比。我用一个简单的动画来测试:让一个方块从左移到右,分别用 setIntervalrequestAnimationFrame 实现。

对比项 setInterval / setTimeout requestAnimationFrame
执行时机 到时间就执行,不管浏览器是否准备好 在浏览器准备重绘时执行
帧率稳定性 容易累积延迟,造成丢帧或跳帧 自动匹配屏幕刷新率,稳定 60fps
CPU 占用 页面隐藏时依然执行,浪费资源 页面隐藏时自动暂停,省电省 CPU
时间精度 最低 4ms 的嵌套延迟 高精度时间戳,微秒级
动画平滑度 容易产生撕裂感 与垂直同步对齐,画面流畅

为什么会这样?说白了,setInterval 只管「到点就执行」,它不管浏览器现在是不是在渲染。如果你的回调执行时间超过了间隔时间,任务就会堆积,然后一下子全跑完——画面就「跳」过去了。

requestAnimationFrame 是「浏览器说可以画了,你才动」。它保证你的每一帧更新,都能被浏览器完整地渲染出来。不会有中间丢失的帧,也不会有重复的帧。

一句话总结setInterval 是「我不管你在干嘛,到点我就干活」;requestAnimationFrame 是「你准备好了叫我,我随时可以干活」。显然后者更懂浏览器的脾气。

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

第一个坑:忘记取消请求。组件卸载了,动画还在跑,控制台报错说「在已卸载的组件上操作 DOM」。解决办法是保存 requestAnimationFrame 返回的 ID,在清理的时候用 cancelAnimationFrame 取消。

let rafId;

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

function stop() {
  cancelAnimationFrame(rafId);
}

第二个坑:嵌套太深导致栈溢出。如果你在 requestAnimationFrame 回调里又调用了 requestAnimationFrame,理论上不会栈溢出,因为它是异步的。但如果你在回调里同步递归调用自己,那就危险了。

第三个坑:以为它是精确的定时器。它不保证每秒刚好 60 帧。如果浏览器忙不过来,它会自动降帧。所以做物理引擎的时候,一定要用时间戳来计算位移,而不是假设每帧固定走多少像素。

我的习惯:所有动画都用 requestAnimationFrame,除非是后台需要持续运行的逻辑(比如 WebSocket 心跳)。定时器只用来做「延迟执行」和「间隔执行」,不做动画。

好了,关于 requestAnimationFrame 的核心内容就这些。记住一句话:动画用 rAF,定时用 setTimeout,别混着用。下一章咱们聊聊如何用这些知识做一个高性能的粒子系统,到时候你会更深刻地体会到 rAF 的威力。