2. 浏览器渲染流水线:深入解析关键渲染路径(CRP)的6个步骤
关键渲染路径,英文叫 Critical Rendering Path,简称 CRP。说白了,就是浏览器从拿到 HTML 字节流,到最终在屏幕上画出像素的整个过程。
我刚开始做性能优化时,总觉得这玩意儿太底层,跟业务代码关系不大。直到有一次,一个简单的列表页在低端机上卡得飞起,我查了半天发现是 CSS 选择器太复杂导致重绘频繁。嗯,从那以后,我再也不敢小看 CRP 了。
CRP 一共 6 个步骤,环环相扣。任何一个环节出问题,都会直接导致刷新卡顿。我们一个一个来看。
2.1 第一步:DOM 树构建
浏览器拿到 HTML 后,会把它解析成一棵 DOM 树。这棵树由一个个节点组成,每个节点对应一个 HTML 标签。
解析过程是边下载边解析的。遇到 <script> 标签时,默认会阻塞解析。为什么?因为 JS 可能修改 DOM 结构,浏览器不敢继续往下读。
<script> 放在 <body> 底部,或者加上 defer / async 属性。这样 DOM 解析不会被卡住。
举个例子,这段 HTML:
<html>
<head></head>
<body>
<div>Hello</div>
<p>World</p>
</body>
</html>
解析后生成的 DOM 树大致是:
document
└── html
├── head
└── body
├── div (文本: "Hello")
└── p (文本: "World")
注意,DOM 树只包含 HTML 结构,不包含样式信息。样式信息在下一步。
2.2 第二步:CSSOM 树构建
CSS 解析成 CSSOM(CSS Object Model)树。它和 DOM 树类似,但存的是样式规则。
CSSOM 的构建会阻塞渲染。浏览器必须等所有 CSS 下载并解析完,才敢进入下一步。为什么?因为如果先渲染了,后面 CSS 加载完又得重绘,浪费性能。
@import 引入 CSS。这玩意儿会串行下载,比 <link> 慢得多。后来全改成 <link>,首屏速度明显提升。
CSSOM 树的结构大概是:
CSSOM
└── body
├── div
│ └── font-size: 16px
│ └── color: black
└── p
└── font-size: 14px
└── color: gray
每个节点都存着最终计算后的样式值。注意,这里已经处理了继承和层叠。
2.3 第三步:Render 树构建
DOM 树 + CSSOM 树 = Render 树。这一步把两者合并,只保留可见的节点。
什么叫「可见」?display: none 的节点会被剔除。visibility: hidden 的节点会保留,因为它还占空间。
你想想看,如果一个 <div> 设置了 display: none,它就不会出现在 Render 树里。但 opacity: 0 的节点会保留,因为它仍然参与布局。
Render 树的结构:
RenderTree
└── body
├── div (font-size: 16px, color: black)
│ └── 文本: "Hello"
└── p (font-size: 14px, color: gray)
└── 文本: "World"
注意,<head> 里的节点被移除了,因为它们不显示。
2.4 第四步:布局(Layout)
布局也叫回流(Reflow)。这一步计算每个节点在视口中的精确位置和大小。
浏览器从 Render 树的根节点开始,递归计算每个盒模型的宽、高、x、y 坐标。这个过程非常消耗 CPU。
我举个例子。一个 <div> 宽度设为 50%,浏览器得先知道父容器的宽度,才能算出这个 <div> 的具体像素值。如果父容器也是百分比,就得一层层往上算。
布局完成后,每个节点都有一个精确的「布局框」:
div: { x: 10, y: 20, width: 300, height: 100 }
p: { x: 10, y: 130, width: 300, height: 50 }
2.5 第五步:绘制(Paint)
绘制也叫栅格化。这一步把每个节点的视觉样式(颜色、背景、边框、阴影等)画成像素。
浏览器会把 Render 树分成多个绘制层(Paint Layer)。每个层独立绘制,最后再合成。
绘制是一个填充像素的过程。比如一个 <div> 有红色背景和黑色边框,浏览器就在它的布局框内填充红色像素,再画上黑色边框的像素。
绘制本身很快,但如果节点有复杂的视觉效果(比如圆角、阴影、渐变),绘制时间会显著增加。
transform 和 opacity 实现的动画,就别改 width、height、left、top。前者只触发合成,后者会触发布局+绘制。
2.6 第六步:合成(Composite)
合成是最后一步。浏览器把多个绘制层合并成一张最终图像,显示在屏幕上。
为什么需要合成?因为现代浏览器会把页面分成多个层。比如一个 position: fixed 的导航栏,可能单独占一层。滚动时,导航栏层不动,内容层滚动,这样效率更高。
合成是在 GPU 上完成的,非常快。所以,尽量把动画限制在合成层上,避免触发布局和绘制。
触发合成的属性:transform、opacity、will-change。
will-change。它会让浏览器提前创建合成层,占用 GPU 内存。用得太多,反而会卡。
2.7 完整 CRP 流程图
下面这张 SVG 图,把 6 个步骤串起来了。你可以看到每一步的输入、输出和阻塞关系。
2.8 各步骤性能影响对比
不同操作触发的 CRP 步骤不同,性能开销也不同。下面这张表可以帮你快速判断:
| 操作 | 触发步骤 | 性能开销 | 优化建议 |
|---|---|---|---|
修改 width / height |
布局 + 绘制 + 合成 | 高 | 改用 transform: scale() |
修改 color / background |
绘制 + 合成 | 中 | 减少重绘区域 |
修改 transform / opacity |
仅合成 | 低 | 优先使用 |
修改 display: none |
布局 + 绘制 + 合成 | 高 | 用 visibility: hidden 替代 |
读取 offsetTop / scrollTop |
强制同步布局 | 极高 | 批量读取,避免频繁触发 |
2.9 避坑指南
最后,分享几个我踩过的坑:
- 强制同步布局:在 JS 里先改了样式,紧接着读取布局属性(比如
offsetHeight),浏览器会被迫立即执行一次布局。我曾经在一个循环里这么干,页面直接卡死。 - 频繁修改内联样式:每改一次,都可能触发 CRP。建议用
classList批量切换,或者用 CSS 变量。 - 忽略
will-change的副作用:它确实能提升动画性能,但会占用 GPU 内存。在移动端,滥用它反而会导致卡顿。
记住,CRP 的每一步都是性能优化的切入点。理解了它,你就能在卡顿发生时,快速定位到是哪个环节出了问题。