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);
}

这个模板有几个好处:

  • 基于时间而非帧数,帧率波动不影响总时长
  • 内置缓动函数,动画更自然
  • 不会产生微任务,渲染路径干净

总结一下

做动画,记住三件事:

  1. requestAnimationFrame,别碰 setInterval
  2. 动画回调里别搞微任务,Promise 和 MutationObserver 放一边
  3. 基于时间戳做动画,别依赖帧数累加

嗯,这些原则看起来简单,但我在代码评审里至少见过十次「setInterval + 帧数累加」的组合。每次我都会说:换成 requestAnimationFrame 吧,浏览器比你更懂什么时候该画。