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 的核心逻辑和与旧方案的对比。你可以把它当作本章的「思维导图」。
避坑指南:我踩过的几个坑
最后分享几个实战中容易翻车的地方:
- 不要用 rAF 做「一次性延迟」:rAF 是循环调用的,如果你只想延迟一次,用
setTimeout更合适。别杀鸡用牛刀。 - 注意 this 指向:rAF 回调里的
this是window。如果你用类方法做回调,记得 bind 或者用箭头函数。我因为这个 bug 排查了半小时。 - 不要嵌套 rAF 太深:如果你在 rAF 回调里又调了
requestAnimationFrame,理论上没问题。但如果你在回调里同步执行了耗时操作(比如大量 DOM 操作),下一帧就会延迟。保持回调轻量。 - 帧率降了怎么办?:如果用户设备性能差,rAF 会自动降帧(比如降到 30fps)。这时候你的
deltaTime机制就派上用场了——动画速度不变,只是看起来「跳」了一点。比直接卡死强多了。
一句话总结:requestAnimationFrame 是浏览器动画的「亲儿子」。它知道什么时候该画,什么时候该歇。你只需要把每一帧要干的事告诉它,剩下的交给浏览器。