4、JavaScript动画基础:使用requestAnimationFrame驱动动画,对比setInterval的优劣,实现帧率控制
说到前端动画,很多同学第一反应就是CSS3的transition和animation。确实,CSS动画能搞定大部分场景。但真要做复杂交互、游戏、或者需要精确控制每一帧的时候,还得靠JavaScript。
我个人习惯把JS动画分成两个时代:setInterval时代和requestAnimationFrame时代。今天咱们就来聊聊这两个API,看看它们到底差在哪,以及怎么用rAF写出丝滑的动画。
4.1 为什么setInterval不适合做动画?
先别急着否定setInterval,它本身是个好工具。只是用它做动画,有几个硬伤。
核心问题:setInterval不关心浏览器是否准备好绘制新帧。
你想想看,setInterval只管「每隔多少毫秒执行一次回调」。但浏览器什么时候渲染页面,它不管。这就导致几个问题:
- 丢帧:如果回调执行时间过长,下一帧可能被跳过
- 不同步:回调频率和屏幕刷新率不一致,造成卡顿
- 资源浪费:页面隐藏时还在跑,白白消耗性能
我曾经在一个H5小游戏里用过setInterval做主循环。结果呢?在60Hz的屏幕上还行,换到144Hz的显示器上,动画直接飞起来了。嗯,这就是不关心刷新率的后果。
4.2 requestAnimationFrame:为动画而生
rAF的出现,说白了就是解决上面那些问题的。它告诉浏览器:「嘿,你下次要刷新屏幕的时候叫我一声,我准备好新画面了。」
浏览器会怎么做?它会等自己准备好渲染的时候,才调用你的回调。这个时机,恰好就在下一次屏幕刷新之前。
关键点:rAF的回调频率 = 屏幕刷新率。60Hz的屏幕每秒调用60次,120Hz就是120次。完全同步。
来看个最简单的例子:
// 用rAF驱动一个方块移动
const box = document.getElementById('box');
let position = 0;
function animate() {
position += 2;
box.style.transform = `translateX(${position}px)`;
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
这段代码里,animate函数会在每一帧被调用。position每次增加2个像素,直到方块移动到500px的位置停下。
注意看,我们没有设置「每隔多少毫秒执行一次」。浏览器自己决定什么时候调用,我们只管提供下一帧的画面数据。
4.3 setInterval vs rAF:正面硬刚
咱们用表格来对比一下,更直观:
| 对比项 | setInterval | requestAnimationFrame |
|---|---|---|
| 执行时机 | 固定时间间隔 | 下一次屏幕刷新前 |
| 与刷新率同步 | 否 | 是 |
| 页面隐藏时 | 继续执行 | 自动暂停 |
| 帧率控制 | 需要手动计算 | 基于时间戳控制 |
| 性能 | 可能浪费CPU | 更高效 |
| 兼容性 | 所有环境 | IE10+,现代浏览器全支持 |
看到没?rAF在动画场景下几乎是全面碾压。尤其是「页面隐藏时自动暂停」这个特性,太实用了。你想想看,用户切到其他标签页了,动画还在跑,这不是白费电吗?
4.4 帧率控制:让动画跑得稳
rAF虽然好,但它有个特点:帧率完全跟着屏幕走。60Hz的屏幕就是60fps,120Hz就是120fps。
这就有问题了。同一个动画,在不同设备上速度不一样。怎么办?
答案是:基于时间差来计算位移,而不是基于帧数。
重要:永远不要假设每帧的时间间隔是固定的。不同设备的刷新率不同,甚至同一设备也可能波动。
来看改进后的代码:
const box = document.getElementById('box');
let position = 0;
let lastTime = 0;
const speed = 200; // 每秒移动200像素
function animate(currentTime) {
// 计算时间差(毫秒)
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// 基于时间计算位移
position += speed * (deltaTime / 1000);
box.style.transform = `translateX(${position}px)`;
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
这里的关键是 deltaTime。不管屏幕是60Hz还是120Hz,我们只关心「距离上一帧过去了多少毫秒」。然后用这个时间差乘以速度,得到这一帧应该移动的距离。
这样做的好处很明显:
- 60Hz屏幕:每帧约16.67ms,每帧移动约3.33px
- 120Hz屏幕:每帧约8.33ms,每帧移动约1.67px
- 最终效果:1秒后都移动了200px
完美!速度一致了。
4.5 限制帧率:有时候不需要跑满60fps
有些场景下,我们不需要那么高的帧率。比如一个简单的进度条动画,30fps就够用了。跑60fps反而浪费CPU。
怎么限制?也很简单:
let lastFrameTime = 0;
const targetFPS = 30;
const frameInterval = 1000 / targetFPS; // 约33.33ms
function animate(currentTime) {
requestAnimationFrame(animate);
const elapsed = currentTime - lastFrameTime;
if (elapsed < frameInterval) return; // 时间没到,跳过
lastFrameTime = currentTime - (elapsed % frameInterval);
// 真正的动画逻辑写在这里
updateAnimation();
}
requestAnimationFrame(animate);
这段代码的核心思路是:用时间差做节流。不到33ms就不执行真正的动画逻辑。但注意,我们仍然要递归调用rAF,这样才能继续检查下一帧。
小技巧:我一般把帧率控制封装成一个工具函数。传入目标fps和回调,内部自动处理时间计算。这样业务代码里就不用关心帧率了。
4.6 实际项目中的避坑指南
最后分享几个我在项目中踩过的坑:
- 忘记取消rAF:组件卸载时一定要调用
cancelAnimationFrame(id),否则回调会一直跑,造成内存泄漏。我曾经因为这个让一个后台页面CPU飙到100%…… - 不要在rAF里做重计算:每一帧的时间很宝贵,尽量把计算量大的操作放到外面。比如提前算好路径数据,rAF里只做赋值。
- 注意this指向:rAF的回调里,this默认指向window。如果你用class写动画,记得bind或者用箭头函数。
- 不要依赖帧数:永远用时间戳来计算进度,而不是数「第几帧」。这一点前面已经强调过了。
嗯,差不多就这些。rAF其实不复杂,但用好它需要一点对浏览器渲染机制的理解。记住一句话:rAF不是让你「跑得更快」,而是让你「跑得更准」。
下一章咱们会聊缓动函数和动画曲线,到时候这些知识都会用上。先把这个基础打牢吧。