2. 浏览器渲染流水线:解析、样式计算、布局、绘制、合成,每一帧的旅程
聊到刷新卡顿,很多人第一反应就是「JS 执行太慢了」。但说实话,我排查过上百个卡顿案例,真正纯 JS 导致的其实不到三成。大部分问题,都出在浏览器渲染流水线的某个环节上。
你想想看,浏览器要把你的代码变成屏幕上的像素,中间要经过多少道工序?每一道都可能成为瓶颈。这一章,我就带你完整走一遍这个流程。
2.1 渲染流水线全景图
先看一张总图,把整个流程串起来。我习惯把这六个阶段称为「渲染六步」:
核心认知:浏览器每一帧的渲染,本质上是一个「输入 → 处理 → 输出」的管道。任何一步阻塞,都会导致整条流水线延迟。
2.2 第一步:解析(Parse)
浏览器拿到 HTML 和 CSS 文件后,第一件事就是解析。HTML 解析器把字节流变成 DOM 树,CSS 解析器把样式表变成 CSSOM 树。
这里有个坑,我踩过好几次。就是 CSS 会阻塞渲染。浏览器在构建 CSSOM 完成之前,不会开始渲染。所以 CSS 文件越大、网络越慢,首屏白屏时间就越长。
我的建议:首屏关键 CSS 直接内联到 <head> 里,非关键 CSS 用 media="print" 或 rel="preload" 延迟加载。这样能提前解锁渲染。
另外,<script> 标签也会阻塞解析。默认情况下,遇到 script 标签,HTML 解析器会停下来等 JS 下载和执行完。我以前做过一个项目,页面头部塞了个很大的第三方 SDK,结果首屏渲染硬生生被拖慢了 1.2 秒。
2.3 第二步:样式计算(Style Calculation)
DOM 树和 CSSOM 树都准备好了,浏览器开始计算每个 DOM 节点的最终样式。说白了,就是把所有 CSS 规则匹配到对应元素上,算出 computedStyle。
这个过程涉及选择器匹配。我记得 Chrome 团队做过测试,从右向左匹配的效率远高于从左向右。所以写 CSS 时,尽量让选择器右侧的规则更具体。
/* ❌ 不推荐:浏览器要从左向右匹配所有 .nav 下的 li */
.nav li a { color: blue; }
/* ✅ 推荐:直接命中目标,减少匹配路径 */
.nav-link { color: blue; }
注意:样式计算阶段虽然快,但如果 DOM 节点数超过 5000 个,或者选择器嵌套超过 4 层,计算耗时就会明显上升。我见过一个极端案例,页面有 2 万个 DOM 节点,样式计算花了 80ms——直接导致掉帧。
2.4 第三步:布局(Layout / Reflow)
样式算完了,浏览器开始计算每个元素在页面上的几何位置:宽、高、x、y。这一步叫布局,也叫 Reflow。
布局是渲染流水线里最耗时的环节之一。为什么?因为一个元素尺寸变了,可能影响它后面的所有兄弟元素,甚至影响父元素和祖先元素。这就是所谓的「布局抖动」。
我排查过一个表格排序卡顿的问题。每次点击表头排序,JS 会重新插入所有行,然后浏览器触发全量 Reflow。页面 2000 行数据,每次排序卡 300ms。后来改成只更新数据、用 transform 做动画,卡顿直接消失。
| 触发 Reflow 的操作 | 影响范围 | 性能影响 |
|---|---|---|
| 修改 width / height / margin / padding | 当前元素 + 兄弟 + 父元素 | 高 |
| 修改 top / left / position | 当前元素 + 可能影响其他定位元素 | 中 |
| 修改 font-size / line-height | 当前元素 + 所有子元素 | 高 |
| 读取 offsetHeight / getBoundingClientRect | 强制同步布局(最危险) | 极高 |
避坑指南:千万不要在循环里交替读写 DOM 属性。比如 for(...){ el.style.width = x; console.log(el.offsetWidth); },每次循环都会强制浏览器重新布局。我曾经在一个数据大屏项目里发现,就因为这个写法,页面每帧布局耗时从 2ms 飙升到 45ms。
2.5 第四步:绘制(Paint)
布局算完了,浏览器知道每个元素在哪、有多大。接下来要决定每个像素画什么颜色、什么形状。这一步叫绘制。
绘制阶段会把元素拆成多个绘制层(Paint Layer),每个层独立绘制。比如 position: fixed、opacity < 1、transform、will-change 等属性都会创建新的绘制层。
绘制本身不慢,但绘制区域越大、越复杂,耗时就越长。比如一个页面有大量 border-radius、box-shadow、filter,绘制阶段就会很吃力。
我的经验:用 Chrome DevTools 的「Paint flashing」功能,可以直观看到哪些区域被重绘了。如果整个页面都在闪,说明绘制范围太大,需要优化。
2.6 第五步:合成(Composite)
绘制层准备好了,浏览器把它们交给合成线程。合成线程负责把这些层按正确的顺序、位置、透明度拼到一起,生成最终的位图。
合成是 GPU 的强项。所以只要动画只涉及 transform 和 opacity,浏览器就可以跳过布局和绘制,直接在合成线程完成。这就是为什么 transform: translate() 比 left 动画流畅得多。
/* ❌ 触发布局 + 绘制 + 合成 */
.box { left: 100px; transition: left 0.3s; }
/* ✅ 仅触发合成 */
.box { transform: translateX(100px); transition: transform 0.3s; }
我记得有一次优化一个轮播图,用 left 做位移,每帧耗时 16ms 以上,经常掉帧。改成 transform 后,每帧降到 3ms,60fps 稳稳的。
2.7 第六步:显示(GPU 光栅化)
合成线程把最终结果提交给 GPU,GPU 把位图光栅化到屏幕上。这一步通常很快,但如果页面层数太多(比如超过 30 层),GPU 内存带宽可能成为瓶颈。
我见过一个极端案例:一个页面用了 50 多个 will-change: transform,结果 GPU 内存爆了,页面直接黑屏。所以 will-change 不要滥用,只对真正需要动画的元素使用。
2.8 每一帧的旅程:16.67ms 的生死线
浏览器每秒刷新 60 次,也就是每帧只有 16.67ms。在这 16.67ms 里,要完成 JS 执行、渲染流水线、合成、显示。如果任何一个环节超时,这一帧就丢了,用户就会感觉到卡顿。
我习惯用 Chrome DevTools 的 Performance 面板来排查。看每一帧的「Task」耗时,如果超过 16ms,就点进去看是哪个阶段拖了后腿。
核心结论:
- 布局(Reflow)和绘制(Paint)是卡顿的两大元凶
- 优先使用仅合成属性(transform、opacity)做动画
- 避免强制同步布局(在读取布局属性前不要修改样式)
- 减少 DOM 节点数,简化 CSS 选择器