3. requestAnimationFrame 详解:与 setTimeout/setInterval 对比,掌握其调度机制与最佳实践
聊到动画刷新,很多人第一反应就是 setTimeout 或者 setInterval。说实话,我刚入行那会儿也是这么干的。直到有一次我做一个 H5 的跑马灯效果,用 setInterval 跑得飞起,结果在低端机上卡成幻灯片,还被测试小姐姐追着改 bug……嗯,从那以后我才真正去研究 requestAnimationFrame(简称 rAF)。
这一节,咱们就把 rAF 的调度机制掰开揉碎,再跟它的两位“前辈”做个彻底对比。
3.1 为什么需要 rAF?
先问一个问题:浏览器什么时候最适合更新动画?
答案是——在屏幕即将刷新的时候。大多数显示器的刷新率是 60Hz,也就是每 16.67ms 刷新一次。如果你在两次刷新之间改了 DOM 样式,浏览器并不会立刻绘制,而是要等到下一个 vsync 信号到来时才统一处理。
我用 setTimeout 做动画时,经常遇到两个坑:
- 掉帧:任务队列里如果塞了其他同步任务,定时器回调可能被延迟执行,错过 vsync 窗口。
- 浪费计算:即使屏幕没刷新,定时器依然在疯狂执行,白白消耗 CPU/GPU。
rAF 的出现,就是为了解决这两个问题。它告诉浏览器:“嘿,等你要刷新屏幕的时候再叫我,我保证给你最新的状态。”
核心结论:rAF 的回调会在每次浏览器重绘之前执行,且执行次数严格匹配屏幕刷新率。这是它跟定时器最本质的区别。
3.2 调度机制:rAF 到底怎么工作的?
我画了一张图,帮你理解 rAF 在浏览器渲染流水线中的位置:
看到没?rAF 回调的执行时机,就在 JS 执行阶段之后、样式计算之前。这意味着你在回调里修改 DOM,浏览器能立刻感知到,并在同一帧完成后续的布局和绘制。
小技巧:如果你在 rAF 回调里读取布局属性(比如 offsetTop),浏览器会强制同步布局(Forced Reflow),反而会拖慢性能。我建议把“读”和“写”分开:先统一读,再统一写。
3.3 与 setTimeout/setInterval 的对比
咱们直接上表格,一目了然:
| 对比维度 | requestAnimationFrame | setTimeout / setInterval |
|---|---|---|
| 执行时机 | 每次屏幕刷新前(vsync 信号) | 任务队列空闲时,不关心刷新周期 |
| 执行频率 | 匹配屏幕刷新率(通常 60fps) | 由开发者指定,但可能被延迟 |
| CPU 节能 | 页面不可见时自动暂停 | 始终执行,浪费资源 |
| 精度 | 高,与 vsync 同步 | 低,受事件循环影响 |
| 回调参数 | 自动传入高精度时间戳 | 无时间戳,需手动计算 |
| 适用场景 | 动画、滚动、Canvas 绘制 | 延时任务、轮询、非动画定时操作 |
我个人的经验是:凡是跟视觉更新沾边的,优先用 rAF。定时器更适合做“过一会儿再干”的事,比如 3 秒后弹个提示框。
3.4 最佳实践:怎么写好 rAF 动画?
3.4.1 基础用法
function animate(timestamp) {
// timestamp 是 rAF 自动传入的高精度时间戳(毫秒)
// 用它来计算动画进度,比 Date.now() 更精准
const progress = (timestamp - startTime) / duration;
if (progress < 1) {
element.style.transform = `translateX(${progress * 200}px)`;
requestAnimationFrame(animate);
}
}
const startTime = performance.now();
const duration = 2000; // 2 秒
requestAnimationFrame(animate);
注意看,我用了 performance.now() 而不是 Date.now()。为什么?因为 performance.now() 返回的是页面启动以来的高精度时间,不受系统时间调整影响,而且精度更高(微秒级)。
3.4.2 控制帧率
有时候你不需要跑满 60fps,比如一个缓慢的呼吸灯效果,30fps 就够了。怎么做?
let lastTime = 0;
const targetFPS = 30;
const interval = 1000 / targetFPS;
function loop(timestamp) {
const delta = timestamp - lastTime;
if (delta >= interval) {
// 执行动画更新
updateAnimation(delta);
lastTime = timestamp - (delta % interval); // 对齐时间
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
这里有个细节:我用 delta % interval 做了时间对齐,避免累积误差。如果不做对齐,长时间运行后动画会越来越慢。
避坑指南:千万不要在 rAF 回调里嵌套 setTimeout 或 setInterval。我曾经在一个项目中这么干过,结果回调执行顺序完全乱套,动画出现诡异的“跳帧”。rAF 的回调队列是独立的,跟定时器队列混用会导致不可预测的延迟。
3.4.3 取消动画
rAF 返回一个 ID,你可以用 cancelAnimationFrame(id) 取消它:
let rafId;
function start() {
rafId = requestAnimationFrame(loop);
}
function stop() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
这个习惯一定要养成。组件卸载、页面隐藏时记得取消,否则回调会一直占用资源。
3.4.4 页面不可见时自动暂停
这是 rAF 的一大亮点。当页面切换到后台标签页时,rAF 会自动停止回调。而 setInterval 不会停,它会在后台疯狂执行,等你切回来时发现 CPU 占用率飙升。
不过要注意:rAF 暂停后,时间戳会“跳变”。比如你切出去 5 秒,回来时 timestamp 直接增加了 5000ms。如果你用 timestamp 做累加,动画会瞬间跳到结束位置。解决办法是用 deltaTime(帧间隔时间)来控制进度:
let lastTimestamp = 0;
function loop(timestamp) {
if (!lastTimestamp) lastTimestamp = timestamp;
const delta = timestamp - lastTimestamp;
lastTimestamp = timestamp;
// 用 delta 控制动画进度,而不是累计时间
progress += delta * speed;
element.style.transform = `translateX(${progress}px)`;
requestAnimationFrame(loop);
}
这样即使页面切回来,动画也只是“暂停”了一段时间,不会出现跳跃。
3.5 性能对比:一个真实的案例
我之前优化过一个 Canvas 粒子系统,粒子数量 5000 个。用 setInterval(16) 跑,在 Chrome DevTools 的 Performance 面板里看到:
- 每帧耗时:22-35ms(经常超过 16.67ms 的预算)
- 掉帧率:约 15%
- CPU 占用:稳定在 40% 以上
改成 rAF 后:
- 每帧耗时:12-16ms(稳定在预算内)
- 掉帧率:< 1%
- CPU 占用:30% 左右(页面切后台时降到 0%)
差距很明显吧?说白了,rAF 就是让浏览器“自己决定什么时候干活”,而不是你强行命令它。你想想看,浏览器比你更了解自己的渲染管线,为什么不信任它呢?
一句话总结:做动画,忘掉 setInterval,拥抱 requestAnimationFrame。它不仅是性能优化,更是一种“跟浏览器合作”的思维方式。