跳到主要内容
预计阅读 41 分钟

渲染引擎 — 从一堆字符到一棵树,浏览器如何”读懂”你的代码

浏览器拿到 HTML 和 CSS 之后,面临的第一个任务是”理解”它们。HTML 只是一串文本字符,CSS 只是一组样式规则——渲染引擎需要把它们转化为结构化的数据(DOM 树和 CSSOM 树),然后合并成布局树,才能知道”什么内容画在什么位置”。

📋 开篇自测:你已经知道多少?

  1. DOM 树和你在 Elements 面板中看到的结构是一回事吗?
  2. CSS 的”层叠”规则是如何决定最终样式的?specificity 你能手动计算吗?
  3. 布局树(Layout Tree)和 DOM 树有什么区别?哪些 DOM 节点不会出现在布局树中?

一、HTML 解析:从字节到 DOM 树

1.1 字节流到字符流

网络进程把服务器返回的原始字节传递给渲染进程时,渲染进程首先要做的是确定编码方式。它会从以下几个来源获取编码信息(优先级从高到低):

  1. HTTP 响应头中的 Content-Type: text/html; charset=utf-8
  2. HTML 文档开头的 <meta charset="utf-8">
  3. 浏览器的自动编码检测(根据字节特征猜测)

确定编码后,字节流被转换为字符流——也就是你在文本编辑器中看到的 HTML 代码。

1.2 词法分析(Tokenization)

字符流接下来被送入词法分析器(Tokenizer),也叫分词器。它的任务是识别出一个个有意义的”词法单元”(Token)。

词法分析过程

输入字符流:
<div class="box"><p>Hello</p></div>

输出Token序列:
┌────────────┬──────────┬────────────────┐
│ Token类型   │ 标签名   │ 属性            │
├────────────┼──────────┼────────────────┤
│ StartTag   │ div      │ class="box"    │
│ StartTag   │ p        │                │
│ Character  │ "Hello"  │                │
│ EndTag     │ p        │                │
│ EndTag     │ div      │                │
└────────────┴──────────┴────────────────┘

HTML 的词法分析器是一个状态机。它从第一个字符开始,根据当前字符和当前状态决定下一步:

HTML Tokenizer 状态机(简化)

          ┌─── '<' ────→ [标签打开状态]
          │                    │
[数据状态]─┤              ┌────┼────┐
          │              │    │    │
          └── 其他字符 ──→│  字母  │ '/' │
             [文本Token]  │    │    │
                          ▼    │    ▼
                    [标签名状态]│ [结束标签打开状态]
                         │    │         │
                    ' '/'>'/  │    ┌────┘
                    属性      │    │
                         │    │    ▼
                         ▼    │  [标签名状态]
                   [属性名状态]│       │
                         │    │       ▼
                         ▼    │  [EndTag Token]
                   [属性值状态]│
                         │    │
                         ▼    │
                  [StartTag   │
                   Token]─────┘

HTML 规范定义了 80 多种状态,覆盖了所有可能的语法场景(包括注释、DOCTYPE、CDATA 等)。

1.3 树构建(Tree Construction)

Token 序列被逐个送入树构建器(Tree Builder)。树构建器维护一个开放元素栈,并根据每个 Token 的类型执行不同的操作:

  • StartTag Token:创建对应的 DOM 节点,将其作为栈顶元素的子节点,然后将新节点压入栈
  • EndTag Token:从栈中弹出对应的元素
  • Character Token:创建文本节点,作为栈顶元素的子节点
DOM 树构建过程(逐步演示)

输入: <html><head><title>Hi</title></head><body><p>Hello</p></body></html>

步骤1: StartTag html → 创建html节点, 压栈
  栈: [html]
  树: Document
        └── html

步骤2: StartTag head → 创建head节点, 压栈
  栈: [html, head]
  树: Document
        └── html
              └── head

步骤3: StartTag title → 创建title节点, 压栈
  栈: [html, head, title]

步骤4: Character "Hi" → 创建文本节点
  栈: [html, head, title]
  树: ...head
            └── title
                  └── "Hi"

步骤5: EndTag title → 弹出title
  栈: [html, head]

步骤6: EndTag head → 弹出head
  栈: [html]

步骤7: StartTag body → 创建body节点, 压栈
  栈: [html, body]

步骤8: StartTag p → 创建p节点, 压栈
步骤9: Character "Hello" → 创建文本节点
步骤10: EndTag p → 弹出p
步骤11: EndTag body → 弹出body
步骤12: EndTag html → 弹出html

最终 DOM 树:
Document
└── html
    ├── head
    │   └── title
    │       └── "Hi"
    └── body
        └── p
            └── "Hello"

1.4 容错处理:浏览器比你想象的更宽容

HTML 解析器有一个非常特别的特性:极强的容错能力。即使你写的 HTML 有明显的错误,浏览器也会尽力修复它而不是抛出错误。

<!-- 你写的代码 -->
<p>段落1
<p>段落2
<div><p>嵌套错误</div></p>

<!-- 浏览器修复后的 DOM -->
<p>段落1</p>
<p>段落2</p>
<div><p>嵌套错误</p></div>

HTML 规范中定义了大量的容错规则。例如:

  • 遇到 <p> 中嵌套 <div>,会自动关闭 <p>
  • 未关闭的标签会在适当位置自动关闭
  • <table> 中的文本会被移到 <table> 之前

这种容错能力是把双刃剑——它让错误的 HTML 也能”正常”显示,但也意味着你在 Elements 面板中看到的 DOM 结构可能和你写的代码并不一致。

🤔 想一想 为什么 HTML 解析器被设计成”极度宽容”而不是像 JSON 解析器那样”严格报错”?这与 Web 的发展历史有什么关系?


二、DOM 树的本质

2.1 DOM 是什么

DOM(Document Object Model,文档对象模型)是 HTML 文档的结构化表示。它有三个本质特征:

  1. 数据结构:DOM 是一棵树形数据结构,每个节点代表文档中的一个元素、属性或文本
  2. 编程接口:DOM 提供了 JavaScript 可调用的 API,让脚本能够查询和修改文档结构
  3. 渲染基础:DOM 树是后续样式计算、布局计算的输入数据

2.2 DOM 节点类型

DOM 树中的节点并非只有”元素”一种类型:

DOM 节点类型

Document (文档节点)
├── DocumentType (<!DOCTYPE html>)
├── Element (html)
│   ├── Element (head)
│   │   ├── Element (meta)    ← 自闭合元素,无子节点
│   │   └── Element (title)
│   │       └── Text ("Hello") ← 文本节点
│   └── Element (body)
│       ├── Comment ("注释")  ← 注释节点
│       ├── Element (div)
│       │   └── Attr (id="app") ← 属性节点
│       └── Text ("\n  ")     ← 空白文本节点(常被忽视)

特别注意空白文本节点。HTML 中标签之间的换行和缩进会被解析为文本节点。这有时会导致意想不到的布局问题(比如 inline-block 元素之间出现间隙)。

2.3 DOM 的性能特点

DOM 操作是”昂贵”的,原因在于:

  1. DOM 树存在于渲染引擎(C++ 实现的 Blink)中
  2. JavaScript 运行在 V8 引擎中
  3. 每次 JS 操作 DOM,都需要跨越 JS 引擎和渲染引擎的边界
JavaScript 操作 DOM 的开销

┌───────────────┐    桥接层    ┌───────────────┐
│   V8 引擎      │ ◄──────────► │  Blink 引擎    │
│  (JavaScript)  │   跨引擎通信  │  (DOM 树)      │
│               │   每次调用    │               │
│ element.style  │ ──有开销──→  │  修改 DOM 属性  │
│ = "red"       │              │               │
└───────────────┘              └───────────────┘

这就是为什么”批量 DOM 操作”和”虚拟 DOM”技术如此重要——它们本质上都是在减少跨引擎边界的调用次数。


三、CSS 解析:从规则文本到结构化样式

3.1 CSS 的来源

浏览器需要处理三种来源的 CSS:

  1. 外部样式表:通过 <link rel="stylesheet" href="style.css"> 引入
  2. 内联样式表<style> 标签中的 CSS
  3. 行内样式:元素的 style 属性

此外,浏览器自身还有一套用户代理样式表(User Agent Stylesheet),定义了 HTML 元素的默认样式(比如 <h1> 的字号、<p> 的 margin、<a> 的蓝色和下划线)。

3.2 CSS 解析过程

CSS 的解析过程与 HTML 类似,也分为词法分析和语法分析两步:

CSS 解析流程

CSS 文本:
div.box > p { color: red; font-size: 16px; }

        │ 词法分析


Token 序列:
[选择器: "div.box > p"] [左花括号] [属性: "color"] [冒号]
[值: "red"] [分号] [属性: "font-size"] [冒号] [值: "16px"]
[分号] [右花括号]

        │ 语法分析


StyleRule 对象:
{
  selector: "div.box > p"    (解析为选择器列表)
  declarations: [
    { property: "color", value: "red" },
    { property: "font-size", value: "16px" }
  ]
}

解析后的结果不是一棵树,而是一个样式规则集合。这些规则会被存储在 CSSOM(CSS Object Model)中。

3.3 CSSOM 树

CSSOM 树的结构类似 DOM 树,但每个节点存储的是计算后的样式信息:

CSSOM 树(概念模型)

                 body
              font-size: 16px
              color: #333
              ┌────────┴────────┐
           div.box              nav
        background: #fff      display: flex
        padding: 20px

              p
           color: red        ← 来自 "div.box > p" 规则
           font-size: 16px   ← 继承自 body
           margin: 1em 0     ← 来自用户代理样式表

3.4 样式计算的三个步骤

从原始 CSS 到每个 DOM 节点的最终样式,需要经过三个步骤:

第一步:属性值标准化

CSS 中的很多值需要被转换为统一的格式:

属性值标准化示例

原始值                 标准化后
────────               ────────
color: red          →  color: rgb(255, 0, 0)
font-size: 2em      →  font-size: 32px  (假设父元素16px)
font-weight: bold   →  font-weight: 700
width: 50%          →  width: 具体像素值 (需要布局阶段计算)

第二步:样式继承

CSS 中有些属性是可继承的(如 colorfont-sizeline-height),有些则不是(如 widthheightborderpadding)。

样式继承示意

body (color: #333, font-size: 16px, padding: 10px)
└── div (继承 color: #333, 继承 font-size: 16px)
    │   padding: 不继承,使用默认值 0
    └── p (继承 color: #333, 继承 font-size: 16px)
        │   padding: 不继承,使用默认值 0
        └── span (继承 color: #333, 继承 font-size: 16px)

第三步:层叠(Cascade)

当多条规则同时应用到一个元素时,需要通过”层叠”规则决定最终值。层叠的优先级从高到低:

CSS 层叠优先级(从高到低)

1. !important 声明 (尽量避免使用)
2. 行内样式 (style属性)           — 优先级独立于 specificity 体系
3. ID 选择器 (#header)            specificity: (1,0,0)
4. 类/属性/伪类选择器 (.box, :hover) specificity: (0,1,0)
5. 元素/伪元素选择器 (div, ::before) specificity: (0,0,1)
6. 通配符 (*)                      specificity: (0,0,0)
7. 继承的样式
8. 用户代理样式表

注: 现代 CSS 规范(Selectors Level 4)将 specificity 定义为三元组 (a,b,c),
行内样式的优先级独立于 specificity 计算。你可能在一些旧教材中见到
(1,0,0,0) 的四元组写法,那是把行内样式也纳入了 specificity 计算。

Specificity 计算示例:
div.box > p.text    → (0,2,2) = 两个类 + 两个元素
#main .content p    → (1,1,1) = 一个ID + 一个类 + 一个元素
div#sidebar.active  → (1,1,1) = 一个ID + 一个类 + 一个元素

🤔 想一想 以下两条规则同时作用于一个元素,最终颜色是什么?

#nav .link { color: blue; }    /* specificity: (1,1,0) */
.sidebar a.active { color: red; } /* specificity: (0,2,1) */

答案:最终颜色是 blue。比较 specificity 时从左到右逐位对比:第一位 1 > 0,所以 (1,1,0) 胜出,后续位无需再比。


四、布局树的生成

4.1 从 DOM + CSSOM 到布局树

DOM 树描述了文档的结构,CSSOM 树描述了每个节点的样式。浏览器将两者合并,生成布局树(Layout Tree)

DOM树 + CSSOM → 布局树

DOM 树:                    CSSOM 样式:
html                       html   {}
├── head                   head   { display: none }
│   └── title              body   { font-size: 16px }
└── body                   .box   { width: 200px }
    ├── div.box            .hide  { display: none }
    │   └── p              p      { margin: 10px }
    ├── span.hide
    └── p


                ▼ 合并(跳过不可见节点)

布局树 (Layout Tree):
html
└── body  (font-size: 16px)
    ├── div.box  (width: 200px)
    │   └── p  (margin: 10px)
    └── p  (margin: 10px)

注意: head、title、span.hide 不在布局树中!

4.2 哪些节点不会出现在布局树中

布局树并不是 DOM 树的精确副本。以下节点会被排除:

条件示例原因
display: none隐藏元素不参与布局,不占空间
<head> 及其子元素<meta><title>非可视内容
<script>脚本标签非可视内容

注意区分:visibility: hiddenopacity: 0 的元素出现在布局树中——它们仍然占据空间,只是不可见。

4.3 布局计算

有了布局树之后,浏览器开始进行布局计算(Layout),确定每个节点在页面中的精确位置和尺寸。

布局计算过程

输入: 布局树 + 每个节点的样式信息
输出: 每个节点的几何信息 (x, y, width, height)

┌─── viewport (width: 1200px) ──────────────────────┐
│                                                    │
│  ┌─── body (margin: 8px) ──────────────────────┐  │
│  │  x: 8, y: 8, width: 1184                   │  │
│  │                                             │  │
│  │  ┌─── div.box ──────────────────────────┐  │  │
│  │  │  x: 8, y: 8, width: 200, height: ?  │  │  │
│  │  │                                      │  │  │
│  │  │  ┌─── p ────────────────────────┐   │  │  │
│  │  │  │  x: 8, y: 18                │   │  │  │
│  │  │  │  width: 200, height: 自动    │   │  │  │
│  │  │  │  margin: 10px               │   │  │  │
│  │  │  └──────────────────────────────┘   │  │  │
│  │  └──────────────────────────────────────┘  │  │
│  └─────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘

布局是一个递归过程:从根节点开始,计算每个子节点的位置和尺寸,子节点的高度又会反过来影响父节点的高度。这也是为什么布局计算(尤其是涉及复杂 flex 或 grid 布局时)可能非常耗时。

4.4 不同布局模型

浏览器支持多种布局模型,每种模型的计算逻辑截然不同:

CSS 布局模型

┌───────────────────────────────────────────────┐
│  Normal Flow (普通流)                          │
│  ├── Block Layout: div, p, section...         │
│  │   上下排列, 独占一行                         │
│  └── Inline Layout: span, a, em...            │
│      从左到右排列, 可换行                        │
├───────────────────────────────────────────────┤
│  Flex Layout (弹性布局)                        │
│  display: flex                                │
│  主轴/交叉轴方向排列, 自动分配空间               │
├───────────────────────────────────────────────┤
│  Grid Layout (网格布局)                        │
│  display: grid                                │
│  二维网格, 行列交叉定位                          │
├───────────────────────────────────────────────┤
│  Positioned Layout (定位布局)                   │
│  position: absolute / fixed / sticky          │
│  脱离普通流, 根据参考点定位                      │
├───────────────────────────────────────────────┤
│  Float Layout (浮动布局)                       │
│  float: left / right                          │
│  部分脱离普通流, 文字环绕                        │
└───────────────────────────────────────────────┘

五、JavaScript 对解析的影响

5.1 解析阻塞

当 HTML 解析器遇到 <script> 标签时,它必须暂停解析,把控制权交给 JavaScript 引擎。这是因为 JavaScript 有能力通过 document.write() 向 HTML 流中写入新的内容,从而改变后续的解析结果。

JavaScript 阻塞 HTML 解析

时间 →

HTML解析:  ████████│         │████████████████
                  │         │
JS下载:           │████████ │
JS执行:           │        ██│
                  ↑         ↑
              遇到<script> 执行完毕
              暂停解析     恢复解析

5.2 CSS 也会间接阻塞解析

JavaScript 经常需要查询元素的计算样式(比如 getComputedStyle)。如果在 JavaScript 执行时 CSS 还没有解析完毕,浏览器会等待 CSS 解析完成后再执行 JavaScript。这就形成了一个连锁阻塞:

CSS → JS → HTML 的连锁阻塞

CSS下载:   ████████████
CSS解析:                ████
JS执行:                      ██████    ← 等CSS解析完才执行
HTML解析:  ████████              │██████████ ← 等JS执行完才恢复

                        JS依赖CSS的计算样式

5.3 优化策略

了解了阻塞机制,优化方向就很清晰:

<script> 标签的三种模式

普通:    <script src="app.js"></script>
         HTML解析 → 暂停 → 下载JS → 执行JS → 恢复解析

defer:   <script defer src="app.js"></script>
         HTML解析 ──────────────────→ DOMContentLoaded前执行JS
                   └── 并行下载JS ──┘  (保证执行顺序)

async:   <script async src="analytics.js"></script>
         HTML解析 ──────────────→ 恢复解析
                   └── 并行下载JS → 下载完立即执行 (不保证顺序)

最佳实践

  • 关键业务逻辑用 defer:保证 DOM 就绪后按顺序执行
  • 非关键的第三方脚本用 async:如分析统计、广告
  • 将 CSS 放在 <head> 中尽早加载,避免 CSS 成为瓶颈

🤔 想一想 如果一个页面有 3 个 defer 脚本和 2 个 async 脚本,它们的执行顺序是怎样的?


六、增量解析与流式渲染

6.1 浏览器不会等到 HTML 全部下载完才开始解析

这是一个非常重要的认知:浏览器采用**增量解析(Incremental Parsing)**策略。网络进程每收到一块数据,就立即把这块数据交给渲染进程的 HTML 解析器。

增量解析流程

网络传输:  [chunk1] [chunk2] [chunk3] [chunk4] ... [完毕]
               │        │        │        │
HTML解析:  [解析C1] [解析C2] [解析C3] [解析C4]
               │        │
DOM更新:   [更新DOM] [更新DOM]

首次绘制:  [可能在这里发生FCP]

这就是为什么优化服务端的 TTFB(Time To First Byte)如此重要——即使响应体还没有完全生成,服务器也应该尽早发送 HTML 的 <head> 部分,让浏览器提前开始解析和资源加载。

6.2 document.write 的危害

document.write() 会向当前的 HTML 流中写入内容。如果在解析阶段调用,它会插入到当前解析位置;如果在页面加载完成后调用,它会清空整个文档。

// 危险:在异步加载的脚本中使用 document.write
// Chrome 在某些条件下会直接忽略它,并在控制台输出警告
document.write('<script src="slow.js"><\/script>');

Chrome 从 55 版本开始,会在慢速网络环境下阻止 document.write() 插入的外部脚本的执行,因为它会严重影响页面加载性能。


七、动手实验:观察解析过程

7.1 实验一:在 Performance 面板中观察解析

  1. 打开 Chrome DevTools,切换到 Performance 面板
  2. 点击录制按钮,然后刷新页面
  3. 停止录制后,在时间线中找到 Parse HTML 任务
  4. 观察它是否被 JavaScript 评估(Evaluate Script)打断

7.2 实验二:用 DOM API 验证 DOM 树结构

// 在控制台中执行
const root = document.documentElement;

function printTree(node, indent = 0) {
  const type = node.nodeType === 1 ? node.tagName :
               node.nodeType === 3 ? `"${node.textContent.trim()}"` :
               node.nodeType === 8 ? `<!-- ${node.textContent} -->` :
               `[${node.nodeType}]`;
  if (type === '""') return; // 跳过空文本节点
  console.log(' '.repeat(indent) + type);
  node.childNodes.forEach(child => printTree(child, indent + 2));
}

printTree(root);

7.3 实验三:观察样式计算

// 查看一个元素的所有计算样式
const el = document.querySelector('body');
const styles = getComputedStyle(el);

// 查看特定属性
console.log('font-size:', styles.fontSize);
console.log('color:', styles.color);
console.log('display:', styles.display);

// 查看所有属性(数量惊人)
console.log('计算样式属性总数:', styles.length);

八、渲染引擎的核心数据结构对比

四棵核心"树"的对比

┌──────────┬───────────────┬─────────────────┬──────────────┐
│          │   DOM 树       │   CSSOM 树       │   布局树      │
├──────────┼───────────────┼─────────────────┼──────────────┤
│ 输入     │ HTML 文本     │ CSS 文本        │ DOM + CSSOM   │
│ 节点内容 │ 元素、文本、   │ 样式规则、      │ 可见元素的     │
│          │ 注释等        │ 选择器、值      │ 几何信息       │
│ 包含     │ 所有节点      │ 所有样式规则    │ 仅可见节点     │
│ 不包含   │ 样式信息      │ 文档结构        │ display:none  │
│ 用途     │ JS操作的接口   │ 样式查询的依据  │ 绘制的依据     │
│ 可被JS修改│ 是            │ 是             │ 间接(通过DOM) │
└──────────┴───────────────┴─────────────────┴──────────────┘

九、本章知识脉络总结

渲染引擎知识地图

HTML 解析
├── 字节流 → 字符流 (编码识别)
├── 字符流 → Token (词法分析/状态机)
├── Token → DOM 树 (树构建器/开放元素栈)
└── 容错处理 (自动修复不规范HTML)

CSS 解析
├── CSS 来源 (外部/内联/行内/UA样式表)
├── 词法分析 + 语法分析 → 样式规则集
├── CSSOM 树
└── 样式计算三步骤
    ├── 属性值标准化
    ├── 样式继承
    └── 层叠与specificity

布局树
├── DOM + CSSOM → 布局树
├── 排除不可见节点 (display:none, head等)
├── 布局计算 (几何信息: x, y, width, height)
└── 布局模型 (Normal Flow/Flex/Grid/Position/Float)

JS 对解析的影响
├── 阻塞: script标签暂停HTML解析
├── 连锁: CSS阻塞JS, JS阻塞HTML
├── 优化: defer / async / 预加载扫描器
└── 增量解析: 边接收边解析

📝 结尾自测:检验你的学习成果

  1. HTML 解析的三个主要步骤是什么?词法分析器的核心机制是什么(状态机)?
  2. CSS 样式计算的三个步骤分别是什么?什么是 specificity?如何计算?
  3. 布局树和 DOM 树有什么区别?列举至少 3 种不会出现在布局树中的节点。
  4. 为什么 <script> 标签会阻塞 HTML 解析?CSS 是如何间接造成阻塞的?
  5. 什么是增量解析?它对页面首次绘制时间有什么影响?

下一章预告:布局树生成之后,页面离呈现到屏幕上还有好几步。下一章我们将深入渲染流水线的后半程——分层、绘制指令生成、图层合成、GPU 光栅化,彻底理解重排(Reflow)、重绘(Repaint)和合成(Composite)的区别与触发条件。

购买课程解锁全部内容

前端进阶第一课:11 章掌握浏览器核心

¥29.90