浏览器渲染流程 (Critical Rendering Path)
浏览器从获取 HTML 到最终在屏幕上显示图像,经历了一系列复杂的过程,通常称为关键渲染路径 (Critical Rendering Path)。
1. 渲染流程概览
主要包含五个步骤:
- HTML 解析:构建 DOM 树。
- CSS 解析:构建 CSSOM 树。
- 构建渲染树 (Render Tree):结合 DOM 和 CSSOM。
- 布局 (Layout/Reflow):计算每个节点在屏幕上的位置和大小。
- 绘制 (Paint):将节点绘制到屏幕上。
- (现代浏览器还有一步:合成 Composite)
mermaid
graph LR
HTML[HTML] --> DOM[DOM 树]
CSS[CSS] --> CSSOM[CSSOM 树]
DOM --> RenderTree[渲染树]
CSSOM --> RenderTree
RenderTree --> Layout[布局 Layout]
Layout --> Paint[绘制 Paint]
Paint --> Composite[合成 Composite]
Composite --> Display[显示]2. 详细步骤解析
2.1 构建 DOM 树 (Constructing the DOM Tree)
- 输入:HTML 字节流。
- 过程:
- 解码:将字节转换成字符。
- 令牌化 (Tokenization):将字符转换成 Token(如
<html>,<body>)。 - 生成节点 (Lexing):将 Token 转换成对象(Node)。
- 构建树:根据嵌套关系建立节点间的父子联系。
- 输出:DOM 树(Document Object Model)。
2.2 构建 CSSOM 树 (Constructing the CSSOM Tree)
- 输入:CSS 样式(外链、内联、样式表)。
- 过程:类似于 DOM 构建,但 CSS 是级联的,浏览器需要计算出每个节点的最终样式(Computed Style)。
- 输出:CSSOM 树(CSS Object Model)。
- 注意:CSS 解析会阻塞渲染(Render Blocking),因为没有样式,渲染出的页面是乱的,浏览器宁愿白屏也不愿渲染无样式的页面。
2.3 构建渲染树 (Render Tree)
- 过程:遍历 DOM 树的根节点。
- 可见性检查:忽略不可见的节点(如
<head>,<script>,display: none的节点)。- 注意:
visibility: hidden的节点会包含在 Render Tree 中,因为它占据空间。
- 注意:
- 匹配样式:对于每个可见节点,找到在 CSSOM 中对应的规则并应用。
- 可见性检查:忽略不可见的节点(如
- 输出:包含视觉信息的渲染树(每个节点也是一个渲染对象 RenderObject)。
2.4 布局 (Layout) / 回流 (Reflow)
- 目的:计算渲染树中每个节点在视口(Viewport)内的确切位置和大小。
- 过程:从根节点开始递归遍历,计算几何信息(位置、尺寸)。
- 输出:每个节点的盒子模型(Box Model)信息。
- 触发回流:当 DOM 变化影响了元素的几何属性(宽、高、位置)或结构时,浏览器需要重新计算布局。
- 添加/删除 DOM 元素。
- 改变窗口大小。
- 修改字体大小。
- 获取某些测量属性(如
offsetWidth、scrollTop)会强制立即回流。
2.5 绘制 (Paint) / 重绘 (Repaint)
- 目的:将渲染树中的每个节点转换成屏幕上的像素。
- 过程:遍历渲染树,调用系统的图形 API 绘制背景、边框、文本、阴影等。
- 触发重绘:当元素外观发生变化但没有改变布局(如
color,background,visibility)时触发。 - 代价:重绘的代价比回流小,但仍然消耗性能。
2.6 合成 (Composite)
- 现代浏览器的优化:为了提升性能,浏览器会将页面分层(Layer)。
- 层 (Layer):拥有独立层级的元素(如
<video>,cancel,position: fixed,z-index,transform 3D)。 - 过程:
- 主线程计算好每层的绘制指令。
- 合成线程(Compositor Thread)接收指令,将层分成图块(Tile)。
- 栅格化线程(Raster Thread)将图块转换成位图。
- GPU 将位图绘制到屏幕。
- 优势:
transform和opacity动画可以直接在合成线程处理,不触发 Layout 和 Paint,性能极高。
3. 回流 (Reflow) vs 重绘 (Repaint)
| 特性 | 回流 (Reflow) | 重绘 (Repaint) |
|---|---|---|
| 定义 | 布局或者几何属性改变,需重新计算位置 | 元素外观改变,不影响布局 |
| 触发条件 | 宽高改变、增删 DOM、窗口大小改变 | 颜色改变、背景改变、visibility |
| 影响范围 | 可能影响整个页面或部分树 | 仅影响当前元素 |
| 性能开销 | 极大 | 较小 |
| 关系 | 回流必将引起重绘 | 重绘不一定引起回流 |
性能优化建议
批量修改 DOM:
- 使用
documentFragment。 - 先
display: none隐藏元素,修改完后再显示(只触发两次回流)。 - 使用
cloneNode在内存中修改后替换。
- 使用
CSS 优化:
- 避免使用复杂的选择器(选择器匹配是从右向左的)。
- 避免设置多层内联样式。
- 优先使用 transform 和 opacity 做动画(只触发合成,不回流不重绘)。
- 将频繁变化的元素提升为独立层(
will-change: transform)。
JS 优化:
- 缓存布局信息(不要在循环中频繁读取
offsetWidth等属性,会导致强制同步布局)。
javascript// ❌ 强制同步布局 (Layout Thrashing) for (let i = 0; i < len; i++) { box.style.width = box.offsetWidth + 1 + 'px'; // 读 -> 写 (反复回流) } // ✅ 读写分离 const width = box.offsetWidth; for (let i = 0; i < len; i++) { box.style.width = width + 1 + 'px'; }- 缓存布局信息(不要在循环中频繁读取
4. 为什么 CSS 放头部,JS 放尾部?
CSS 放头部 (<head>)
- 避免闪烁 (FOUC):如果 CSS 放在底部,浏览器先渲染无样式的 HTML,加载完 CSS 后又重绘一次,用户会看到页面样式突然变化。放在头部可以让浏览器在渲染前就准备好样式。
- CSS 不阻塞 DOM 解析:但会阻塞渲染,所以越早加载越好,以便 CSSOM 尽早构建完成。
JS 放尾部 (<body> 结束前)
- 避免阻塞渲染:JS 执行会阻塞 GUI 渲染线程。如果 JS 放在头部且执行时间长,用户会看到白屏。
- 确保 DOM 已加载:JS 通常用来操作 DOM,放在底部可以保证脚本执行时 DOM 树已经构建完成,避免找不到节点的错误。
- 现代方案:使用
<script defer>或<script async>放在头部。
5. defer 与 async 的区别
html
<script src="script.js"></script>
<script async src="script.js"></script>
<script defer src="script.js"></script>| 模式 | 解析 HTML | 加载 JS | 执行 JS | 执行时机 |
|---|---|---|---|---|
| 普通 | 停止 | 并行下载 | 阻塞加载后立即执行 | 遇到标签时 |
| async | 继续 | 并行下载 | 阻塞下载后立即执行 | 下载完立即执行(乱序) |
| defer | 继续 | 并行下载 | 等待 | HTML 解析完成后,DOMContentLoaded 前(顺序) |
- Async:适用于独立的脚本(如统计分析),不依赖其他脚本,也不被依赖。
- Defer:适用于应用逻辑脚本,保证执行顺序,且不阻塞解析。