浏览器接收到一份 HTML 文档后,要在同一帧的时间预算内完成从字节到像素的转换。这个过程被拆成若干职责明确的阶段,每个阶段的输入是上一个阶段的输出。理解这条链路,是分析页面性能问题的前提。
从字节到字符
网络层交出的是字节流,而分词器需要的是字符流,因此首先要确定字符编码。浏览器按以下顺序推断:
- 响应头
Content-Type中的charset参数 - 字节流开头的 BOM 标记
- HTML 文档前 1024 字节内出现的
<meta charset> - 以上均缺失时,回退到文档的默认编码或进行探测
第 3 项之所以限制在前 1024 字节,是为了让浏览器在读到足够信息后尽快确定编码,避免把整份文档按错误编码解析后再重新解码。这也是 <meta charset> 必须写在 <head> 靠前位置的原因。
分词与建树
确定编码后,HTML 解析器以流式方式消费字符,边读边产出 DOM 节点,而不是等整份文档下载完成。解析过程包含两个协作的部分:
- 分词器:按 HTML 词法规则把字符切分为起始标签、属性、文本、注释、结束标签等记号
- 树构建器:按插入模式把记号组装成 DOM 树
HTML 的容错规则由规范明确定义,因此即使标签未闭合或嵌套错误,解析器也会给出确定的结果。例如 <p> 在遇到新的块级元素时会自动闭合,<table> 内部的裸文本会被移到表格之外。
<!-- 输入:标签未闭合 -->
<p>段落一
<p>段落二
<!-- 解析结果:两个平级的段落 -->
CSSOM 构建与样式计算
CSS 的处理相对直接:解析样式表文本,生成规则列表,构成 CSSOM。与 DOM 不同的是,选择器匹配需要遍历整棵树,因此 CSSOM 在结构上更接近于「规则 → 声明」的索引,而非与 DOM 一一对应的树。
样式计算阶段为每个元素确定实际生效的声明,依次处理:
- 层叠:按来源(用户代理、作者、用户)、重要性(
!important)与特异性排序 - 继承:对可继承属性,从父元素取值
- 默认值:未命中的属性取初始值或继承值
计算后的值会与 DOM 节点关联,形成渲染树的基础。注意 display: none 的元素不会进入渲染树,而 visibility: hidden 的元素会——前者不产生盒,后者产生盒但不绘制。
布局
布局(Layout,也称作一次回流)把渲染树转换为带有几何信息的盒模型树。输入是元素的样式与内容,输出是每个盒的位置与尺寸。
布局的核心概念是格式化上下文。块级格式化上下文中,盒沿垂直方向依次排列;行内格式化上下文中,内容沿行方向排布并在必要时折行。绝对定位、浮动、弹性布局与网格布局各自建立不同的上下文规则。
| 阶段 | 输入 | 输出 |
|---|---|---|
| 样式计算 | DOM 树、CSSOM | 带计算样式的渲染树 |
| 布局 | 渲染树 | 带几何信息的盒树 |
| 绘制 | 盒树 | 绘制指令列表 |
| 合成 | 绘制指令、图层 | 屏幕像素 |
由于布局的结果依赖于元素的尺寸与位置,任何影响几何的变更都会使已完成的布局失效。
绘制与合成
绘制阶段不直接操作像素,而是生成一份有序的绘制指令列表,记录「在什么位置画什么」。指令列表按层叠上下文分层组织,同一层内的指令顺序决定了覆盖关系。
合成阶段把图层交给合成线程,由它栅格化为纹理并交给 GPU 合成画面。把页面划分为独立图层的意义在于:当某一图层的内容变化时,只需重新栅格化该图层,不必重做前序阶段。
阻塞关系
渲染管线的几个阶段之间存在明确的阻塞规则:
- CSS 阻塞渲染:样式表未加载完成时,样式计算无法进行,因此页面不会首次绘制。这是为了避免「无样式内容闪烁」。
- 同步脚本阻塞解析:
<script>无defer或async时,解析器必须暂停并立即执行脚本。因为脚本可能通过document.write改变文档结构,解析器无法预判。 - 脚本等待样式:脚本可能读取元素的布局信息,因此在脚本执行前,浏览器会等待其之前的所有样式表加载并完成样式计算。
<!-- 解析被暂停,脚本执行完成后才继续 -->
<script src="/heavy.js"></script>
<!-- 不阻塞解析,在文档解析完成后按顺序执行 -->
<script src="/app.js" defer></script>
重排与重绘
几何属性变化会触发重排,开销较大;仅视觉属性变化(如颜色、背景)只需重绘;如果变更可以只由合成线程处理(如 transform、opacity),则跳过前两个阶段。
需要留意的是,读取布局相关属性会强制浏览器同步计算布局,这一操作通常称为强制同步布局。在循环中交替进行读写,会把一次布局放大成多次:
// 每次迭代都触发一次强制同步布局
for (const el of items) {
el.style.width = el.offsetWidth + 10 + 'px';
}
把读取集中到一处、写入集中到另一处,即可把多次布局合并为一次。更进一步,使用 requestAnimationFrame 把写入放到帧的合适位置,可以避免在同一帧内反复触发。
小结
渲染管线的各阶段串联成一条流水线:编码确定 → 分词建树 → 样式计算 → 布局 → 绘制 → 合成。性能优化的基本思路是减少进入流水线的次数、降低每次进入的成本,或者让变更尽可能停留在流水线靠后的阶段。