2. 浏览器渲染流水线:深入解析关键渲染路径(CRP)的6个步骤

关键渲染路径,英文叫 Critical Rendering Path,简称 CRP。说白了,就是浏览器从拿到 HTML 字节流,到最终在屏幕上画出像素的整个过程。

我刚开始做性能优化时,总觉得这玩意儿太底层,跟业务代码关系不大。直到有一次,一个简单的列表页在低端机上卡得飞起,我查了半天发现是 CSS 选择器太复杂导致重绘频繁。嗯,从那以后,我再也不敢小看 CRP 了。

CRP 一共 6 个步骤,环环相扣。任何一个环节出问题,都会直接导致刷新卡顿。我们一个一个来看。

核心观点:CRP 的每一步都可能成为性能瓶颈。理解它,你才能精准定位卡顿的根因。

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> 的具体像素值。如果父容器也是百分比,就得一层层往上算。

性能关键点:布局的复杂度取决于 DOM 节点数量和嵌套层级。节点越多,布局越慢。

布局完成后,每个节点都有一个精确的「布局框」:

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 个步骤串起来了。你可以看到每一步的输入、输出和阻塞关系。

关键渲染路径(CRP)流程图 关键渲染路径(CRP)6 步骤 1. DOM 树 2. CSSOM 树 3. Render 树 4. 布局 5. 绘制 6. 合成 输入: HTML 字节流 输入: CSS 字节流 输出: 像素图像 阻塞: CSSOM 构建阻塞渲染 JS 阻塞 DOM 解析

2.8 各步骤性能影响对比

不同操作触发的 CRP 步骤不同,性能开销也不同。下面这张表可以帮你快速判断:

操作 触发步骤 性能开销 优化建议
修改 width / height 布局 + 绘制 + 合成 高 改用 transform: scale()
修改 color / background 绘制 + 合成 中 减少重绘区域
修改 transform / opacity 仅合成 低 优先使用
修改 display: none 布局 + 绘制 + 合成 高 用 visibility: hidden 替代
读取 offsetTop / scrollTop 强制同步布局 极高 批量读取,避免频繁触发
我的经验:在项目中,我经常用 Chrome DevTools 的 Performance 面板录制一段操作,然后看「Layout」和「Paint」事件耗时。如果 Layout 耗时超过 50ms,就该优化了。

2.9 避坑指南

最后,分享几个我踩过的坑:

  • 强制同步布局:在 JS 里先改了样式,紧接着读取布局属性(比如 offsetHeight),浏览器会被迫立即执行一次布局。我曾经在一个循环里这么干,页面直接卡死。
  • 频繁修改内联样式:每改一次,都可能触发 CRP。建议用 classList 批量切换,或者用 CSS 变量。
  • 忽略 will-change 的副作用:它确实能提升动画性能,但会占用 GPU 内存。在移动端,滥用它反而会导致卡顿。

记住,CRP 的每一步都是性能优化的切入点。理解了它,你就能在卡顿发生时,快速定位到是哪个环节出了问题。