第一次在项目里认真琢磨 requestAnimationFrame,是因为一个滚动播放卡片堆叠的 H5 页面。在安卓旧手机上,用 setInterval 驱动每次步进的动画,总有一种“哒哒哒”的抽帧感,甚至在快速滑动时直接撕裂出半屏空白。当时同事指着屏幕里一道横向的割痕说:“你这不是动画,是在闹鬼。”后来排查到问题就出在定时器上——它根本不关心屏幕什么时候能绘制,抢着跑、抢着停,画面自然就乱了。换成 requestAnimationFrame 之后,那台卡到怀疑人生的千元机,居然被救回来大半,滚动播放终于像有了呼吸节奏一样顺滑。从那以后,凡是跟绘制、动画、帧率控制沾边的需求,我几乎不再考虑用定时器当主力驱动。
我一直觉得 requestAnimationFrame 属于那种“看着简单,但真想讲透却不容易”的浏览器 API。网上很多文章要么只甩一句“它比 setTimeout 更适合动画”,要么贴一段官方定义就草草收尾。如果你今天想搞明白的不只是一个 API 签名,而是它背后到底为什么能解决这些问题、浏览器在里面做了什么工作、实际开发中要用到什么程度才叫“会用”,那这篇拆解应该能帮你把整个链路一次盘清楚。
1. 先从“人眼要看的流畅”这件事说起
1.1 你需要的不是更快,而是恰到好处
屏幕能呈现动态画面,靠的是不断刷新显示内容。绝大多数显示器的刷新率是 60Hz,也就是一秒内能刷新 60 次画面,每一次刷新被称为一帧,帧与帧之间的间隔大约是 16.67 毫秒。这是什么概念?你去买显示器、手机时看到的“60Hz”“90Hz”“120Hz”,就是这个刷新的频率。
人眼对连续画面的感知并不是越流畅越好,而是只要屏幕帧数足够稳定、时序足够均匀,就会自动脑补成连贯运动。反过来,哪怕某一段的帧数很高,但如果中途突然卡掉几个帧,或者某几帧间隔忽长忽短,人眼会非常敏锐地捕捉到这种“不流畅感”。你回忆一下看手机时偶尔遇到的掉帧,是不是会有“抖了一下”的感觉?本质上就是帧间隔不齐造成的。
动画的本质,是让元素属性在单位时间内发生可感知的连续变化。从渲染技术上来看,浏览器每一帧都要经过脚本执行、样式计算、布局、绘制、合成这些过程。所以一个合适的前端动画驱动循环,应该是跟着显示器的节奏走。显示器请求一帧,你就更新一帧,不多跑也不少跑。它的核心目标不是“跑得最快”,而是“每一步都恰好赶在那一帧渲染之前完成更新”。requestAnimationFrame 这个 API 想解决的核心问题,就是替你找到这个“渲染之前”的准确时机。
1.2 为什么 setInterval 驱动动画容易翻车
可能有人会想:既然setInterval可以每隔16.7ms执行一次回调,不就是60fps吗?但在真实浏览器里,原因比这个模拟复杂得多。
setInterval 的计时是基于主线程任务队列的,它只保证“下一次回调任务被推入队列的最早时间点到了”,根本不知道这一帧浏览器渲染有没有结束、主线程是不是空闲、屏幕此刻有没有在刷新。于是你常常会遇到三类问题:
- 回调任务的执行时机落在帧与帧的渲染间隙,造成本帧样式更新后还没有等到绘制机会,下一帧又来了,多帧的更新挤压在一起,表现为“跳帧”。
- 主线程繁忙时,定时器回调可能会被连续延后几次,然后就突然一起执行好几轮,导致动画一瞬间跑一大截,也就是所谓的“burst”,随后又断片,观感极差。
- 即使你设定的是 16.7ms,但浏览器定时器本身的最小精度、系统时钟漂移、后台标签页的节流策略都会影响真实的触发间隔。
浏览器其实还有个非常现实的问题:如果页面切换到了后台标签页,为了保证性能,定时器会被大幅度节流,有的浏览器直接降到每秒一次甚至暂停。你想想,如果一张轮播图是靠 setInterval 驱动的,用户切走标签再切回来,轮播状态和显示内容可能是错乱的。而 requestAnimationFrame 就聪明得多——页面一旦不可见,浏览器会自动暂停调用;回到可见状态后,再接着从当前状态继续跑。这种机制对动画本身是巨大保护,能防止无意义的计算消耗,也能避免回归页面时“闪一下跳N步”的尴尬。
1.3 你自己写循环为什么做不到精准
也许你还会想:不用 setInterval,我在 while 循环里自己跑一个时间轴不就行了?确实有人这样写过。但问题在于,JavaScript 是单线程语言,如果你用同步阻塞的方式在一个循环里不断改样式、强制计算布局,这期间用户无法点击、页面无法滚动、浏览器无法重绘,所有操作体验全部卡死。
而且动画更新是一个异步的、与渲染配合的活动,它需要的是“每一帧只画一帧的状态”,而不是一股脑把所有中间态都算出来。自己写一个 while 循环把 0 到 100% 的全部中间状态瞬间算完,对用户毫无价值,因为真正能被看到的只有每一帧渲染出来的那一帧状态。
所以,动画驱动需要的是“一次一帧、持续交付”的异步循环机制。浏览器需要在每次绘制前给你留一个更新状态的机会,而 requestAnimationFrame 就是浏览器提供给你的那个官方“入场券”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. requestAnimationFrame 到底在浏览器的哪一层工作
2.1 它和浏览器渲染管线的关系
想要真正理解 rAF,必须从浏览器渲染管线说起。一次典型的一帧渲染过程,往往包括下面几个阶段:
- 处理输入事件(触摸、点击、滚动)
- 执行 JavaScript 回调(比如 rAF 回调、定时器任务等这部分在主线程排队)
- 计算样式(Style)
- 布局(Layout)
- 绘制记录(Paint)
- 合成(Composite)并把帧交给 GPU
requestAnimationFrame 的回调被执行的位置非常有讲究,它通常发生在每一帧渲染流程里的 “开始一帧的脚本阶段” 之前,准确说是在样式计算和布局之前。所以你在 rAF 回调里修改 DOM 样式,理论上还能赶在本次渲染提交前被合并进去,不会产生额外的布局抖动。这也是它被称为“渲染帧回调”的原因——它的存在就是为渲染服务的。
想象一下你站在一个工厂流水线旁边:屏幕每次开始处理一帧时,会喊一声“谁要改东西,现在赶紧说”,这时所有 rAF 回调都会被唤醒,大家在这一帧开始前统一报上这次要更新什么,然后流水线安静走完样式、布局、绘制。对比 setTimeout 的做法:它不是等流水线喊集合,而是自己在随机时间点冲进车间,有时候到得早了,得干等一会儿;有时候到得晚了,上一批零件已经焊死了,只能和下一批混在一起。结果就是画面节奏杂乱。
2.2 rAF 和事件循环、任务队列的密切配合
浏览器主线程上跑着一个类似“排队叫号”的机制,也就是事件循环。所有 JavaScri
