Appearance
浏览器渲染流程
浏览器将一个 HTML 文件转换为屏幕像素时,会经过一条相对固定的流水线。性能分析通常要回到这条管线中,确认每个阶段的职责、阻塞点和主要成本。
完整管线
text
HTML 字节流
→ 解析(Parser)→ DOM Tree
→ 解析(Parser)→ CSSOM Tree
→ DOM + CSSOM → Render Tree
→ Layout(回流)→ Layout Tree
→ Paint(重绘)→ Paint Layers
→ Composite(合成)→ GPU 纹理 → 屏幕这不是一步完成的串行流程。不同阶段之间可以交错执行。例如,HTML 解析到 <img> 标签时,浏览器可以在继续构建 DOM 的同时发起图片下载。
阶段一:HTML 解析 → DOM Tree
浏览器主线程上的 HTML 解析器(HTMLDocumentParser)将 HTML 字节流转换为 DOM 树。关键机制:
非阻塞加载。 解析到外部资源标签(<img>、<link rel="stylesheet">)时,解析器不暂停——资源加载由网络线程异步处理,DOM 构建继续进行。
Script 阻塞。 默认 <script> 标签会暂停 DOM 解析——浏览器必须执行完脚本才能继续构建 DOM,因为脚本可能通过 document.write 修改正在解析的 HTML。这解释了为什么脚本应该放在 <body> 底部或使用 defer/async。
Preload Scanner(预加载扫描器)。 在 DOM 构建的同时,一个独立的轻量级扫描器会提前扫描 HTML 中后续的资源引用(图片、脚本、样式、字体),提前发起网络请求。这就是为什么即使 <script> 阻塞了 DOM,浏览器也能提前下载脚本后面的图片——预加载扫描器不等待 DOM 构建完成。
| 脚本类型 | 下载时机 | 执行时机 | DOM 解析阻塞 | 执行顺序 |
|---|---|---|---|---|
<script> | 解析到标签时 | 下载完立即执行 | ✅ 阻塞 | 文档序 |
<script defer> | 异步下载 | DOM 解析完成后 | ❌ | 文档序 |
<script async> | 异步下载 | 下载完立即执行 | ❌ | 不保证 |
<script type="module"> | 异步下载 | 类似 defer | ❌ | 文档序 |
常见的工程选择是:业务脚本使用 defer 保证执行顺序,独立第三方脚本使用 async,需要尽早执行的关键内联脚本保留在 <head>。
阶段二:CSS 解析 → CSSOM Tree
CSS 解析生成 CSSOM(CSS Object Model)。与 DOM 构建不同:
CSS 解析不阻塞 DOM 构建,但阻塞 Render Tree 生成。 浏览器不会在 CSSOM 完成前开始渲染——因为 Render Tree 需要合并 DOM + CSSOM。这意味着 CSS 是渲染阻塞资源。
CSS 解析也会阻塞后续脚本执行。 如果 <script> 前有未完成的 <link rel="stylesheet">,脚本执行会被推迟到样式表加载完成——因为脚本可能查询元素的计算样式(getComputedStyle),此时样式必须是最新的。
CSS 解析速度很快。 CSS 语法比 HTML 简单得多(无嵌套递归的任意识别),解析通常是微秒级的。CSS 的渲染阻塞主要来自网络下载时间,不是解析时间。
阶段三:Render Tree(渲染树)
DOM + CSSOM → Render Tree。这一步的核心操作:
- 过滤不可见节点:
<head>、<script>、display: none的元素被排除 - 展开伪元素:
::before、::after被作为独立渲染节点插入 - 计算每个节点的最终样式:通过选择器匹配 + 级联 + 继承
关键点:display: none 的元素不进入 Render Tree(不占用布局空间),但 visibility: hidden 的元素会进入——它占据空间只是不可见。opacity: 0 的元素也在 Render Tree 中——透明度为 0 但元素仍参与布局和事件。
阶段四:Layout(回流/重排)
Layout 计算每个可见元素的几何信息(位置和尺寸)。这是渲染管线中最昂贵的阶段之一。
布局计算的复杂度:父元素尺寸变化 → 子元素尺寸重新计算 → 兄弟元素位置调整。这种连锁效应使得 Layout 的成本与受影响元素数量成正比,而非固定开销。
触发 Layout 的操作(部分列表):
- 修改元素的
width、height、padding、margin、border - 修改
display、position、top、left、right、bottom - 添加/移除 DOM 节点
- 修改
font-size、font-family(影响文本尺寸) - 读取
offsetWidth、offsetHeight、getBoundingClientRect()等——在布局未更新时读取会触发强制同步布局
强制同步布局(Forced Synchronous Layout)是布局性能问题中的高频类型:
js
// ❌ 强制同步布局:在修改样式后立即读取布局属性
element.style.width = '100px'; // 样式失效
const height = element.offsetHeight; // 强制立即 Layout 以获取新值
// ✅ 批量读取再批量写入
const height = element.offsetHeight; // 先读取(使用当前布局)
element.style.width = '100px'; // 再写入(下次 Layout 时生效)每次强制同步布局的耗时在 0.5-5ms(取决于 DOM 大小)。在 1000 次迭代的循环中,这可能是 5 秒 vs 5 毫秒的差异。
阶段五:Paint(重绘)
Paint 将 Render Tree 的视觉样式(颜色、边框、阴影、背景)填充到像素中。
Layout 触发后必然触发 Paint(几何变了 → 像素也变了)。但 Paint 也可以独立触发——修改 color、background-color、box-shadow 等只影响视觉不影响布局的属性。
Paint 的产物是 Paint Layer(绘制图层)。浏览器根据 will-change、3D transform、<video>、<canvas> 等条件将元素提升为独立的合成层——这些元素可以由 GPU 直接在合成线程上处理,避免主线程的 Paint 开销。
阶段六:Composite(合成)
Composite 将 Paint Layers 按正确的 z-index 顺序合并为最终的屏幕图像。这一步在合成线程(Compositor Thread)上完成,不阻塞主线程。
transform 和 opacity 动画的优势:修改这两个属性只触发 Composite,不触发 Layout 和 Paint。GPU 已经在合成层中保留了元素的纹理,变换和透明度调整只需在 GPU 上做矩阵变换和 alpha 混合——成本远低于 Layout。
css
/* ✅ 只触发 Composite——流畅的 60fps 动画 */
.element {
transform: translateX(0);
transition: transform 0.3s;
}
.element:hover {
transform: translateX(100px);
}
/* ❌ 触发 Layout + Paint + Composite——低帧率动画 */
.element {
left: 0;
transition: left 0.3s;
}
.element:hover {
left: 100px;
}渲染阻塞关系总结
| 资源 | 阻塞 DOM 构建 | 阻塞 Render Tree | 阻塞后续脚本 |
|---|---|---|---|
<link rel="stylesheet"> | ❌ | ✅ | ✅ |
<script>(无属性) | ✅ | — | — |
<script defer> | ❌ | — | — |
<script async> | ❌ | — | — |
<img> | ❌ | ❌ | ❌ |
@font-face | ❌ | 部分(取决于 font-display) | ❌ |
生产环境调试
Chrome DevTools → Performance → Record 可以看到完整的渲染管线:
- 蓝色:HTML 解析(Parse HTML)
- 紫色:Layout(紫色块 = 回流事件)
- 绿色:Paint(绿色块 = 重绘事件)
- 灰色:Composite Layers
红色三角标记表示发生了强制同步布局(Forced Reflow),通常应作为优先排查对象。
关键原则:性能问题的根因不在于"某一次 Layout 太慢",而在于"Layout 被触发了太多次"。关注 Layout 事件的数量和频率,而不是单次耗时。
