4、JavaScript动画:requestAnimationFrame原理、setTimeout与setInterval对比、帧率控制
说到前端动画,很多同学第一反应就是 setTimeout 或者 setInterval。说实话,我刚入行那会儿也是这么干的。直到有一次做大屏数据可视化项目,动画一多就开始卡顿、掉帧,甚至出现诡异的闪烁。排查了半天,才发现问题出在定时器上。
后来我彻底换成了 requestAnimationFrame,效果立竿见影。今天我们就来好好聊聊这三兄弟的区别,以及怎么把帧率稳稳控制在 60fps。
一、setTimeout 与 setInterval:看似简单,坑却不少
先说说最常用的 setTimeout 和 setInterval。它们本质上都是「定时器」,不是「动画专用 API」。你告诉浏览器:「过 16 毫秒帮我执行一次这个函数」,但浏览器不一定准时执行。
举个例子:
// 用 setInterval 做动画
let left = 0
setInterval(() => {
left += 1
element.style.transform = `translateX(${left}px)`
}, 16)
看起来每 16ms 执行一次,约等于 60fps。但实际运行中,如果页面有大量 DOM 操作、网络请求或者复杂的计算,这个间隔会被拉长。更糟糕的是,setInterval 不会管上一帧是否执行完毕,它会「固执地」把回调塞进队列。这就可能导致回调堆积,动画瞬间「跳帧」。
我个人习惯是:能用 requestAnimationFrame 的地方,绝不用定时器做动画。定时器更适合做倒计时、轮询这类非视觉任务。
二、requestAnimationFrame:浏览器原生动画引擎
requestAnimationFrame 是浏览器专门为动画设计的 API。它的核心逻辑很简单:告诉浏览器「我要做动画了,你下次重绘之前记得叫我」。
它有几个天然优势:
- 与浏览器刷新同步:大多数屏幕是 60Hz,也就是每秒刷新 60 次。rAF 会跟着这个节奏走,不会多也不会少。
- 页面不可见时自动暂停:如果用户切到其他标签页,rAF 会停止调用,节省 CPU 和电池。定时器可没这么智能。
- 回调队列统一管理:所有 rAF 回调会在同一帧内执行,不会出现堆积。
基本用法:
function animate() {
// 更新动画状态
element.style.transform = `translateX(${left}px)`
left += 2
// 请求下一帧
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
嗯,这里要注意:requestAnimationFrame 会传入一个时间戳参数,表示当前帧的触发时间。这个时间戳非常精确,适合做基于时间的动画。
速度 * (当前时间 - 上一帧时间) / 1000 来计算位移。
三、帧率控制:别让动画跑飞了
你可能会问:rAF 不是跟着屏幕刷新走吗?为什么还要控制帧率?
原因很简单:不是所有动画都需要 60fps。比如一个缓慢的呼吸灯效果,30fps 就足够了。强行跑 60fps 只会浪费性能。另外,有些低端设备屏幕刷新率只有 30Hz,你按 60fps 去设计,反而会出问题。
我在项目中遇到过这样的情况:大屏上同时跑了十几个图表动画,每个都用 rAF 以 60fps 运行,结果 CPU 直接飙到 90%。后来我把非关键动画降到了 30fps,流畅度没变,CPU 降了一半。
帧率控制的思路其实很简单:记录时间,跳过不需要的帧。
let lastTime = 0
const targetFPS = 30
const interval = 1000 / targetFPS
function animate(timestamp) {
const delta = timestamp - lastTime
if (delta >= interval) {
// 更新动画
updateAnimation()
lastTime = timestamp - (delta % interval) // 对齐时间
}
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
这段代码的核心是:delta >= interval 这个判断。只有时间差达到了目标帧率的间隔,才执行更新。比如目标 30fps,间隔就是 33.3ms。如果两次 rAF 回调间隔只有 16ms,那就跳过不更新。
四、三者的对比总结
| 特性 | setTimeout | setInterval | requestAnimationFrame |
|---|---|---|---|
| 执行时机 | 延迟后加入任务队列 | 固定间隔加入任务队列 | 浏览器重绘前执行 |
| 与屏幕刷新同步 | 否 | 否 | 是 |
| 页面不可见时 | 继续执行 | 继续执行 | 自动暂停 |
| 回调堆积风险 | 有 | 有 | 无 |
| 帧率控制 | 手动实现 | 手动实现 | 手动实现 |
| 适用场景 | 延迟执行、轮询 | 定时任务、轮询 | 动画、视觉更新 |
说白了,requestAnimationFrame 就是浏览器给动画开的「绿色通道」。它知道什么时候该画、什么时候不该画。你想想看,如果浏览器正在忙着加载资源,你硬要用 setInterval 去更新动画,那不是添乱吗?
五、避坑指南
最后分享几个我踩过的坑:
- 别在 rAF 里做耗时操作:rAF 回调里如果执行了超过 16ms 的任务,就会导致掉帧。复杂的计算请放到 Web Worker 里。
- 记得取消 rAF:组件卸载或页面跳转时,调用
cancelAnimationFrame(id)释放资源。我曾经因为忘了取消,导致页面切换后动画还在后台跑,白白消耗性能。 - 不要依赖 rAF 的调用次数:不同设备的刷新率不同,120Hz 屏幕上一秒调用 120 次,60Hz 屏幕上一秒 60 次。用时间戳做动画才是正道。
- 帧率控制要留余量:目标 30fps,实际控制时可以用 28fps 的间隔。因为时间戳计算有微小误差,留点余量能避免「差一帧」导致的卡顿感。
我曾经在一个大屏项目里,把所有动画都换成了 rAF + 帧率控制,结果页面流畅度从 20fps 直接提升到了 55fps。嗯,从那以后我再也没用定时器写过动画。
记住一句话:动画的事,交给浏览器去管。你只管算好每一帧该画什么。