浏览器渲染流水线:从 HTML 到屏幕上的像素

0 阅读12分钟

前言

当用户在浏览器地址栏敲下回车,到页面最终呈现在屏幕上,中间发生了什么?这个问题是前端性能优化的基石。只有理解了浏览器的渲染流水线,我们才能准确判断"为什么这个动画卡了"、"为什么修改这个 CSS 属性会导致整页重排"。

本文将完整拆解浏览器从拿到 HTML 到最终绘制像素的全过程,并重点关注每个阶段的性能特征和优化策略。


渲染流水线全景

浏览器的渲染流水线可以分为以下几个核心阶段:

+--------+     +------+     +-------+     +--------+
|  HTML  | --> | DOM  | -+  | Render| --> | Layout |
| 解析   |     | Tree |  |  | Tree  |     | (回流) |
+--------+     +------+  |  +-------+     +--------+
                          |      ^             |
+--------+     +-------+  |      |             v
|  CSS   | --> | CSSOM | -+      |        +--------+
| 解析   |     | Tree  | -------+        | Paint  |
+--------+     +-------+                 | (绘制) |
                                          +--------+
                                               |
                                               v
                                         +-----------+
                                         | Composite |
                                         | (合成)     |
                                         +-----------+
                                               |
                                               v
                                         +-----------+
                                         |  Screen   |
                                         |  (屏幕)   |
                                         +-----------+

用一条线性流程来表示:

HTML --> DOM --> +            --> Render Tree --> Layout --> Paint --> Composite --> Pixels
                |                    ^
CSS --> CSSOM --+--------------------+

每个阶段都有明确的输入和输出,下面我们逐一拆解。


第一步:DOM 树构建

HTML 解析过程

浏览器接收到 HTML 字节流后,经过以下步骤构建 DOM 树:

字节 (Bytes)
  |
  v
字符 (Characters)
  |    (按指定编码解码, 如 UTF-8)
  v
令牌 (Tokens)
  |    (词法分析: 识别标签、属性、文本)
  v
节点 (Nodes)
  |    (根据令牌创建对应的 DOM 节点)
  v
DOM 树 (DOM Tree)
       (按照标签嵌套关系组装成树形结构)

以一段简单的 HTML 为例:

<!DOCTYPE html>
<html>
<head>
  <title>Demo</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>Hello</h1>
    <p>World</p>
  </div>
</body>
</html>

对应的 DOM 树:

Document
 └── html
      ├── head
      |    ├── title
      |    |    └── "Demo"
      |    └── link[rel="stylesheet"]
      └── body
           └── div.container
                ├── h1
                |    └── "Hello"
                └── p
                     └── "World"

script 标签对解析的阻塞

HTML 解析器在遇到 <script> 标签时的行为至关重要,因为 JavaScript 可能会修改 DOM 结构:

普通 <script>:

HTML 解析 ====>  [暂停]  ==============================> 继续解析 ===>
                   |                                       ^
                   v                                       |
              下载脚本 -----> 执行脚本 --------------------+

解析被完全阻塞,下载和执行都会卡住后续 HTML 的解析。
<script async>:

HTML 解析 ========================> [暂停] =====> 继续解析 ===>
                                      ^    ^
              下载脚本(并行) ----+     |    |
                                |     |    |
                                v     |    |
                           执行脚本 --+----+

下载不阻塞解析,但执行时仍会暂停解析。
执行时机不确定,下载完即执行。
<script defer>:

HTML 解析 ======================================> 解析完成
                                                     |
              下载脚本(并行) ---------+              v
                                     |         执行脚本(按顺序)
                                     |              |
                                     +--------------+

下载不阻塞解析,执行推迟到 HTML 解析完成后、DOMContentLoaded 之前。
多个 defer 脚本按顺序执行。

三者对比:

特性           | <script>  | async     | defer
---------------|-----------|-----------|----------
下载阻塞解析   | 是        | 否        | 否
执行阻塞解析   | 是        | 是        | 否(延迟)
执行顺序保证   | 是        | 否        | 是
执行时机       | 立即      | 下载完    | 解析完后
适用场景       | 内联小脚本| 独立脚本  | 依赖DOM的脚本

实际建议:

<!-- 不依赖 DOM,也不被其他脚本依赖 -->
<script async src="analytics.js"></script>

<!-- 需要操作 DOM,或有依赖顺序 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>

第二步:CSSOM 构建

CSS 是渲染阻塞资源

与 DOM 构建类似,浏览器会把 CSS 解析为 CSSOM(CSS Object Model)。但 CSS 有一个关键特性:CSS 是渲染阻塞的

CSS 解析流程:

CSS 字节 --> 字符 --> 令牌 --> 节点 --> CSSOM 树

为什么 CSS 会阻塞渲染?因为 CSS 有层叠(Cascade)特性,后面的规则可能覆盖前面的。如果在 CSSOM 未完全构建时就进行渲染,用户可能看到一瞬间没有样式的页面(FOUC),然后页面突然"跳变"。浏览器选择等待 CSSOM 构建完成再渲染。

时间线:

HTML 解析:  |=============================>|
CSS 下载:   |======|                        
CSS 解析:          |====|                   
CSSOM 完成:             |                   
                        |-- 渲染阻塞点 --|
                                         |
首次渲染:                                 |==> 开始渲染

注意:CSS 阻塞渲染,但不阻塞 DOM 解析。DOM 解析和 CSSOM 构建可以并行进行。

关键 CSS(Critical CSS)

既然 CSS 阻塞渲染,一个重要的优化策略就是关键 CSS:将首屏渲染所需的最小 CSS 内联到 HTML 中,其余 CSS 异步加载。

<head>
  <!-- 关键 CSS 直接内联,无需额外网络请求 -->
  <style>
    .header { display: flex; height: 60px; }
    .hero { min-height: 400px; background: #f5f5f5; }
    body { margin: 0; font-family: sans-serif; }
  </style>

  <!-- 非关键 CSS 异步加载 -->
  <link rel="preload" href="full.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="full.css"></noscript>
</head>

CSSOM 树的结构示例:

CSSOM Tree:

StyleSheet
 ├── Rule: body
 |    ├── margin: 0
 |    ├── font-family: sans-serif
 |    └── font-size: 16px
 ├── Rule: .container
 |    ├── max-width: 1200px
 |    └── margin: 0 auto
 ├── Rule: h1
 |    ├── font-size: 2em
 |    └── color: #333
 └── Rule: .hidden
      └── display: none

第三步:渲染树(Render Tree)

DOM 树和 CSSOM 树构建完成后,浏览器将它们合并为渲染树。渲染树只包含可见节点

DOM Tree          CSSOM               Render Tree
---------         -----               -----------
html              body{font:14px}
 ├── head    +    h1{color:red}   =   RenderView
 |    └──title    p{margin:10px}       └── RenderBody
 └── body         .hide{display:none}       ├── RenderBlock(div)
      ├── div                               |    ├── RenderInline(h1)
      |    ├──h1                            |    |    └── "Hello"
      |    └──p                             |    └── RenderBlock(p)
      └── span.hide                         |         └── "World"
                                            (span.hide 不在渲染树中)
                                            (head 不在渲染树中)

以下元素不会出现在渲染树中:

排除规则:

1. <head> 及其子元素           --> 非可视元素
2. display: none 的元素        --> 不占空间,不渲染
3. <script>, <meta>, <link>   --> 非可视元素

注意区分:
- display: none    --> 不在渲染树中,不占空间
- visibility: hidden --> 在渲染树中,占空间,只是不可见
- opacity: 0       --> 在渲染树中,占空间,完全透明

第四步:布局(Layout / Reflow)

渲染树构建完成后,浏览器需要计算每个节点的精确位置和大小,这个过程叫做布局(Layout),也叫回流(Reflow)。

布局阶段的计算:

+--viewport(1920px)---------------------------+
|                                              |
|  +--body(margin:8px)----------------------+  |
|  |                                        |  |
|  |  +--div.container(width:80%)--------+  |  |
|  |  |  width = 1920 * 0.8 = 1536px     |  |  |
|  |  |                                  |  |  |
|  |  |  +--h1(font-size:2em)---------+  |  |  |
|  |  |  | width: 1536px              |  |  |  |
|  |  |  | height: 根据内容计算        |  |  |  |
|  |  |  | x: 8, y: 8                 |  |  |  |
|  |  |  +----------------------------+  |  |  |
|  |  |                                  |  |  |
|  |  |  +--p(margin:10px)------------+  |  |  |
|  |  |  | width: 1536px - 20px       |  |  |  |
|  |  |  | x: 18, y: (h1底部+10)     |  |  |  |
|  |  |  +----------------------------+  |  |  |
|  |  +----------------------------------+  |  |
|  +----------------------------------------+  |
+----------------------------------------------+

什么会触发回流

回流的代价很高,因为浏览器需要重新计算布局。以下操作会触发回流:

触发回流的常见操作:

几何属性变化:
  width, height, padding, margin, border
  top, left, right, bottom (position元素)
  font-size, line-height
  min-height, max-width 等

DOM 结构变化:
  添加/删除可见元素
  元素内容变化(文本改变, 图片加载完成)
  
窗口变化:
  resize 事件
  滚动条出现/消失

读取布局信息(强制同步布局):
  offsetTop/Left/Width/Height
  scrollTop/Left/Width/Height
  clientTop/Left/Width/Height
  getComputedStyle()
  getBoundingClientRect()

浏览器的布局批处理

浏览器会尽量将多次 DOM 修改合并为一次回流:

// 浏览器会合并这三次修改,只触发一次回流
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

// 但如果中间读取了布局信息,就会强制触发回流!
element.style.width = '100px';
let h = element.offsetHeight;  // 强制回流!浏览器必须先计算
element.style.height = '200px'; // 再次标记需要回流

这就是所谓的"强制同步布局"(Forced Synchronous Layout),又叫"布局抖动"(Layout Thrashing):

// 反面示例:布局抖动
// 每次循环都触发一次强制回流,N个元素就是N次回流
for (let i = 0; i < elements.length; i++) {
  elements[i].style.width = box.offsetWidth + 'px'; // 读+写交替
}

// 正确做法:先批量读,再批量写
const width = box.offsetWidth; // 只读一次
for (let i = 0; i < elements.length; i++) {
  elements[i].style.width = width + 'px'; // 只写,浏览器自动合并
}

第五步:绘制(Paint)

布局完成后,浏览器知道了每个节点的精确位置和大小。接下来需要将它们绘制成实际的像素,这个过程叫做绘制(Paint)。

绘制顺序

浏览器按照固定的顺序绘制各种属性:

绘制顺序 (由后到前):

1. background-color
2. background-image
3. border
4. children (子元素)
5. outline

一个元素的完整绘制:

  +---border-------------------+
  | +---background-image-----+ |
  | | +---background-color-+ | |
  | | |                    | | |
  | | |    文本/子元素      | | |
  | | |                    | | |
  | | +--------------------+ | |
  | +------------------------+ |
  +---outline------------------+

绘制层(Paint Layers)

浏览器不会把所有内容绘制到同一个图层,而是会创建多个绘制层:

图层堆叠示意:

Layer 3 (最上层)   +---popup---+
                   |  弹窗内容  |
                   +-----------+

Layer 2            +---fixed-header--------+
                   | 固定导航栏             |
                   +-----------------------+

Layer 1            +---main-content--------+
                   | 页面主体内容           |
                   | ......                |
                   | ......                |
                   +-----------------------+

Layer 0 (底层)     +---background----------+
                   | 页面背景               |
                   +-----------------------+

以下情况会创建新的图层:

创建新图层的条件:

- position: fixed / sticky
- will-change: transform / opacity
- transform: translateZ(0) / translate3d(...)
- opacity 值小于 1 (在某些情况下)
- <video>, <canvas>, <iframe>
- CSS filter
- 有 z-index 的定位元素在合成层之上

什么会触发重绘

触发重绘但不触发回流的属性:

  color, background-color, background-image
  border-color, border-radius
  outline, outline-color
  visibility
  box-shadow
  text-decoration

关系:
  回流一定会导致重绘
  重绘不一定需要回流

第六步:合成(Composite)

合成是渲染流水线的最后一步。浏览器将各个图层的绘制结果组合在一起,最终输出到屏幕。

合成过程:

Paint Layer 0 ──+
                |
Paint Layer 1 ──+──> GPU 合成 ──> 帧缓冲区 ──> 屏幕
                |
Paint Layer 2 ──+

GPU 合成层

当某个元素的变化只需要合成阶段处理时,性能是最好的。浏览器可以直接在 GPU 上操作图层,不需要重新布局和绘制。

仅触发合成的 CSS 属性:

  transform   (位移、旋转、缩放)
  opacity     (透明度)

这两个属性的变化可以完全在 GPU 上完成,
不需要主线程参与, 不会触发回流和重绘。

will-change 提示

/* 告诉浏览器:这个元素即将发生 transform 变化 */
.animated-element {
  will-change: transform;
}

/* 动画结束后移除,释放 GPU 内存 */
.animated-element.idle {
  will-change: auto;
}

注意事项:

will-change 使用原则:

  [正确] 在动画即将开始前设置
  [正确] 动画结束后移除
  [正确] 只对确实会变化的属性使用
  
  [错误] 对所有元素设置 will-change
  [错误] 设置后永不移除
  [错误] 在 CSS 中静态设置大量 will-change
  
  原因: 每个 will-change 都会创建新的合成层,
        消耗额外的 GPU 内存。滥用反而降低性能。

实际性能影响

为什么 transform 比 top/left 快

这是面试和实际优化中的高频问题。答案就在渲染流水线中:

使用 top/left 实现动画:

  样式变化 --> Layout --> Paint --> Composite
                 |          |
                 v          v
              重新计算    重新绘制
              所有元素    受影响区域
              的位置

  - 每帧都触发 Layout (回流)
  - 每帧都触发 Paint (重绘)
  - 在主线程执行, 可能被 JS 阻塞
  - 帧率容易不稳定
使用 transform 实现动画:

  样式变化 --> Composite
                  |
                  v
              GPU 直接移动
              该元素的图层

  - 跳过 Layout
  - 跳过 Paint
  - 在合成器线程执行, 不被 JS 阻塞
  - 稳定 60fps

代码对比:

/* 性能差: 触发 Layout + Paint + Composite */
.box-slow {
  position: absolute;
  transition: top 0.3s, left 0.3s;
}
.box-slow:hover {
  top: 100px;
  left: 100px;
}

/* 性能好: 只触发 Composite */
.box-fast {
  transition: transform 0.3s;
}
.box-fast:hover {
  transform: translate(100px, 100px);
}

为什么 opacity 动画很高效

opacity 变化的处理:

  修改 opacity --> GPU 直接调整图层透明度 --> 合成输出

  - 不需要重新计算布局 (元素大小位置不变)
  - 不需要重新绘制 (图层内容不变, 只是透明度变了)
  - GPU 原生支持透明度混合, 几乎零开销

淡入淡出的推荐写法:

/* 推荐: opacity 动画 */
.fade-enter {
  opacity: 0;
  transition: opacity 0.3s ease;
}
.fade-enter.active {
  opacity: 1;
}

/* 不推荐: 用 visibility 或 display 切换 */
/* visibility 不支持过渡效果 */
/* display 会触发回流 */

Chrome DevTools 实战

Layers 面板

Chrome DevTools 提供了 Layers 面板,可以直观查看页面的图层信息。

打开方式:

  1. F12 打开 DevTools
  2. Ctrl+Shift+P 打开命令面板
  3. 输入 "Show Layers"
  4. 回车打开 Layers 面板

Layers 面板信息:

  +--Layers Panel------------------------------+
  |                                            |
  |  [3D视图]        [图层详情]                 |
  |                                            |
  |  +---------+    Layer: .modal-overlay       |
  |  |Layer 3  |    Size: 1920 x 1080           |
  |  |  +------+    Memory: 8.1 MB              |
  |  |  |L2    |    Compositing Reasons:         |
  |  +--+------+      - will-change: transform  |
  |  |  Layer 1|    Paint count: 1              |
  |  +---------+                                |
  |                                            |
  +--------------------------------------------+

重点关注:

  • Compositing Reasons:为什么创建了合成层
  • Memory:图层占用的内存
  • Paint count:重绘次数

Paint Profiler

打开方式:

  1. Performance 面板 --> 勾选 "Enable advanced paint instrumentation"
  2. 录制性能数据
  3. 点击 Paint 事件 --> 查看 Paint Profiler

Paint Profiler 信息:

  +--Paint Profiler----------------------------+
  |                                            |
  |  Paint 操作列表:                            |
  |  +-----------------------------------------+
  |  | drawRect    (0, 0, 1920, 60)    0.02ms  |
  |  | drawText    "Navigation"        0.05ms  |
  |  | drawImage   logo.png            0.08ms  |
  |  | clipRect    (0, 60, 1920, 940)  0.01ms  |
  |  | drawRect    (20, 80, 400, 300)  0.03ms  |
  |  +-----------------------------------------+
  |                                            |
  |  总耗时: 0.19ms                             |
  +--------------------------------------------+

Performance 面板中的渲染指标

Performance 录制结果:

  Main Thread:
  |-Parse HTML------|
  |    |-Parse CSS--|
  |    |-Evaluate Script---------|
  |                              |-Recalc Style--|
  |                                    |-Layout--|
  |                                         |-Paint--|
  |                                              |-Composite--|

  关注指标:
  - Recalculate Style: 样式重计算耗时
  - Layout: 回流耗时
  - Paint: 绘制耗时
  - Composite Layers: 合成耗时

  理想情况: 每帧总耗时 < 16.67ms (60fps)

使用 Rendering 面板实时调试:

Rendering 面板 (Ctrl+Shift+P --> "Show Rendering"):

  [x] Paint flashing        --> 高亮重绘区域(绿色闪烁)
  [x] Layout Shift Regions  --> 高亮布局偏移区域
  [x] Layer borders         --> 显示合成层边框
  [ ] FPS meter             --> 显示实时帧率
  [ ] Scrolling perf issues --> 标记滚动性能问题

  Paint flashing 是排查不必要重绘最直接的工具:
  如果滚动时整个页面都在绿色闪烁, 说明有性能问题。

CSS 属性与流水线阶段对照表

不同的 CSS 属性变化会触发不同的流水线阶段,直接影响性能:

+----------------------+--------+-------+-----------+
| CSS 属性             | Layout | Paint | Composite |
+----------------------+--------+-------+-----------+
| width, height        |   是   |  是   |    是     |
| padding, margin      |   是   |  是   |    是     |
| top, left, right,    |   是   |  是   |    是     |
| bottom               |        |       |           |
| font-size            |   是   |  是   |    是     |
| display              |   是   |  是   |    是     |
| border-width         |   是   |  是   |    是     |
| position             |   是   |  是   |    是     |
| float                |   是   |  是   |    是     |
+----------------------+--------+-------+-----------+
| color                |   --   |  是   |    是     |
| background-color     |   --   |  是   |    是     |
| background-image     |   --   |  是   |    是     |
| border-color         |   --   |  是   |    是     |
| border-radius        |   --   |  是   |    是     |
| border-style         |   --   |  是   |    是     |
| box-shadow           |   --   |  是   |    是     |
| outline              |   --   |  是   |    是     |
| visibility           |   --   |  是   |    是     |
| text-decoration      |   --   |  是   |    是     |
+----------------------+--------+-------+-----------+
| transform            |   --   |  --   |    是     |
| opacity              |   --   |  --   |    是     |
+----------------------+--------+-------+-----------+

性能从上到下递增:
  Layout + Paint + Composite  (最慢)
  Paint + Composite           (中等)
  Composite                   (最快)

用一张图总结优化决策:

需要做动画?
  |
  +--位移效果 --> 用 transform: translate() 代替 top/left
  |
  +--缩放效果 --> 用 transform: scale() 代替 width/height
  |
  +--旋转效果 --> 用 transform: rotate()
  |
  +--淡入淡出 --> 用 opacity
  |
  +--颜色变化 --> 不可避免触发 Paint, 尽量缩小影响范围
  |
  +--尺寸变化 --> 不可避免触发 Layout, 考虑用 transform 模拟

完整流水线回顾

  +------+    +------+
  | HTML | -> | DOM  |--+
  +------+    +------+  |    +---------+    +--------+    +-------+    +-----------+    +--------+
                         +--> | Render  | -> | Layout | -> | Paint | -> | Composite | -> | Screen |
  +-----+    +-------+  |    | Tree    |    | 回流   |    | 绘制  |    | 合成      |    | 像素   |
  | CSS | -> | CSSOM |--+    +---------+    +--------+    +-------+    +-----------+    +--------+
  +-----+    +-------+
                               DOM+CSSOM      计算         转为像素     图层合成        显示
                               合并为         几何信息     (分层绘制)   (GPU加速)
                               可见节点树     (位置,大小)

  性能优化的核心思路:
  +-----------------------------------------------------------------+
  | 尽量让变化发生在流水线的后端 (Composite)                         |
  | 避免变化波及流水线的前端 (Layout)                                |
  | 减少受影响的节点数量                                             |
  +-----------------------------------------------------------------+

总结

浏览器渲染流水线的每个阶段都有明确的输入输出和性能特征:

  1. DOM 构建:HTML 解析为 DOM 树,script 标签会阻塞解析,优先使用 defer/async。
  2. CSSOM 构建:CSS 阻塞渲染但不阻塞 DOM 解析,关键 CSS 应内联以加速首次渲染。
  3. 渲染树:DOM + CSSOM 合并,排除不可见元素(display:none、head 等)。
  4. 布局:计算精确几何信息,代价高昂,避免强制同步布局和布局抖动。
  5. 绘制:按照固定顺序绘制各层,注意绘制层的数量和范围。
  6. 合成:GPU 加速的最后一步,transform 和 opacity 变化只需此阶段。

性能优化的核心原则:让变化尽可能少地触发流水线前面的阶段。能用 transform/opacity 完成的效果,就不要用会触发 Layout 的属性。掌握了这条流水线,你就能准确定位任何渲染性能问题的根因。

如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。