写前端动画做到一定阶段,几乎都会撞上 requestAnimationFrame。网上一搜,教程一大堆,但绝大多数只是让你“背下来这样写”,至于它为什么存在、浏览器到底在哪个环节调用它、为什么有时候用 setInterval 会卡、为什么切后台动画就停了,很少有人一次讲清楚。这篇文章不搞虚的,直接从浏览器渲染机制开始,把 requestAnimationFrame 的来龙去脉、运行规律、封装思路和排错经验一次说透。不管你是刚入门前端的新手,还是写过不少动效但心里没底的同学,这篇都能让你看完之后,在真正写代码时更有底气。
1. 先把“卡顿”的根源挖出来:为什么 setTimeout 做动画不靠谱
1.1 一次浏览器渲染周期里,到底发生了什么
要理解 requestAnimationFrame,得先理解浏览器是“一帧一帧”画页面的。每次你更新了元素的样式,浏览器并不会立刻把结果画到屏幕上,而是等下一个合适的时机,统一走一遍完整的通道。这个通道大概包含这几个阶段:执行 JavaScript、计算样式(Style)、生成布局(Layout)、绘制图形(Paint),最后把所有图层合成在一起显示出来(Composite)。这一整套在 60Hz 刷新率的屏幕上,目标是在 16.7ms 内完成一次;在 120Hz 的屏幕上,这个时间预算会被压缩到大约 8.3ms。
这里有个关键点:这些阶段是串行的,而且每一帧的起点和终点之间有一条严格的时间线。如果在某个阶段卡住了,比如 JavaScript 同步计算跑了 80ms,那么后面的样式计算、布局、绘制全部都要往后排队,用户看到的就是掉帧、卡顿。所以判断一段动画代码好不好,核心指标不是“写得快不快”,而是它有没有挤占和破坏这一帧的完整节奏。
1.2 setInterval 做动画的三个硬伤
在早期没有 requestAnimationFrame 的时候,大家喜欢用 setInterval(fn, 1000 / 60) 来做动画,也就是自己模拟 60fps 的调用节奏。但现实很快打了脸,因为这种模拟至少有三个解决不了的问题。
第一个问题是定时器触发时机和屏幕刷新时机经常错位。就算你设定 16.7ms 执行一次,浏览器实际的定时器精度受任务队列影响很大,回调可能落在 14ms、20ms 甚至更离散的位置。假设回调执行时刚好错过了本轮渲染的“样式计算和合成窗口”,那么效果就会顺延到下一帧,表现出来就是动画抖动、不流畅。
第二个问题是后台标签页会被浏览器强制节流。浏览器为了省电,会降低后台页面里定时器的执行频率,setInterval 的回调可能从 16ms 一次被拉到 1000ms 一次。对动画来说这倒不算太大的灾难,因为用户没在看;但对一些依赖定时器做轮询或倒计时的业务来说,体验就是灾难。
第三个问题是定时器没法感知“当前这一帧到底要不要渲染”。就算当前页面元素完全没变化、或者用户已经把滚动条拖到底了,定时器依然会傻乎乎地在后台执行。而浏览器真正关心的是“画面需要刷新时,你要更新什么状态”,requestAnimationFrame 就是这种思路的产物——浏览器自己掌握渲染节奏,每次准备重绘前,主动叫你一声。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. requestAnimationFrame 的运行机制,越早明白越少踩坑
2.1 基础 API:注册、回调参数、取消循环
先看最基本的用法。requestAnimationFrame 接收一个回调函数,浏览器会在下一次屏幕重绘之前调用这个回调,并且只调用一次。如果你需要持续动画,就得在回调里面再次调一次 requestAnimationFrame:
js复制let rafId = 0;
function loop(now) {
// 这里更新元素状态
update(now);
// 继续注册下一帧
rafId = requestAnimationFrame(loop);
}
// 启动
rafId = requestAnimationFrame(loop);
请注意这里的 now 参数。回调函数能接收到一个 DOMHighResTimeStamp 类型的时间戳,单位是毫秒,精度比 Date.now() 高,表示当前回调触发时离页面加载完成过去了多少时间。在实际动画里,尽量使用这个回调参数来计算时间差,而不是在注册前用 Date.now() 外推算一次,因为后者的误差会包含注册代码本身的执行时间,累积起来就可能看到抖动。
既然有注册,就一定有取消。requestAnimationFrame 返回一个整数 ID,你可以用这个 ID 调用 cancelAnimationFrame 来取消尚未执行的帧:
js复制// 取消一帧
cancelAnimationFrame(rafId);
我早期在这里栽过跟头:一个组件里同时起了两个动画循环,分别用自己的 rafId 维护,某个时机需要销毁组件,只取消了其中一个,另一个回调还在继续跑,状态更新还在继续,导致内存泄露和奇怪的视觉残留。正确做法是:每个循环的 ID 都分开保存,取消时分别对应取消,不能图省事。
2.2 背后调度策略:自动合并、自动暂停、刷新率自适应
很多同学以为 requestAnimationFrame 就是“setTimeout 的优化版”,只是把 16.7ms 固定延迟换成了浏览器自定义延迟,这样理解还是太浅了。它的核心优势在于“与渲染管线对齐”。
浏览器在下一次刷新之前,会把这一帧里所有通过 requestAnimationFrame 注册的回调批量执行一遍,而不是一个回调触发一次独立的计时。这意味着,如果你在同一帧里多次注册不同的回调,它们会被尽量安排在同一次渲染周期内。同步过后,浏览器统一计算样式、布局和绘制。这样安排出来的动画,才不会出现某个状态更新落后半拍导致画面撕裂的现象。
另一个很重要的特性是“页面不可见时自动暂停”。当浏览器标签页切到后台或最小化时,没有屏幕会真的去重绘画面,requestAnimationFrame 回调就不会再被调用。这是它的一个优点,能有效降低后台功耗,但它对业务逻辑来说也可能变成一个坑。比如你在做一个需要精确计时、后台也不能停的逻辑,那就不能用 requestAnimationFrame 来兜底,最稳妥的方案是用 performance.now() 记录每次回调之间的实际时间差,或者单独依赖真正可用的计时器。
刷新率自适应的逻辑也特别值得说。60Hz 屏幕下,回调大概 16.7ms 一次;120Hz 屏下可能是 8.3ms 一次。这里隐藏了一个非常常见的动画 bug:如果你在每一帧给元素加上固定的 10px 位移,那么在 120Hz 屏上动画速度就会比 60Hz 屏上快一倍。这个问题的正确答案是使用“基于时间的动画”,这一点会在下一节重点演示。
3. 从零写一个能落地的补间动画:关键不是“下一帧”,而是“基于时间”
3.1 三步走:起始值、目标值、进度计算
理解了机制,接着写一个真正能用的补间动画。很多人写动画时会习惯性地“每帧加一点”,但更专业、更可控的做法是“每帧算进度”。你要定义三样东西:起始值、目标值、动画时长。然后每一帧根据当前时间算出一个 0 到 1 之间的进度值,再把这个进度映射到当前值上。
下面是一个通用补间函数,可以用它完成从数值 A 到数值 B 的动画:
js复制function animate({ from, to, duration, easing = (t) => t, onUpdate, onDone }) {
// 记录动画开始时的时间点
const startTime = performance.now();
function frame(now) {
// 当前进度 = 已经经过的时间 / 总时长
let progress = (now - startTime) / duration;
// 防止超出 1
progress = Math.min(progress, 1);
// 如果有缓动函数,就先把 progress 做一次映射
const easedProgress = easing(progress);
// 当前值 = 起始值 + 进度差 × 总差值
const currentValue = from + (to - from) * easedProgress;
onUpdate(currentValue, progress);
// 没结束就继续下一帧
if (progress < 1) {
requestAnimationFrame(frame);
} else if (onDone) {
onDone();
}
}
requestAnimationFrame(frame);
}
这里最关键的是 let progress = (now - startTime) / duration; 这句。它把时间作为唯一维度,不关心中途掉不掉帧、刷新率是 60 还是 120,动画总时长始终保持在 duration 毫秒。如果某一帧确实被卡了很久,下一帧过来时,progress 会直接跳到正确的位置,不会出现“卡完继续按每帧固定增量走”的错乱。
3.2 实际调用示例:给元素加缓动
把上面的函数实际拿过来用。这里我让一个方块用 1 秒时间从左边平移到 300px 的位置,并套了一个简单的缓入缓出函数:
js复制const box = document.getElementById('box');
animate({
from: 0,
to: 300,
duration: 1000,
easing: (t) => (t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2
),
onUpdate(value) {
// 优先使用 transform,避免频繁触发 Layout
box.style.transform = `translateX(${value}px)`;
},
onDone() {
console.log('动画结束');
},
});
为什么这里推荐用 transform 而不是 left?因为修改 left 会触发布局阶段重新计算位置,而修改 transform 通常只影响绘制和合成阶段。做动画时,减少布局计算是提高流畅度最有效的方法之一。
还有两个细节得提一下。
第一,Math.min(progress, 1) 很重要。浏览器隐藏标签页后,requestAnimationFrame 会暂停,等用户切回来时 now 时间已经变大了,progress 可能一瞬间就超过 1。如果不做钳制,动画会跳到一个荒谬的中间值,接着继续跑。你需要在业务上决定“切后台回来后是直接跳到终点,还是从头再播”,但不管哪种,都要先把 progress 限制在合法范围内。
第二,缓动函数接收的进度参数应该是“线性进度”,而不是“缓动后的进度”。判断动画是否结束,要用未缓动的原始 progress。如果你用缓动后的值去判断,因为缓动函数在小数值阶段会变化极慢,可能导致动画在视觉上几乎没有移动时就已经被判定为结束了。
4. 进阶用法:把 RAF 封装成各种顺手的工具
4.1 用 Promise 包一个 nextFrame()
不少业务场景并不需要从零实现一个补间引擎,只是希望在“下一帧”执行某个操作。这时可以封装一个极简的 nextFrame() 工具:
js复制function nextFrame() {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
}
// 用法:在 async 函数里等待一帧
async function run() {
await nextFrame();
console.log('我在下一帧渲染前执行了');
}
这样一个函数在写测试、做框架集成、或者要在 DOM 更新后等待渲染完成再执行测量时特别有用。严格来说,nextFrame() 只代表“下一个渲染帧开始之前”的时机,并不代表DOM 已经完成了绘制,但多数场景里,它已经足够让浏览器走到样式计算和布局阶段,所以你大概率能拿到最新的几何信息。
如果你真的需要等渲染完成后读取某些实时数据,可以配合 setTimeout 再往后推一次,或者直接监听 ResizeObserver 之类的专用 API。nextFrame() 的精髓在于它是“排队”而不是“定时”,多个地方同时调用它时,能被浏览器很好地合并到同一帧。
4.2 把 RAF 当成滚动高频事件的节流器
另一个非常实用的封装是把 requestAnimationFrame 当作“节流器”使用。页面 scroll 或 resize 事件的触发频率远高于屏幕刷新率,如果每次触发都同步计算复杂的布局数据,滚动时很容易掉帧。常见解法是:事件触发时先记录“需要处理”的状态,但真正的计算推迟到下一帧渲染前统一执行一次:
js复制function throttleByRAF(callback) {
let ticking = false;
return function (...args) {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
callback.apply(this, args);
ticking = false;
});
};
}
const onScroll = throttleByRAF(() => {
// 这里放真正需要做的、耗时的滚动处理
});
window.addEventListener('scroll', onScroll);
这样做能保证同一帧内无论滚动事件触发多少次,回调都只会执行一次,把大量重复计算压缩到一帧一次。注意,demo 代码里的 this 和 args 传递其实带了点小技巧,不过大多数使用场景里,直接写 callback() 也行,关键在于“一次滚动帧里只做一次处理”这个思路。
这里额外提醒一下:requestAnimationFrame 不适合做真正的“高频事件完全不处理”的业务判断。如果你的代码需要根据滚动位置做“是否加载更多”这种语义化判断,还是应该读取事件本身的状态,而不是单纯依赖 RAF,因为如果浏览器空闲,RAF 回调也可能要等待下一次渲染,逻辑上会有额外延迟。
5. 用 RAF 做性能观测:顺手写个 FPS 监控小工具
5.1 实现思路:相邻两帧回调的时间差就是当前帧耗时
可能很多人没意识到,requestAnimationFrame 还有一个隐藏价值:它能相当准确地测量页面实时帧率。因为浏览器会在每一帧渲染前调用它,所以相邻两次回调之间的时间差,约等于当前一帧的总耗时。我们要做的就是把每次时间差记下来,计算每秒能触发多少帧。
一个简单版本是这样:
js复制let frameCount = 0;
let lastTime = performance.now();
let fps = 0;
function measure(now) {
frameCount += 1;
const elapsed = now - lastTime;
// 每 500ms 统计一次 FPS,页面上的数字更新不用太频繁
if (elapsed >= 500) {
fps = Math.round((frameCount * 1000) / elapsed);
frameCount = 0;
lastTime = now;
console.log('当前 FPS:', fps);
}
requestAnimationFrame(measure);
}
requestAnimationFrame(measure);
再细致一点,你可以不只看 FPS,还看“最大帧间隔”。如果某一帧耗时超过了 50ms,说明用户至少感知到一次明显卡顿。这些数据能直观帮你判断“我写的动画在低端设备上到底卡不卡”。
5.2 如何阅读帧时间:掉帧、长任务与主线程开销
从统计逻辑里能明显看出来,一段 requestAnimationFrame 回调如果写得特别重,它自己就会拖慢下一帧的触发节奏。所以拿 RAF 自测性能有一个悖论:监控代码本身必须足够轻,否则测出来的是“监控导致的卡顿”,而不是页面自然状态下的表现。
实践中我建议把 FPS 监控做在页面里一个独立的小组件中,不影响核心逻辑;测试时还要留意有没有其它 setInterval、setTimeout、网络请求响应解析之类的长任务在同时抢占主线程。真正要排查掉帧原因时,还是得打开 Performance 面板,看主线程的火焰图。RAF 回调附近的紫色部分或超过一帧预算的短任务,往往是掉帧的元凶。FPS 数字只能提醒你“卡了”,Performance 才能告诉你“为什么卡”。
6. 高频踩坑记录:排错思路与性能建议
6.1 常见问题速查表
这些年我在实际开发里见过不少同学踩在同样的坑里,这里整理成一张速查表,方便大家直接对照:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 动画只执行了一次 | requestAnimationFrame 只注册一帧,没有在回调中继续注册 |
递归调用下一帧,或使用封装好的循环工具 |
cancelAnimationFrame 不起作用 |
保存的 ID 不是最新的注册 ID,可能取消了旧帧 | 每次都把 requestAnimationFrame 的返回值重新赋给同一个变量 |
| 切后台再回来,动画直接跳到了终点 | RAF 在后台暂停,恢复后时间差过大,progress 瞬间超 1 |
对进度做 Math.min(progress, 1),再按业务决定表现 |
| 高刷新率屏幕上动画明显变快 | 每帧都加固定像素值 | 改为基于时间的进度计算 |
用 RAF 模拟 setTimeout 造成任务不执行 |
RAF 页面隐藏时会暂停 | 若任务不能中断,换用定时器或 performance.now() 持续记录 |
| 多个 RAF 循环互相干扰 | 回调写得太重且在循环里反复读元素布局 | 合并循环或拆帧处理,避免布局抖动 |
| 页面后台运行一段时间后 CPU 占用异常 | 有些回调还在用定时器不断地触发操作 | 优先使用 RAF 调度渲染型任务,定时器留给非渲染任务 |
你以为自己记住了 API,但实际环境一变化就出问题,这种状况大多是因为没把 RAF 的调度规则和内化到代码取舍中。这张表基本能覆盖日常开发 80% 以上的问题。
6.2 我总结的五条性能红线
最后分享几条我写动画和交互时给自己定的规则,可以理解成性能红线。
第一条,requestAnimationFrame 回调里不要做重活。只要是跟随帧执行的操作,都应该保证能在当前帧预算内完成。一旦处理不过来,就直接掉帧。
第二条,不要在同一个回调里交叉读写 DOM 布局属性。读 offsetWidth、getBoundingClientRect() 这类布局属性会强制刷新样式,如果你接着修改样式,再读一次又触发一次,重复来回就会产生 layout thrashing。最好先统一读取一批数据,再统一写元素样式。
第三条,能用 transform 和 opacity 的属性动画,就不要用 left、top、width、height。前者更容易走合成器层,绕开耗时的布局和绘制阶段。
第四条,不要同时开着大量独立 RAF 循环。十几个组件各起一个循环,每一帧都在跑,启动和停止的管理就会变得混乱。可以做一个简单的调度器,把多个动画放进一个循环里统一驱动。
第五条,别把渲染逻辑当成普通异步任务来用。RAF 解决的是“屏幕刷新前同步状态”的问题,不是“延迟执行逻辑”的通用工具。轮询接口、后台心跳、数据上报这类不依赖渲染的业务,不该用 RAF 去承载。
我在实际项目中体会最深的其实是“可取消”和“可控制”这两件事。真正理解 requestAnimationFrame 之后,你会发现做动画不再是“开个计时器拼命跑”,而是把自己放到浏览器的帧循环里,每一帧都清楚知道当前处于什么进度、这回应该取消哪一帧、切后台后要怎么恢复。做到这一步,你写的动效、滚动联动、Canvas 游戏循环、FPS 监控工具都会变得干净利落,出问题时定位起来也快得多。
