前言
当用户在浏览器地址栏敲下回车,到页面最终呈现在屏幕上,中间发生了什么?这个问题是前端性能优化的基石。只有理解了浏览器的渲染流水线,我们才能准确判断"为什么这个动画卡了"、"为什么修改这个 CSS 属性会导致整页重排"。
本文将完整拆解浏览器从拿到 HTML 到最终绘制像素的全过程,并重点关注每个阶段的性能特征和优化策略。
渲染流水线全景
浏览器的渲染流水线可以分为以下几个核心阶段:
lua
+--------+ +------+ +-------+ +--------+
| HTML | --> | DOM | -+ | Render| --> | Layout |
| 解析 | | Tree | | | Tree | | (回流) |
+--------+ +------+ | +-------+ +--------+
| ^ |
+--------+ +-------+ | | v
| CSS | --> | CSSOM | -+ | +--------+
| 解析 | | Tree | -------+ | Paint |
+--------+ +-------+ | (绘制) |
+--------+
|
v
+-----------+
| Composite |
| (合成) |
+-----------+
|
v
+-----------+
| Screen |
| (屏幕) |
+-----------+
用一条线性流程来表示:
rust
HTML --> DOM --> + --> Render Tree --> Layout --> Paint --> Composite --> Pixels
| ^
CSS --> CSSOM --+--------------------+
每个阶段都有明确的输入和输出,下面我们逐一拆解。
第一步:DOM 树构建
HTML 解析过程
浏览器接收到 HTML 字节流后,经过以下步骤构建 DOM 树:
scss
字节 (Bytes)
|
v
字符 (Characters)
| (按指定编码解码, 如 UTF-8)
v
令牌 (Tokens)
| (词法分析: 识别标签、属性、文本)
v
节点 (Nodes)
| (根据令牌创建对应的 DOM 节点)
v
DOM 树 (DOM Tree)
(按照标签嵌套关系组装成树形结构)
以一段简单的 HTML 为例:
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 树:
css
Document
└── html
├── head
| ├── title
| | └── "Demo"
| └── link[rel="stylesheet"]
└── body
└── div.container
├── h1
| └── "Hello"
└── p
└── "World"
script 标签对解析的阻塞
HTML 解析器在遇到 <script> 标签时的行为至关重要,因为 JavaScript 可能会修改 DOM 结构:
less
普通 <script>:
HTML 解析 ====> [暂停] ==============================> 继续解析 ===>
| ^
v |
下载脚本 -----> 执行脚本 --------------------+
解析被完全阻塞,下载和执行都会卡住后续 HTML 的解析。
dart
<script async>:
HTML 解析 ========================> [暂停] =====> 继续解析 ===>
^ ^
下载脚本(并行) ----+ | |
| | |
v | |
执行脚本 --+----+
下载不阻塞解析,但执行时仍会暂停解析。
执行时机不确定,下载完即执行。
go
<script defer>:
HTML 解析 ======================================> 解析完成
|
下载脚本(并行) ---------+ v
| 执行脚本(按顺序)
| |
+--------------+
下载不阻塞解析,执行推迟到 HTML 解析完成后、DOMContentLoaded 之前。
多个 defer 脚本按顺序执行。
三者对比:
xml
特性 | <script> | async | defer
---------------|-----------|-----------|----------
下载阻塞解析 | 是 | 否 | 否
执行阻塞解析 | 是 | 是 | 否(延迟)
执行顺序保证 | 是 | 否 | 是
执行时机 | 立即 | 下载完 | 解析完后
适用场景 | 内联小脚本| 独立脚本 | 依赖DOM的脚本
实际建议:
html
<!-- 不依赖 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 是渲染阻塞的。
rust
CSS 解析流程:
CSS 字节 --> 字符 --> 令牌 --> 节点 --> CSSOM 树
为什么 CSS 会阻塞渲染?因为 CSS 有层叠(Cascade)特性,后面的规则可能覆盖前面的。如果在 CSSOM 未完全构建时就进行渲染,用户可能看到一瞬间没有样式的页面(FOUC),然后页面突然"跳变"。浏览器选择等待 CSSOM 构建完成再渲染。
diff
时间线:
HTML 解析: |=============================>|
CSS 下载: |======|
CSS 解析: |====|
CSSOM 完成: |
|-- 渲染阻塞点 --|
|
首次渲染: |==> 开始渲染
注意:CSS 阻塞渲染,但不阻塞 DOM 解析。DOM 解析和 CSSOM 构建可以并行进行。
关键 CSS(Critical CSS)
既然 CSS 阻塞渲染,一个重要的优化策略就是关键 CSS:将首屏渲染所需的最小 CSS 内联到 HTML 中,其余 CSS 异步加载。
html
<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 树的结构示例:
yaml
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 树构建完成后,浏览器将它们合并为渲染树。渲染树只包含可见节点。
css
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 不在渲染树中)
以下元素不会出现在渲染树中:
sql
排除规则:
1. <head> 及其子元素 --> 非可视元素
2. display: none 的元素 --> 不占空间,不渲染
3. <script>, <meta>, <link> --> 非可视元素
注意区分:
- display: none --> 不在渲染树中,不占空间
- visibility: hidden --> 在渲染树中,占空间,只是不可见
- opacity: 0 --> 在渲染树中,占空间,完全透明
第四步:布局(Layout / Reflow)
渲染树构建完成后,浏览器需要计算每个节点的精确位置和大小,这个过程叫做布局(Layout),也叫回流(Reflow)。
lua
布局阶段的计算:
+--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) | | | |
| | | +----------------------------+ | | |
| | +----------------------------------+ | |
| +----------------------------------------+ |
+----------------------------------------------+
什么会触发回流
回流的代价很高,因为浏览器需要重新计算布局。以下操作会触发回流:
arduino
触发回流的常见操作:
几何属性变化:
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 修改合并为一次回流:
javascript
// 浏览器会合并这三次修改,只触发一次回流
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):
javascript
// 反面示例:布局抖动
// 每次循环都触发一次强制回流,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)。
绘制顺序
浏览器按照固定的顺序绘制各种属性:
arduino
绘制顺序 (由后到前):
1. background-color
2. background-image
3. border
4. children (子元素)
5. outline
一个元素的完整绘制:
+---border-------------------+
| +---background-image-----+ |
| | +---background-color-+ | |
| | | | | |
| | | 文本/子元素 | | |
| | | | | |
| | +--------------------+ | |
| +------------------------+ |
+---outline------------------+
绘制层(Paint Layers)
浏览器不会把所有内容绘制到同一个图层,而是会创建多个绘制层:
lua
图层堆叠示意:
Layer 3 (最上层) +---popup---+
| 弹窗内容 |
+-----------+
Layer 2 +---fixed-header--------+
| 固定导航栏 |
+-----------------------+
Layer 1 +---main-content--------+
| 页面主体内容 |
| ...... |
| ...... |
+-----------------------+
Layer 0 (底层) +---background----------+
| 页面背景 |
+-----------------------+
以下情况会创建新的图层:
diff
创建新图层的条件:
- position: fixed / sticky
- will-change: transform / opacity
- transform: translateZ(0) / translate3d(...)
- opacity 值小于 1 (在某些情况下)
- <video>, <canvas>, <iframe>
- CSS filter
- 有 z-index 的定位元素在合成层之上
什么会触发重绘
arduino
触发重绘但不触发回流的属性:
color, background-color, background-image
border-color, border-radius
outline, outline-color
visibility
box-shadow
text-decoration
关系:
回流一定会导致重绘
重绘不一定需要回流
第六步:合成(Composite)
合成是渲染流水线的最后一步。浏览器将各个图层的绘制结果组合在一起,最终输出到屏幕。
makefile
合成过程:
Paint Layer 0 ──+
|
Paint Layer 1 ──+──> GPU 合成 ──> 帧缓冲区 ──> 屏幕
|
Paint Layer 2 ──+
GPU 合成层
当某个元素的变化只需要合成阶段处理时,性能是最好的。浏览器可以直接在 GPU 上操作图层,不需要重新布局和绘制。
仅触发合成的 CSS 属性:
transform (位移、旋转、缩放)
opacity (透明度)
这两个属性的变化可以完全在 GPU 上完成,
不需要主线程参与, 不会触发回流和重绘。
will-change 提示
css
/* 告诉浏览器:这个元素即将发生 transform 变化 */
.animated-element {
will-change: transform;
}
/* 动画结束后移除,释放 GPU 内存 */
.animated-element.idle {
will-change: auto;
}
注意事项:
ini
will-change 使用原则:
[正确] 在动画即将开始前设置
[正确] 动画结束后移除
[正确] 只对确实会变化的属性使用
[错误] 对所有元素设置 will-change
[错误] 设置后永不移除
[错误] 在 CSS 中静态设置大量 will-change
原因: 每个 will-change 都会创建新的合成层,
消耗额外的 GPU 内存。滥用反而降低性能。
实际性能影响
为什么 transform 比 top/left 快
这是面试和实际优化中的高频问题。答案就在渲染流水线中:
markdown
使用 top/left 实现动画:
样式变化 --> Layout --> Paint --> Composite
| |
v v
重新计算 重新绘制
所有元素 受影响区域
的位置
- 每帧都触发 Layout (回流)
- 每帧都触发 Paint (重绘)
- 在主线程执行, 可能被 JS 阻塞
- 帧率容易不稳定
markdown
使用 transform 实现动画:
样式变化 --> Composite
|
v
GPU 直接移动
该元素的图层
- 跳过 Layout
- 跳过 Paint
- 在合成器线程执行, 不被 JS 阻塞
- 稳定 60fps
代码对比:
css
/* 性能差: 触发 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 动画很高效
markdown
opacity 变化的处理:
修改 opacity --> GPU 直接调整图层透明度 --> 合成输出
- 不需要重新计算布局 (元素大小位置不变)
- 不需要重新绘制 (图层内容不变, 只是透明度变了)
- GPU 原生支持透明度混合, 几乎零开销
淡入淡出的推荐写法:
css
/* 推荐: 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 面板,可以直观查看页面的图层信息。
lua
打开方式:
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
lua
打开方式:
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 面板中的渲染指标
lua
Performance 录制结果:
Main Thread:
|-Parse HTML------|
| |-Parse CSS--|
| |-Evaluate Script---------|
| |-Recalc Style--|
| |-Layout--|
| |-Paint--|
| |-Composite--|
关注指标:
- Recalculate Style: 样式重计算耗时
- Layout: 回流耗时
- Paint: 绘制耗时
- Composite Layers: 合成耗时
理想情况: 每帧总耗时 < 16.67ms (60fps)
使用 Rendering 面板实时调试:
rust
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
+----------------------+--------+-------+-----------+
| 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 (最快)
用一张图总结优化决策:
lua
需要做动画?
|
+--位移效果 --> 用 transform: translate() 代替 top/left
|
+--缩放效果 --> 用 transform: scale() 代替 width/height
|
+--旋转效果 --> 用 transform: rotate()
|
+--淡入淡出 --> 用 opacity
|
+--颜色变化 --> 不可避免触发 Paint, 尽量缩小影响范围
|
+--尺寸变化 --> 不可避免触发 Layout, 考虑用 transform 模拟
完整流水线回顾
lua
+------+ +------+
| HTML | -> | DOM |--+
+------+ +------+ | +---------+ +--------+ +-------+ +-----------+ +--------+
+--> | Render | -> | Layout | -> | Paint | -> | Composite | -> | Screen |
+-----+ +-------+ | | Tree | | 回流 | | 绘制 | | 合成 | | 像素 |
| CSS | -> | CSSOM |--+ +---------+ +--------+ +-------+ +-----------+ +--------+
+-----+ +-------+
DOM+CSSOM 计算 转为像素 图层合成 显示
合并为 几何信息 (分层绘制) (GPU加速)
可见节点树 (位置,大小)
性能优化的核心思路:
+-----------------------------------------------------------------+
| 尽量让变化发生在流水线的后端 (Composite) |
| 避免变化波及流水线的前端 (Layout) |
| 减少受影响的节点数量 |
+-----------------------------------------------------------------+
总结
浏览器渲染流水线的每个阶段都有明确的输入输出和性能特征:
- DOM 构建:HTML 解析为 DOM 树,script 标签会阻塞解析,优先使用 defer/async。
- CSSOM 构建:CSS 阻塞渲染但不阻塞 DOM 解析,关键 CSS 应内联以加速首次渲染。
- 渲染树:DOM + CSSOM 合并,排除不可见元素(display:none、head 等)。
- 布局:计算精确几何信息,代价高昂,避免强制同步布局和布局抖动。
- 绘制:按照固定顺序绘制各层,注意绘制层的数量和范围。
- 合成:GPU 加速的最后一步,transform 和 opacity 变化只需此阶段。
性能优化的核心原则:让变化尽可能少地触发流水线前面的阶段。能用 transform/opacity 完成的效果,就不要用会触发 Layout 的属性。掌握了这条流水线,你就能准确定位任何渲染性能问题的根因。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。