
做前端动画这些年requestAnimationFrame下面我简称 rAF大概是那种“人人都听过、但真正吃透的人不多”的 API。它看着简单一行requestAnimationFrame(fn)就能跑可真要拿它来做 js 性能优化里面的门道一点都不少。这篇文章我想聊的不是教科书式的定义而是把这几年在项目里踩过的坑、对比过的数据、封装的工具函数都摊开来讲。不管你是刚学前端、只会用 setInterval 写轮播图的新手还是天天和帧率、卡顿、掉帧打交道的资深开发应该都能从这里挖到点能直接抄走的东西。核心就一件事搞清楚 rAF 为什么能成为浏览器动画和渲染调度的标准姿势以及它到底怎么帮我们在 js 性能优化这条路上少走弯路。1. 从动画卡顿说起为什么我们需要 requestAnimationFrame我第一次认真研究 rAF是因为一个特别具体的线上问题一个活动页的抽奖转盘用 setInterval 每 16 毫秒转一次在低端安卓机上卡得像 PPT转盘在转的过程中还会突然“抽搐”一下。用户投诉、运营追着问那一周我把动画这块从头到尾翻了一遍也正是那次让我彻底换掉了 setInterval。所以讲 rAF 之前我得先把“为什么老办法不行”这件事说透你才能真正理解它解决了什么。1.1 定时器动画的先天缺陷藏在时间片里setInterval 和 setTimeout 的调度本质上是把任务扔进浏览器的宏任务队列等主线程空了再来执行。问题就在这儿动画需要一个稳定的执行节奏而定时器给不了这个保证。你写setInterval(fn, 16)期待的是一秒 60 帧但实际运行中主线程可能正忙着解析一段大 JSON、执行一个复杂计算或者刚触发了大量的样式重排。这些活儿一占住线程你的定时器回调就只能排队等着等它终于被执行时浏览器可能已经错过好几帧了。更麻烦的是定时器的时间间隔和浏览器真正刷新的节奏根本对不齐。显示器有自己的刷新率常见的是 60Hz也就是每 16.67 毫秒刷新一次画面有些高刷屏是 90Hz、120Hz 甚至更高。你设的 16 毫秒和硬件的 16.67 毫秒永远在错位错位积累起来就会出现“一帧里画了两次、下一帧什么都没画”的情况视觉上就是抖动、跳帧。我在低端机上实测过一个用 setInterval 定时的进度条帧间隔的波动能到 8ms 到 40ms 之间来回跳那画面看着能舒服才怪。提示setTimeout 还有个“最小延迟”的问题嵌套层级深了之后浏览器会强制把延迟拉到 4ms 以上这直接让高频定时器动画变得不可控。所以定时器动画的本质缺陷是它试图用一个“逻辑时间”去驱动一个“物理刷新”的动作而这两者压根不同步。你压不住帧率也躲不开主线程的阻塞卡顿是必然的。1.2 requestAnimationFrame 的设计初衷是跟浏览器渲染管线握手rAF 的思路完全不一样。它不问“你希望多久执行一次”而是说“浏览器你下一次要重绘之前顺便把我这个回调也执行了吧”。这个“恰好在下一次重绘之前”就是它的精髓所在。浏览器的渲染是一帧一帧走的每一帧大致会经历这么几个阶段处理输入事件、执行定时器和 microtask、执行 rAF 回调、计算样式、布局、绘制、合成。rAF 回调被安排在了“计算样式和布局”之前也就是说你在回调里改的 DOM 和样式会正好被这一帧的渲染流程消化掉不会白白多渲染一次。这就带来了两个直接好处。第一帧率天然对齐60Hz 屏幕上它就是一秒 60 次120Hz 屏幕上它就是 120 次浏览器会自动按当前屏幕的刷新率来调度你完全不用操心那个恼人的 16.67。第二避免无效渲染页面在后台标签页、或者用户切到别的窗口时浏览器会主动把 rAF 回调暂停省电省 CPU而 setInterval 依然在后台傻乎乎地跑白白消耗资源。我之前做过一个数据看板切到后台再切回来用定时器的版本累计跑了上千次无用回调换成 rAF 之后后台直接归零。你可以把 rAF 理解成“预约制”你不是每隔一段时间硬闯进来说“我要画东西”而是提前跟浏览器打好招呼“你下次作画前叫我一声”然后由浏览器统一安排。这种和渲染管线握手的机制是它区别于所有定时器方案的根本。2. requestAnimationFrame 的核心机制拆解光知道“它更好”还不够要用得顺手得把它的运行机制摸清楚。这一章我想讲三个东西回调到底在什么时候被调用、它和定时器到底差多少、以及那个容易被忽略的回调参数时间戳该怎么用。这几点搞明白了你写出来的动画代码质量会直接上一个台阶。2.1 帧率、垂直同步与回调执行时机先说“垂直同步”这个概念。显示器刷新画面不是随机的它有一个固定的节奏电子束从上到下扫描一遍现代屏幕是逐行刷新这个动作叫一次刷新。显卡往屏幕上写新画面的时候如果写早了或者写晚了导致写到一半屏幕开始刷新就会出现画面撕裂——上半部分是旧画面、下半部分是新画面看着像被横着切了一刀。垂直同步就是为了解决这个让绘制动作必须等到一次刷新结束、下一次刷新开始之前完成保证整屏一致。rAF 的回调时机就卡在这个位置它保证回调在浏览器准备好进行下一次重绘时被调用。如果这一帧主线程太忙浏览器判断来不及完成渲染它就可能跳过这次渲染你的回调也会被推迟到下一个真正渲染的帧。这一点特别重要——它意味着 rAF 会“自适应”忙的时候少画一帧保证不撕裂闲的时候按满帧率跑。我拿一个持续运行的 rAF 循环在性能面板里看过帧率会随着页面负载在 60、45、30 之间浮动但画面始终是连贯的没有那种定时器动画里突兀的一跳。还有一个细节一帧内多次调用 requestAnimationFrame浏览器并不会给你安排多个回调它会合并成同一个回调队列在下一个渲染帧统一执行。这个特性后面在“多任务合并”里我会详细用它做优化。2.2 requestAnimationFrame 与 setInterval 对比实测空口说无凭我整理了一张对比表这些结论基本都来自我在中低端安卓机和主流桌面浏览器上的实测对比维度setInterval / setTimeoutrequestAnimationFrame执行节奏固定时间间隔与屏幕无关跟随屏幕刷新率自适应帧率对齐容易错位产生抖帧天然对齐画面连贯后台表现后台标签页仍在运行耗电自动暂停恢复时继续主线程繁忙时回调堆积动画跳跃自动丢帧保证连贯是否触发多余重绘可能触发无效渲染与渲染管线协同无浪费高刷新率支持需手动改间隔适配麻烦自动跟随 120Hz 等精准时间控制时间间隔不准回调带高精度时间戳从表里能看出来定时器唯一还能打的场景是那些“必须按时执行”的网络心跳、轮询之类的逻辑任务。凡是和视觉动画、UI 更新相关的rAF 几乎是全面占优。我现在的原则很简单只要能画在屏幕上的东西就用 rAF只有不涉及画面的定时逻辑才考虑定时器。2.3 回调时间戳被低估的高精度计时器很多人写 rAF 的时候都是requestAnimationFrame(function() { ... })完全无视了回调里那个参数。其实浏览器传给回调的这个时间戳非常好用它是一个高精度的 DOMHighResTimeStamp单位是毫秒精度可以到微秒级取决于浏览器的安全策略而且它是相对于页面导航开始时间的单调递增值不受系统时间调整影响。它最大的价值是帮你把“用了多少时间”这件事算准。举个最常见的场景一个物体从 A 点移动到 B 点你不应该用“每次移动固定距离”来做那样帧率一变速度就变了。正确做法是用时间戳算差值let start null; const duration 1000; // 动画持续 1 秒 function step(timestamp) { if (start null) start timestamp; const elapsed timestamp - start; const progress Math.min(elapsed / duration, 1); // 用 progress 去算位置1 秒后 progress 到 1 box.style.transform translateX(${progress * 300}px); if (progress 1) { requestAnimationFrame(step); } } requestAnimationFrame(step);这样写不管中间掉了几帧、帧率是 60 还是 30物体从 A 到 B 永远耗时一秒视觉速度是稳定的。这就是所谓的“基于时间而非基于帧”的动画是做流畅动画的基石。我以前用固定步长写动画在 120Hz 屏幕上速度直接翻倍闹过笑话后来全部改成时间驱动才彻底解决。提示timestamp 是单调递增的但不要让它在长时间停留后继续累加比如后台切回来因为切后台期间它可能不再更新恢复后可能出现一个巨大的时间差记得用 Math.min 或重置 start 来兜底。3. 实战用 requestAnimationFrame 重构一个卡顿的动画理论讲够了来点真东西。这一章我拿一个真实重构过的进度条动画做例子从反面教材到正面解法中间还会讲怎么封装一个通用调度器以及怎么用合并技巧一次 rAF 处理多个动画任务。这套东西我在好几个项目里反复用过直接搬就能生效。3.1 反面案例setInterval 实现的滚动进度条先看原始代码这是我早期写的一个页面顶部滚动进度条逻辑是监听滚动、更新宽度同时用定时器做平滑过渡// 反面教材定时器驱动 每帧读布局 let progress 0; setInterval(function () { const scrollTop document.documentElement.scrollTop; // 读布局 const total document.body.scrollHeight - window.innerHeight; progress scrollTop / total; bar.style.width (progress * 100) %; // 写布局 }, 16);这段代码有三个致命问题。第一每 16 毫秒强制读取 scrollTop这会触发浏览器的布局计算如果在读之后又立刻写样式就形成了“读写交替”导致布局抖动一帧内可能触发多次重排。第二用 setInterval 完全不看渲染时机滚动过程中主线程本来就忙回调更容易堆叠。第三.width这个属性的变化会触发重排加重绘开销比 transform 大得多。实测在低端机上滚动时帧率掉到 20 出头进度条明显一卡一卡的。注意在滚动、动画这种高频场景里任何一次“读布局后又写样式”的操作都可能引发强制同步布局这是性能杀手比单纯的重绘严重得多。3.2 正面对比用 requestAnimationFrame 重写同样的功能用 rAF 加读写分离重写之后体验完全不一样let ticking false; const bar document.querySelector(.progress-bar); function updateProgress() { const scrollTop document.documentElement.scrollTop; const total document.body.scrollHeight - window.innerHeight; const ratio total 0 ? scrollTop / total : 0; // 用 transform 替代 width避开重排 bar.style.transform scaleX(${ratio}); ticking false; } window.addEventListener(scroll, function () { if (!ticking) { // 只登记一次 rAF滚动再频繁也只在下一帧处理一次 requestAnimationFrame(updateProgress); ticking true; } }, { passive: true });改动看着不大但每一步都有讲究。ticking标志位的作用是节流滚动事件可能一秒触发上百次但我们只在下一帧真正处理一次中间的全部合并掉。{ passive: true }告诉浏览器这个监听器不会调用 preventDefault浏览器就能立刻滚动、不用等回调执行完移动端上这个细节能明显提升滚动手感。而scaleX走的是合成层不触发重排重绘性能比改 width 好一大截。我实测这套改下来滚动帧率稳定在 58 到 60 之间几乎满帧。同样的思路可以用在所有高频事件上resize、mousemove、拖拽都是“事件里只登记、rAF 里统一处理”的模式。3.3 封装一个通用的动画调度器项目里动画一多每个都写一套 rAF 递归太乱我干脆封装了一个轻量的调度器把所有动画任务统一到一次 rAF 循环里const Scheduler (function () { const tasks new Set(); let running false; function loop(timestamp) { // 先统一执行所有任务读取阶段 tasks.forEach(function (task) { if (task.active) { task.fn(timestamp); } else { tasks.delete(task); } }); if (tasks.size 0) { requestAnimationFrame(loop); } else { running false; } } function add(fn) { const task { fn: fn, active: true }; tasks.add(task); if (!running) { running true; requestAnimationFrame(loop); } return task; } function remove(task) { if (task) task.active false; } return { add: add, remove: remove }; })();这个调度器的好处很明显不管页面有多少个动画在跑全局只有一次 rAF 循环省去了反复注册的开销用 Set 管理任务增删都是 O(1)任务执行完自动清理不会留下野回调。我用它同时驱动过页面上的六个元素动画性能面板里 rAF 回调次数反而比之前每个动画各自递归还少。用起来也简单const t Scheduler.add(function (ts) { // 你的动画逻辑 if (done) Scheduler.remove(t); });3.4 多任务合并与节流配合除了调度器还有一个我常用的合并技巧把“同一帧内需要读的量”集中读、“需要写的量”集中写。浏览器在一帧里如果你先写后读它会为了给你正确的读值而强制重排。正确的做法是一次性把所有读操作做完再统一写Scheduler.add(function () { // 读取阶段集中读避免中间插写 const a el1.getBoundingClientRect(); const b el2.offsetWidth; // 写入阶段集中写 el1.style.transform translateX(${a.width}px); el2.style.width b px; });这个“读写分离”的思维配合 rAF 使用能直接把每帧的重排次数从几次压到零次。我在一个拖拽排序的列表里用过拖动时的卡顿感基本消失。这部分的坑还有一个如果读取的值依赖刚才写入的样式那你没法分离只能认栽多一次重排但这种情况其实可以通过维护一份 JS 侧的状态缓存来规避别什么都去问 DOM。4. 性能优化的深层技巧rAF 只是起点rAF 能解决“何时画”的问题但“画什么、怎么画”同样决定最终性能。这一章我想把和 rAF 配合的几项关键优化展开讲怎么避免布局抖动、怎么利用合成层、以及怎么和现代观察器 API 打配合。这些内容算是把 js 性能优化从“会写”拉到“写得好”的关键。4.1 布局抖动rAF 也救不了的强制同步布局先说个容易踩的坑rAF 保证了你回调的时机但它管不了你回调里干了什么。如果你在 rAF 回调里交替读写布局一样会触发强制同步布局rAF 也救不了你。所谓强制同步布局就是你改了样式之后立刻去读一个布局属性比如 offsetHeight、getBoundingClientRect、scrollTop浏览器为了给你准确的值被迫马上把之前所有的样式变更同步计算一遍把本该在渲染阶段做的事提前到现在做。一次两次还好一帧里来个几十次帧率立刻崩。我排查这类问题的办法很简单打开性能面板录一段看有没有那种特别窄但特别密集的“Recalculate Style”或“Layout”块。如果有就说明在反复强制重排。解决套路还是老几样能缓存的布局值就缓存别每帧去读读写一定要分离实在要读挪到这一帧的读取阶段统一读。我见过一个动画最夸张一帧里读了 50 次 scrollTop把 rAF 用出了定时器的卡顿感改完之后帧率从 30 直接回到 60。注意transform 和 opacity 之外的大部分样式变更都会触发重排或重绘能用 transform 就绝不用 top/left/width/height 做动画。4.2 合成层与 GPU 加速让动画真正丝滑想把动画做到极致的丝滑得理解合成层。浏览器渲染最后一步是合成Composite把各个图层像 PS 图层一样拼到一起。如果你能把需要频繁变化的元素提升为一个独立的合成层那它的变化就只在 GPU 层面完成完全不需要 CPU 重排重绘性能自然飞起。最常用的提升手段是 transform 和 opacity它们本来就能被 GPU 直接处理。必要的时候可以加will-change: transform提前告诉浏览器“这元素要动先给它单独开个层”。但这里有个大坑要提醒你别滥用 will-change。每个合成层都要占用 GPU 显存你给页面上几十个元素都加上 will-change显存分分钟被吃满反而更卡。我一般只在动画即将开始时加、结束时移除比如鼠标悬停的卡片、正在拖拽的元素。我见过有人图省事给整页元素全加上 will-change结果低端机直接白屏得不偿失。记住一句话合成层是稀缺资源用在该用的地方。4.3 与 IntersectionObserver、ResizeObserver 配合现代浏览器给了一组“观察器”API它们和 rAF 是绝配。比如你有一堆元素要在进入视口时才播放动画用 scroll 事件去判断每个元素位置本身就慢还会和 rAF 抢资源。换成 IntersectionObserver浏览器在合适的时机回调告诉你某个元素进出了视口你只在真正要动的地方启动 rAF 动画其余时间完全零开销。const observer new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { startAnimation(entry.target); // 进入视口才开始 rAF 动画 observer.unobserve(entry.target); } }); }, { threshold: 0.1 }); document.querySelectorAll(.animate-on-scroll).forEach(function (el) { observer.observe(el); });这套组合我在长列表页里用过一屏元素全用 rAF 跑动画滚动直接卡死改成 IntersectionObserver 按需启动滚动全程保持满帧。ResizeObserver 也是同理元素尺寸变化时才触发比监听 window resize 精确得多也不会像 resize 那样疯狂触发。这几个 API 的共同思路都是“把该省的省掉把资源留给真正需要动的那一刻”这和大方向的 js 性能优化妥妥一路。5. 常见坑与排查技巧实录最后这一章我掏心窝子聊坑。这些是我和团队这几年真金白银踩出来的文档里基本不会写但每一个都能让你少熬几个通宵。我把它们整理成速查表和几条独家避坑经验你照着排查能省不少事。5.1 页面隐藏时 rAF 停止这个“特性”也能变坑rAF 在后台标签页自动暂停这是好事省电省资源。但它也会带来问题如果你的动画逻辑依赖 rAF 的持续累加来推进状态切后台再切回来动画会“原地卡住”然后突然恢复甚至因为时间戳跳变出现一帧移动超远的情况。我在一个在线播放器里就遇到过切后台一会儿回来进度条猛地窜一大截。解决办法是记录document.visibilityState切回来时重置计时起点let lastTime null; function loop(ts) { if (lastTime null) { lastTime ts; requestAnimationFrame(loop); return; } const delta ts - lastTime; // 如果单帧间隔超过 100ms说明可能刚从后台回来跳过这次累加 if (delta 100) { lastTime ts; requestAnimationFrame(loop); return; } lastTime ts; // 正常用 delta 推进动画 requestAnimationFrame(loop); }这个“超过阈值就跳过”的兜底逻辑很实用能挡住大部分后台切换、断点调试导致的时间跳变。5.2 忘记取消 rAF内存泄漏悄无声息第二个高频坑用了 rAF 却没在组件卸载时取消。你在一个弹窗里挂了 rAF 循环弹窗关掉了但循环还在跑因为它根本没被清理。这种泄漏特别隐蔽页面上看不出什么但 CPU 一直在被消耗。规范做法是保存 rAF 返回的 id在清理时机调用 cancelAnimationFramelet rafId null; function loop(ts) { // ... rafId requestAnimationFrame(loop); } function cleanup() { if (rafId ! null) { cancelAnimationFrame(rafId); rafId null; } } // 比如组件卸载、弹窗关闭时调用 cleanup用前面的调度器模式其实也能规避这个问题因为任务执行完会从 Set 里自动删掉但手动写递归的话取消这一步永远别忘了。5.3 常见问题速查表下面这张表是我自己整理的“rAF 排查手册”遇到问题先对着看一遍能解决八成现象可能原因排查与解决动画抖动、跳帧混用定时器或帧率错位统一改用 rAF用时间戳驱动切后台回来画面突变时间戳跳变未处理加 delta 阈值判断并跳过动画卡顿但 rAF 正常回调里强制同步布局读写分离缓存布局值页面越用越卡rAF 未取消导致泄漏保存 id卸载时 cancel低端机白屏will-change 滥用吃显存按需添加动画结束即移除高刷屏速度变快用固定步长而非时间改为基于时间戳的进度计算滚动时进度条卡每帧读 scrollTop缓存、节流、读写分离5.4 几条独家避坑经验最后再补几条表格里塞不下的经验。第一调试动画时别用 console.log 疯狂打印日志本身就是开销会掩盖真实的性能表现用 performance.now() 记录时间点更靠谱。第二别迷信“帧率越高越好”我把一个动画从 60 帧优化到 120 帧用户根本没感觉反而多耗电能稳定 60 帧就够了。第三用 rAF 做节流比时间戳节流更贴合渲染凡是和画面更新相关的节流优先考虑“事件里登记、rAF 里执行”这个模式比如 mousemove 拖拽手感会明显更好。我个人在实际项目里的体会是rAF 这东西“会用”和“用好”之间隔着一整条性能优化的路。它本身只有两个 API但真正决定动画是否丝滑的是你有没有把渲染时机、读写顺序、合成层、任务合并这些细节串成一条线。我现在的习惯是任何一个要上生产环境的动画先过一遍这套检查是不是用 rAF 驱动、时间戳算的进度、有没有读写分离、rAF 有没有取消、will-change 加得克不克制。这几步走完基本不会翻车。如果你手上正好有个卡顿的动画要救不妨从“把 setInterval 换成 rAF”这一步开始往往立竿见影。