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不是让你「跑得更快」,而是让你「跑得更准」

下一章咱们会聊缓动函数和动画曲线,到时候这些知识都会用上。先把这个基础打牢吧。