3、JavaScript动画优化:requestAnimationFrame详解,避免setInterval/setTimeout,微任务与宏任务对动画的影响
说到JavaScript动画,我见过太多人一上来就写 setInterval 或者 setTimeout。嗯,这其实是个坑。我自己早年也踩过,那时候做一个跑马灯效果,用 setInterval 每16ms更新一次位置,结果在低端手机上卡成PPT。后来才明白,问题出在「时机」上。
为什么setInterval/setTimeout不适合做动画?
说白了,这两个API根本不关心浏览器的渲染节奏。你想想看,浏览器每16.6ms(60fps)才刷新一次屏幕,但 setInterval 可能在你设定的时间点强行插入任务。这就导致两个问题:
- 丢帧:如果回调执行时浏览器正在渲染,任务会被推迟,造成卡顿
- 过度绘制:回调执行频率高于屏幕刷新率,做了无用功
我曾经在一个项目中用
setInterval(fn, 10) 做进度条动画,结果CPU占用飙到80%。后来换成 requestAnimationFrame,占用直接降到15%。这个教训让我记住了:动画的时钟,应该交给浏览器来管。
requestAnimationFrame:浏览器原生的动画时钟
requestAnimationFrame 会告诉浏览器:「嘿,下次你准备刷新屏幕的时候,记得叫我一声。」浏览器会在每次重绘之前,统一执行所有注册的回调。这样做的好处很明显:
- 自动匹配屏幕刷新率(60Hz/120Hz/144Hz)
- 页面不可见时自动暂停,节省资源
- 回调执行时机精准,不会出现撕裂或丢帧
来看一个对比示例:
// ❌ 错误做法:setInterval
let left = 0;
setInterval(() => {
left += 2;
element.style.transform = `translateX(${left}px)`;
if (left > 500) clearInterval(this);
}, 16);
// ✅ 正确做法:requestAnimationFrame
let left = 0;
function animate() {
left += 2;
element.style.transform = `translateX(${left}px)`;
if (left <= 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
我个人习惯在动画函数里记录时间戳,用来做基于时间的动画(而不是基于帧数)。这样即使帧率波动,动画速度也是稳定的。
微任务与宏任务:动画的隐形杀手
这里有个很多人忽略的点:微任务会阻塞渲染。
浏览器的事件循环是这样的:执行一个宏任务 → 清空微任务队列 → 执行UI渲染。如果微任务队列里有大量任务,渲染就会被推迟。你想想看,如果 requestAnimationFrame 回调里又触发了微任务(比如 Promise.then),这些微任务会在当前帧渲染之前执行,导致帧时间被拉长。
| 任务类型 | 对动画的影响 | 典型场景 |
|---|---|---|
| 宏任务(setTimeout) | 延迟不可控,可能跨帧执行 | 定时器、事件回调 |
| 微任务(Promise) | 阻塞当前帧的渲染 | 异步数据更新、状态变更 |
| requestAnimationFrame | 在渲染前执行,时机精准 | 动画循环、视觉更新 |
避坑指南:在 requestAnimationFrame 回调里,尽量避免触发微任务。如果必须做异步操作,考虑用 setTimeout(fn, 0) 把它推到下一帧。
实战:一个平滑的动画循环模板
我在项目中总结了一个通用的动画循环模式,分享给你:
function startAnimation(element) {
let startTime = null;
const duration = 2000; // 2秒
const from = 0;
const to = 500;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const current = from + (to - from) * easeOutCubic(progress);
element.style.transform = `translateX(${current}px)`;
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// 缓动函数
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
这个模板有几个好处:
- 基于时间而非帧数,帧率波动不影响总时长
- 内置缓动函数,动画更自然
- 不会产生微任务,渲染路径干净
总结一下
做动画,记住三件事:
- 用
requestAnimationFrame,别碰setInterval - 动画回调里别搞微任务,Promise 和 MutationObserver 放一边
- 基于时间戳做动画,别依赖帧数累加
嗯,这些原则看起来简单,但我在代码评审里至少见过十次「setInterval + 帧数累加」的组合。每次我都会说:换成 requestAnimationFrame 吧,浏览器比你更懂什么时候该画。