浏览器16.6ms帧预算:从渲染流水线到requestIdleCallback

发布时间:2026/10/7 8:20:44
浏览器16.6ms帧预算:从渲染流水线到requestIdleCallback 很多人聊性能优化开口就是“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 正是在流畅度、成本、功耗之间沉淀下来的平衡值。可以看一张对照表不同刷新率下每帧的可用时间差异很大刷新率每帧可用时间常见场景60Hz16.67ms绝大多数桌面显示器、普通笔记本75Hz13.33ms部分办公显示器90Hz11.11ms中高端手机120Hz8.33ms旗舰手机、高刷显示器144Hz6.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回调以及业务里和动画、交互强相关的 JS8ms ~ 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 一步到位而是理解这条流水线每个环节的成本再决定把哪部分任务放进哪一帧里去完成。