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 就是一个排队机制。浏览器维护一个回调队列,每次准备重绘前,按顺序执行队列里的所有回调。
它的执行流程是这样的:
- 你调用
requestAnimationFrame(callback),把回调注册进去。 - 浏览器在下一帧重绘前,检查这个队列。
- 执行所有回调,传入一个高精度时间戳
DOMHighResTimeStamp。 - 浏览器执行重绘(布局 + 绘制 + 合成)。
- 重复步骤 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 在整个动画系统中的位置。
4.7 实际项目中的避坑指南
最后分享几个我实际踩过的坑,希望能帮你省点时间。
- 别忘了取消 rAF:组件卸载或页面跳转时,用
cancelAnimationFrame(id)取消。否则回调还在跑,可能操作已销毁的 DOM。 - 不要在 rAF 里做重操作:比如操作 DOM 布局属性(offsetHeight、getBoundingClientRect),会触发强制回流。我习惯把计算和 DOM 操作分开。
- 注意 this 指向:rAF 回调里的 this 是 window,不是你的对象。用箭头函数或者 bind 解决。
- 帧率下降时别慌:如果用户电脑卡,rAF 会自动降帧。你的动画逻辑如果是时间驱动的,时长不会变,只是画面跳得「粗」一点。
我的个人习惯:写一个通用的 Animation 类,封装 rAF 的启动、暂停、取消、缓动。这样每个页面直接 new 一个实例就行,不用重复写样板代码。