4、JavaScript动画基础:requestAnimationFrame原理、手动实现简单动画、性能对比

说到前端动画,很多人第一反应是 CSS transition 或 animation。确实,大部分 UI 动效用 CSS 就能搞定。但当你需要精确控制每一帧、做游戏级动画、或者跟滚动位置深度绑定时,就得请出 JavaScript 了。

而 JavaScript 动画的核心,就是 requestAnimationFrame。我刚开始做动画时也用过 setInterval,后来踩了不少坑才明白为什么浏览器要专门搞个 rAF。今天咱们就把这东西彻底聊透。

4.1 为什么需要 requestAnimationFrame?

先想想传统做法。用 setInterval(fn, 16.7) 模拟 60fps,看起来没问题对吧?

但实际跑起来你会发现:

  • 掉帧严重:setInterval 不管浏览器是否在忙,到点就塞任务。如果主线程卡了,回调会排队堆积,画面直接「瞬移」。
  • 资源浪费:页面切到后台标签页时,setInterval 还在跑。你想想看,用户都看不见了,你还在那算动画,白白耗电。
  • 时间不准:setInterval 的最小间隔在不同浏览器里不一样,而且受系统时钟影响。

我在项目中遇到过一个问题:用 setInterval 做轮播图,用户切出去再切回来,图片直接闪了好几张。原因就是回调堆积了。

requestAnimationFrame 就是来解决这些问题的。它告诉浏览器:「嘿,下次重绘之前叫我一声。」浏览器会在最合适的时机执行你的回调——通常是 16.6ms 一次(60fps)。

核心区别一句话:setInterval 是「我定时去问浏览器你画完了没」,rAF 是「浏览器画完主动通知我」。

4.2 requestAnimationFrame 的工作原理

说白了,rAF 就是一个排队机制。浏览器维护一个回调队列,每次准备重绘前,按顺序执行队列里的所有回调。

它的执行流程是这样的:

  1. 你调用 requestAnimationFrame(callback),把回调注册进去。
  2. 浏览器在下一帧重绘前,检查这个队列。
  3. 执行所有回调,传入一个高精度时间戳 DOMHighResTimeStamp。
  4. 浏览器执行重绘(布局 + 绘制 + 合成)。
  5. 重复步骤 2-4。

注意一点:rAF 只执行一次。要实现连续动画,你得在回调里再次调用 rAF。这跟 setInterval 的自动重复不一样。

小技巧:rAF 传入的时间戳是 performance.now() 的返回值,精度到微秒级。用它做时间差计算,比 Date.now() 准得多。

为什么会这样设计?因为浏览器知道什么时候该画。如果显示器是 60Hz,浏览器就尽量每 16.6ms 画一次。如果是 120Hz 的高刷屏,rAF 会自动适配到 8.3ms 一帧。你不需要改任何代码。

4.3 手动实现一个简单动画

光说不练假把式。咱们手写一个方块移动动画,从左边滑到右边。

// 获取元素
const box = document.getElementById('box');
const duration = 2000; // 动画持续 2 秒
const startX = 0;
const endX = 400;
let startTime = null;

function animate(timestamp) {
  // 第一次调用时记录开始时间
  if (!startTime) {
    startTime = timestamp;
  }

  // 计算已经过去了多久
  const elapsed = timestamp - startTime;
  // 计算进度(0 到 1 之间)
  const progress = Math.min(elapsed / duration, 1);

  // 根据进度计算当前位置
  const currentX = startX + (endX - startX) * progress;
  box.style.transform = `translateX(${currentX}px)`;

  // 如果还没结束,继续下一帧
  if (progress < 1) {
    requestAnimationFrame(animate);
  }
}

// 启动动画
requestAnimationFrame(animate);

这段代码看着简单,但有几个关键点:

  • 时间驱动,不是帧驱动:我们不关心「这是第几帧」,只关心「过去了多少毫秒」。这样不管帧率怎么变,动画时长都是固定的 2 秒。
  • 进度归一化:把 elapsed / duration 映射到 0~1,后面加缓动函数就方便了。
  • 边界处理:Math.min(..., 1) 防止进度超过 1,避免方块冲出终点。

我曾经踩过的坑:直接用帧计数(frameCount++)来控制动画。结果用户电脑卡了一下,帧率掉到 30fps,动画直接慢了一倍。后来全部改成时间驱动,再也没出过问题。

4.4 加上缓动函数

线性移动太生硬了。咱们加个缓动,让方块先快后慢。

// 缓出函数:先快后慢
function easeOut(t) {
  return 1 - Math.pow(1 - t, 3);
}

function animate(timestamp) {
  if (!startTime) startTime = timestamp;

  const elapsed = timestamp - startTime;
  const rawProgress = Math.min(elapsed / duration, 1);
  const easedProgress = easeOut(rawProgress);

  const currentX = startX + (endX - startX) * easedProgress;
  box.style.transform = `translateX(${currentX}px)`;

  if (rawProgress < 1) {
    requestAnimationFrame(animate);
  }
}

你看,改动就两行:加了个 easeOut 函数,把 rawProgress 映射成 easedProgress。这就是 rAF 的威力——你完全控制每一帧的数值,想怎么插值都行。

4.5 性能对比:rAF vs setInterval vs setTimeout

咱们直接上数据。我拿一个简单的方块移动动画,分别用三种方式实现,在 Chrome 开发者工具的 Performance 面板里录了数据。

指标 requestAnimationFrame setInterval(16.7ms) setTimeout(16.7ms)
帧率稳定性 稳定 60fps(自适应) 波动大,容易掉帧 波动大,容易掉帧
后台标签页行为 自动暂停 继续执行 继续执行
CPU 占用(后台) 接近 0% 持续占用 持续占用
时间精度 微秒级时间戳 毫秒级,有累积误差 毫秒级,有累积误差
调用次数/秒 约 60 次(与刷新率同步) 约 60 次(固定间隔) 约 60 次(固定间隔)
内存泄漏风险 低(需手动取消) 高(容易忘记 clear) 高(容易忘记 clear)

我个人习惯是:能用 rAF 绝不用定时器。只有一种情况我会用 setInterval——就是动画不需要跟屏幕刷新率同步,比如每隔 5 秒轮询一次数据。

一句话总结性能差异:rAF 是「浏览器主动喂给你时间」,定时器是「你去抢浏览器的时间」。前者优雅高效,后者粗暴浪费。

4.6 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到 rAF 在整个动画系统中的位置。

requestAnimationFrame 动画体系 requestAnimationFrame 回调函数 callback 高精度时间戳 浏览器渲染管道:JavaScript → 样式 → 布局 → 绘制 → 合成 时间驱动 elapsed = timestamp - startTime 进度归一化 progress = elapsed / duration 缓动函数 easeOut / easeIn / linear 更新 DOM / Canvas / WebGL 性能优势:自动暂停 · 帧同步 · 低CPU · 高精度 对比对象:setInterval / setTimeout

4.7 实际项目中的避坑指南

最后分享几个我实际踩过的坑,希望能帮你省点时间。

  • 别忘了取消 rAF:组件卸载或页面跳转时,用 cancelAnimationFrame(id) 取消。否则回调还在跑,可能操作已销毁的 DOM。
  • 不要在 rAF 里做重操作:比如操作 DOM 布局属性(offsetHeight、getBoundingClientRect),会触发强制回流。我习惯把计算和 DOM 操作分开。
  • 注意 this 指向:rAF 回调里的 this 是 window,不是你的对象。用箭头函数或者 bind 解决。
  • 帧率下降时别慌:如果用户电脑卡,rAF 会自动降帧。你的动画逻辑如果是时间驱动的,时长不会变,只是画面跳得「粗」一点。

我的个人习惯:写一个通用的 Animation 类,封装 rAF 的启动、暂停、取消、缓动。这样每个页面直接 new 一个实例就行,不用重复写样板代码。