4、JavaScript动画:requestAnimationFrame原理、setTimeout与setInterval对比、帧率控制

说到前端动画,很多同学第一反应就是 setTimeout 或者 setInterval。说实话,我刚入行那会儿也是这么干的。直到有一次做大屏数据可视化项目,动画一多就开始卡顿、掉帧,甚至出现诡异的闪烁。排查了半天,才发现问题出在定时器上。

后来我彻底换成了 requestAnimationFrame,效果立竿见影。今天我们就来好好聊聊这三兄弟的区别,以及怎么把帧率稳稳控制在 60fps。

一、setTimeout 与 setInterval:看似简单,坑却不少

先说说最常用的 setTimeoutsetInterval。它们本质上都是「定时器」,不是「动画专用 API」。你告诉浏览器:「过 16 毫秒帮我执行一次这个函数」,但浏览器不一定准时执行。

注意:JavaScript 是单线程的。定时器回调会被放入任务队列,必须等当前执行栈清空后才轮到它。如果主线程被其他任务占着,你的动画就得排队等着。

举个例子:

// 用 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 会传入一个时间戳参数,表示当前帧的触发时间。这个时间戳非常精确,适合做基于时间的动画。

小技巧:用时间戳做动画,可以避免「帧率波动导致速度不一致」的问题。比如你想让物体每秒移动 100px,就用 速度 * (当前时间 - 上一帧时间) / 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,那就跳过不更新。

核心要点:帧率控制不是「减少 rAF 的调用次数」,而是「控制动画逻辑的执行频率」。rAF 本身仍然每秒调用 60 次,但只有满足时间条件的回调才会真正更新画面。

四、三者的对比总结

特性 setTimeout setInterval requestAnimationFrame
执行时机 延迟后加入任务队列 固定间隔加入任务队列 浏览器重绘前执行
与屏幕刷新同步
页面不可见时 继续执行 继续执行 自动暂停
回调堆积风险
帧率控制 手动实现 手动实现 手动实现
适用场景 延迟执行、轮询 定时任务、轮询 动画、视觉更新

说白了,requestAnimationFrame 就是浏览器给动画开的「绿色通道」。它知道什么时候该画、什么时候不该画。你想想看,如果浏览器正在忙着加载资源,你硬要用 setInterval 去更新动画,那不是添乱吗?

五、避坑指南

最后分享几个我踩过的坑:

  • 别在 rAF 里做耗时操作:rAF 回调里如果执行了超过 16ms 的任务,就会导致掉帧。复杂的计算请放到 Web Worker 里。
  • 记得取消 rAF:组件卸载或页面跳转时,调用 cancelAnimationFrame(id) 释放资源。我曾经因为忘了取消,导致页面切换后动画还在后台跑,白白消耗性能。
  • 不要依赖 rAF 的调用次数:不同设备的刷新率不同,120Hz 屏幕上一秒调用 120 次,60Hz 屏幕上一秒 60 次。用时间戳做动画才是正道。
  • 帧率控制要留余量:目标 30fps,实际控制时可以用 28fps 的间隔。因为时间戳计算有微小误差,留点余量能避免「差一帧」导致的卡顿感。

我曾经在一个大屏项目里,把所有动画都换成了 rAF + 帧率控制,结果页面流畅度从 20fps 直接提升到了 55fps。嗯,从那以后我再也没用定时器写过动画。

记住一句话:动画的事,交给浏览器去管。你只管算好每一帧该画什么。