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 回调时机与帧同步机制
我们来拆解一下浏览器的一帧里发生了什么。我画了一张图,你看完就明白了。
看到没?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,别搞混了。