很多人聊性能优化,开口就是“FPS掉到多少了”“Long Task 卡了几次”,但你要是追问一句“浏览器每一帧到底做了什么?为什么一帧的死线是 16.6ms?”,多半会卡壳。16.6ms 不是随口编出来的数字,它是 1000ms 除以 60Hz 的结果,是浏览器在每一帧里能用的总预算。而 requestIdleCallback 这个 API,就是专门用来消耗“帧尾剩余时间”的,很多前端写过但说不清它到底什么时候触发、为什么不能拿 setTimeout 替代。
这篇文章我想把这条链路完完整整拆一遍:从显示器的刷新率讲到渲染流水线的每一步,再讲到主线程上的任务排队,最后落到 requestIdleCallback 的触发机制和实战用法。适合所有写页面优化、排查卡顿问题、或者单纯对浏览器内部工作原理好奇的同学。文章里所有流程我都按 Chrome 的 Blink 引擎来讲,其他浏览器内核大同小异,理解了主线,换引擎也能快速对号入座。
1. 16.6ms 是怎么算出来的:刷新率与“一帧”的契约
1.1 60Hz 背后的物理限制
讨论渲染流水线之前,得先把“帧”这个单位本身说清楚。一帧就是屏幕上显示的一幅静止画面。屏幕不是持续发光显示同一幅图,而是以固定的频率反复重绘,这个频率就是刷新率,单位 Hz 表示每秒刷新多少次。
主流显示器从 CRT 时代到现在,基准刷新率一直是 60Hz,也就是每秒刷新 60 次。每一次刷新之间的间隔就是 1000ms ÷ 60 ≈ 16.67ms。所以“16.6ms”不是浏览器发明的性能指标,而是显示器刷新节奏给浏览器设下的物理死线。浏览器必须在下次屏幕刷新之前,把这一帧的画面准备好,交到屏幕上去,才能让用户看到顺滑的动画和响应。
这里有个很多人容易搞反的点:浏览器并不决定帧率,显示器才决定帧率。浏览器只是在尽力配合显示器的节奏。如果浏览器在 16.6ms 内没画完,画面的更新时间就会错过这次刷新,拖到下一个刷新周期,用户看到的就是一帧卡顿。
1.2 为什么是 16.6ms 而不是 10ms 或 20ms
既然“更快的刷新率=更流畅”,为什么不直接把刷新率拉到 90Hz、120Hz?人眼对闪烁的感知阈值大约在 50Hz 到 60Hz 之间,低于这个区间,画面会明显闪烁,长时间看也容易疲劳;高于这个区间,人眼很难感知到显著改善,但硬件成本和功耗会大幅上升。60Hz 正是在流畅度、成本、功耗之间沉淀下来的平衡值。
可以看一张对照表,不同刷新率下每帧的可用时间差异很大:
| 刷新率 | 每帧可用时间 | 常见场景 |
|---|---|---|
| 60Hz | 16.67ms | 绝大多数桌面显示器、普通笔记本 |
| 75Hz | 13.33ms | 部分办公显示器 |
| 90Hz | 11.11ms | 中高端手机 |
| 120Hz | 8.33ms | 旗舰手机、高刷显示器 |
| 144Hz | 6.94ms | 电竞屏 |
注意:如果你的用户显示器是 120Hz,那每帧预算就只有 8.3ms 左右。平时在 60Hz 屏幕上勉强“不卡”的页面,换到高刷屏上反而可能掉帧,因为你的同步任务把时间吃光了。
1.3 帧率翻倍之后,浏览器的活一点没少
高刷新率屏幕对浏览器来说不是福利,是压力。浏览器不会因为你屏幕刷新快就把渲染流程简化,该解析的内容、该计算的样式、该画的像素,一步都不会少,只不过所有这些事被压缩进更短的时间里完成。
这也是为什么很多前端发现同一个页面在手机上(90Hz/120Hz)比在旧台式机上(60Hz)更容易卡。不是手机性能差,而是每帧的时间预算被压缩了。理解了这一点,再看后面要说的渲染流水线,你就会明白:真正的优化不是“让浏览器少干活”,而是“别把不重要的活塞进关键帧路径里”。
2. 一帧内浏览器要交的作业:渲染流水线拆解
2.1 从 HTML 到 DOM:解析的起点
一次完整的页面渲染,起点自然是 HTML。浏览器拿到 HTML 文本后,先做词法分析,把标签拆成 token,再构建成 DOM 树。这个过程叫 HTML Parsing。
HTML 解析是流式的,边下载边解析,不需要等整个文件下载完才开始。如果遇到<script>标签且没有defer或async,解析器会停下来,先下载并执行脚本,因为脚本可能用document.write修改 HTML 结构。这个阻塞是渲染流水线上的第一个常见瓶颈。
DOM 树本身只是结构,不含任何样式信息。所以要等下一步。
2.2 样式计算:CSS 怎么变成最终样式
浏览器拿到 CSS(包括外部样式表、<style>标签、内联样式)之后,会构建 CSSOM,然后对每一个 DOM 节点做样式计算(Recalculate Style),得出一份“计算样式”(Computed Style)。
这里有个细节:CSS 选择器匹配并不是“从左到右”,而是“从右到左”。比如.list .item a这个选择器,浏览器会先找所有的a标签,再向上匹配父元素是否是.item,最后才匹配.list。从右到左匹配的理由很实际:右侧的约束更具体,命中的元素集合更小,向上遍历的次数更少。
样式计算阶段耗时,往往不是选择器写得太长,而是修改了某个节点的 class 导致后代节点全部需要重新计算。所以实践中我很少抠选择器优先级,更关注“别改一个值让全树重算”。
2.3 布局、绘制、合成:后面这三步才是真正的重活
样式算完,接下来是 Layout(布局),也叫 Reflow。浏览器要把每个元素真实的几何位置算出来:宽高、边距、偏移量、是否溢出。这可以理解成排版:DOM 是文章内容,CSSOM 是排版规则,Layout 就是最终排出来的版面。
布局之后是 Paint(绘制),把每个节点的颜色、边框、阴影、文本真实地画出来。在 Chrome 里,绘制不是直接往屏幕上写像素,而是先生成绘制指令列表(display list),真正落到像素的操作发生在后面光栅化阶段。
最后是合成(Composite)。浏览器会把页面拆分成多个图层,光栅化后的图层通过 GPU 合成到一起,最终输出到屏幕。
这三步的耗时差异很大。修改颜色或阴影,通常只触发 Paint;修改宽高、display、position等属性,会触发 Layout,甚至可能影响整棵子树,让后面的 Paint 全部重做;而用transform和opacity做动画,可以把 Layout 和 Paint 都绕过去,直接走合成线程,这也是前端动画优化最核心的一条经验。
3. 从头到尾看一帧:16.6ms 里的时间切片
3.1 主线程上的任务队列,决定了这一帧怎么过
浏览器的渲染主线程是单线程,所有 JavaScript、样式计算、布局、绘制,全在这条线程上排队执行。每一帧的开始,浏览器不是先渲染,而是先看主线程上有没有更紧急的事:用户的点击、触摸事件要不要处理?requestAnimationFrame回调该不该执行?上一次渲染有没有被拖到今天?
所以一帧的实际流程,更像主线程执行完手头任务后,给渲染流程让出一条道。这也可以解释为什么一个长 JS 任务会阻塞渲染:主线程一直在跑脚本,根本轮不到 Layout 和 Paint,屏幕刷新被跳过去,画面就卡了。
3.2 典型一帧的时间分配
我用一个理想化的 60Hz 帧来做时间切片,方便你直观感受:
- 0ms ~ 2ms:处理输入事件(click、touch、keydown),完成浏览器需要响应的交互逻辑;
- 2ms ~ 8ms:执行
requestAnimationFrame回调,以及业务里和动画、交互强相关的 JS; - 8ms ~ 14ms:渲染流水线,样式计算、布局、绘制、合成,把画面准备好;
- 14ms ~ 16.6ms:空闲期,这段时间主线程没事干,正好是
requestIdleCallback出手的窗口。
这里要注意,上面的时间分配是理想状态。真实页面里,前三步经常会超时,尤其当业务 JS 要处理大数组、做 DOM 操作、或者触发强制同步布局时,14ms 之前根本走不完,空闲期直接归零。
3.3 丢帧与帧间隔:卡顿的本质是什么
丢帧的本质,就是主线程在 16.6ms 的预算内没完成渲染任务,导致输出跳过一帧。帧间隔(Frame Interval)指的是两帧实际输出之间的时间差。
在 60Hz 下,均匀的帧间隔应该是 16.6ms 左右。如果浏览器给你记录下来的帧间隔变成 33ms,说明中间丢了一帧;变成 50ms,说明丢了不止一帧。性能分析工具里看到的“卡顿”,很多就是帧间隔的尖刺。
做性能排查时,我不会先看 FPS 平均值,而是看“帧间隔分布”和“Long Task 数量”。一个超过 50ms 的长任务,几乎必然造成丢帧,也必然吃掉后面一两个帧的空闲时间。所以优化重点永远是“把长任务打碎”,而不是“祈祷浏览器跑快点”。
4. requestIdleCallback:主线程的“空闲时间”到底在哪
4.1 帧尾的空闲期是从哪儿冒出来的
上一节说到典型一帧的最后有 2ms 左右的空闲时间。requestIdleCallback(下文简称 rIC)就是浏览器给开发者提供的“利用这段空余时间做非紧急任务”的入口。
这个 API 的设计思路和requestAnimationFrame正好相反。rAF 是在每一帧渲染开始之前执行,任务是“这一帧必须完成的”;rIC 是在渲染完成之后、下一帧还没开始的间隙执行,任务是“能拖就拖”的。
浏览器会在这些时机尝试执行空闲回调:当前一帧已经画完、主线程上暂时没有更紧急的任务;用户处于空闲状态,比如长时间没有键盘鼠标输入;或者页面切到后台,帧渲染压力降到零。注意,并不是每一帧末尾都会触发 rIC,只有主线程真的有空,浏览器才会把回调塞进来。
4.2 触发条件与 deadline 的计算规则
rIC 回调接收一个IdleDeadline参数,它有两个关键能力:timeRemaining()返回当前帧剩余多少毫秒可以干“不紧急的活”;didTimeout表示这次回调是不是因为超时被迫执行的。
requestIdleCallback(function(deadline) { while (deadline.timeRemaining() > 0) { // 这次能干的活 } }, { timeout: 2000 });timeout: 2000的含义是:如果两秒内一直没等到空闲,就强制在这两秒的超时点执行,即使当前帧已经没有空闲时间。这是一个兜底机制,防止“重要但不紧急”的任务被无限期拖延。
timeRemaining()的上限并不是固定的 16.6ms,浏览器会动态计算。实践里有个经验值:如果timeRemaining()返回的剩余时间小于 5ms,说明当前帧几乎满了,继续往里塞任务很可能拖到下个渲染周期,最好等下一轮。
4.3 为什么不能用 setTimeout(0) 代替 rIC
很多人觉得“反正都是延迟执行,我用 setTimeout 不行吗?”不行。setTimeout(0)只是把任务排进下一个宏任务队列,浏览器执行它的时候根本不管当前处于什么渲染状态。可能插在渲染流水线中间,也可能刚好卡在 Layout 之后,让你改的样式触发一次额外的布局。
rIC 则严格避开渲染关键路径:它在确认“帧尾有空闲”之后才执行,而且执行时能看到当前剩余时间,可以自行决定“这次干多少、何时停”。同样一个低优先级任务,setTimeout 做的是“赌一次空闲时机”,rIC 做的是“精准填充空闲时间”。实测下来,把埋点上报这类任务从 setTimeout 换成 rIC,页面交互卡顿出现的概率小得多。
5. 实战:如何用空闲回调优化真实页面
5.1 哪些任务值得放进空闲期
并不是所有“不急的”任务都适合用 rIC。我自己的挑选标准有三条:可以分片执行、不依赖 DOM 布局、失败了也无所谓。
适合放进去的典型任务包括:埋点和统计上报、大对象的序列化与缓存构建、全文搜索索引的预计算、图片懒加载的预解码、离屏 Canvas 的预绘制。这些都是“晚几帧做完,用户也不会发现”的任务。
不适合放进去的包括:读写 DOM、修改样式(会触发重排重绘,只是把卡顿换了个时间)、用户输入处理(必须即时响应)、动画相关逻辑(应该用 rAF,保证每帧开始前拿到最新状态)。
5.2 一个最小可用的时间切片实现
核心思路是把大任务拆成小块,每块只消耗当前剩余时间的配额:
const tasks = [/* 一堆待处理的子任务 */]; function idleProcess(deadline) { // 每帧空闲只做 3ms,剩下的留给后续帧 while (deadline.timeRemaining() > 3 && tasks.length > 0) { const task = tasks.shift(); task(); } if (tasks.length > 0 && !deadline.didTimeout) { requestIdleCallback(idleProcess); } } requestIdleCallback(idleProcess);这段代码的核心是while里的两个条件:deadline.timeRemaining() > 3保证不把一帧的空闲时间耗光,deadline.didTimeout用来防止超时后无限自循环。
如果你观察这个回调,会发现它每次只处理几个小任务就退出,剩下的排队等下一帧。帧数一高,整体完成时间并不长,但用户的交互响应完全不受影响。这就是时间切片的价值。
5.3 用 Long Tasks API 验证优化效果
rIC 到底有没有生效,不能靠感觉,得看数据。浏览器提供了 Long Tasks API,配合PerformanceObserver可以统计主线程上超过 50ms 的任务:
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log('Long Task:', entry.duration, entry.startTime); } }); observer.observe({ entryTypes: ['longtask'] });优化前,把业务里的大任务直接跑,Long Task 常常一片一片地出现;改成 rIC 分片执行后,Long Task 数量会明显下降。不过要提醒一句:PerformanceObserver统计的是超过 50ms 的任务,如果你在 rIC 里不小心一次性塞了过多计算,回调本身也可能变成一个 Long Task,那说明你的分片粒度还不够细。
6. 踩过的一些坑,以及我现在实际的使用习惯
6.1 坑:在 rIC 里改 DOM,等于白优化
我第一次用 rIC 时犯过一个典型错误:看到帧尾有空闲,就在回调里插入一批 DOM 节点并更新样式。结果 Long Task 没少,反而多了几帧明显的卡顿。原因其实前面讲了:DOM 操作会打乱渲染流水线的时间窗口。rIC 执行点已经靠近帧尾,你这一改,浏览器可能得额外触发一次布局和绘制,把下一帧的开始时间顶过去。
所以我的经验是:rIC 回调里最好只碰“不产生渲染副作用”的数据,比如普通对象、数组、Map、JSON。真要更新 DOM,应该先大批量准备好数据,再交给 rAF 在统一时机渲染,或者用 microtask 集中处理。
6.2 坑:timeout 不是“到点就执行,不管前面有多忙”
timeout的全名是“最长等待时间”,但它不是定时器,没有严格的时间线。如果主线程被一个 500ms 的长任务占着,你设置timeout: 2000,那回调大约会在 2000ms 后排队,但真正执行还是要等长任务跑完。换句话说,didTimeout只保证“空闲期没等到,我就给你强行排队”,它挡不住既有长任务导致的延迟。
这带来的实际影响是:如果你的任务属于“超时没执行会有损体验”,比如上报一条关键的退出日志,那就不能只靠 rIC,还得在visibilitychange或beforeunload里用同步方式兜底。
6.3 踩过几次坑之后,我现在的使用习惯
现在我的做法很简单:任何页面初始化时,先开一个requestIdleCallback的入口,把所有“用户感知不到优先级”的任务维护成一个队列,每帧处理 3ms 左右。任务本身尽量切成独立的小数据块,禁止直接触碰布局。我会给每个 rIC 任务设置一个合理的timeout,但对那些既无法分片又不能丢失的任务,绝不放进 rIC,而是用setTimeout分段处理。
再补一个技巧:rIC 在 Safari 上一直不支持,使用前最好做一次特性检测,退化方案就用setTimeout(() => {}, 0)分片执行。虽然它不能精准感知帧尾空闲,但总比报错让整个流程断掉要好。
浏览器每一帧的 16.6ms 其实是一个固定但脆弱的窗口。真正提升流畅度,不是靠某个 API 一步到位,而是理解这条流水线每个环节的成本,再决定把哪部分任务放进哪一帧里去完成。