跳到主要内容
浏览器
导航
网页浏览器

浏览器渲染管线:从 HTML 字节流到像素

拆解浏览器把一份 HTML 文档转换成屏幕像素所经历的完整链路:编码嗅探、分词建树、样式计算、布局、绘制与合成,以及各阶段之间的阻塞关系与优化切入点。

发布于 4 分钟阅读 1493 字

浏览器接收到一份 HTML 文档后,要在同一帧的时间预算内完成从字节到像素的转换。这个过程被拆成若干职责明确的阶段,每个阶段的输入是上一个阶段的输出。理解这条链路,是分析页面性能问题的前提。

从字节到字符

网络层交出的是字节流,而分词器需要的是字符流,因此首先要确定字符编码。浏览器按以下顺序推断:

  1. 响应头 Content-Type 中的 charset 参数
  2. 字节流开头的 BOM 标记
  3. HTML 文档前 1024 字节内出现的 <meta charset>
  4. 以上均缺失时,回退到文档的默认编码或进行探测

第 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 把写入放到帧的合适位置,可以避免在同一帧内反复触发。

小结

渲染管线的各阶段串联成一条流水线:编码确定 → 分词建树 → 样式计算 → 布局 → 绘制 → 合成。性能优化的基本思路是减少进入流水线的次数、降低每次进入的成本,或者让变更尽可能停留在流水线靠后的阶段。

网页浏览器 · 相关文章