Skip to content

浏览器渲染流程

浏览器将一个 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。这一步的核心操作:

  1. 过滤不可见节点<head><script>display: none 的元素被排除
  2. 展开伪元素::before::after 被作为独立渲染节点插入
  3. 计算每个节点的最终样式:通过选择器匹配 + 级联 + 继承

关键点:display: none 的元素不进入 Render Tree(不占用布局空间),但 visibility: hidden 的元素会进入——它占据空间只是不可见。opacity: 0 的元素也在 Render Tree 中——透明度为 0 但元素仍参与布局和事件。


阶段四:Layout(回流/重排)

Layout 计算每个可见元素的几何信息(位置和尺寸)。这是渲染管线中最昂贵的阶段之一。

布局计算的复杂度:父元素尺寸变化 → 子元素尺寸重新计算 → 兄弟元素位置调整。这种连锁效应使得 Layout 的成本与受影响元素数量成正比,而非固定开销。

触发 Layout 的操作(部分列表):

  • 修改元素的 widthheightpaddingmarginborder
  • 修改 displaypositiontopleftrightbottom
  • 添加/移除 DOM 节点
  • 修改 font-sizefont-family(影响文本尺寸)
  • 读取 offsetWidthoffsetHeightgetBoundingClientRect() 等——在布局未更新时读取会触发强制同步布局

强制同步布局(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 也可以独立触发——修改 colorbackground-colorbox-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 事件的数量和频率,而不是单次耗时。