渲染引擎 — 从一堆字符到一棵树,浏览器如何”读懂”你的代码
浏览器拿到 HTML 和 CSS 之后,面临的第一个任务是”理解”它们。HTML 只是一串文本字符,CSS 只是一组样式规则——渲染引擎需要把它们转化为结构化的数据(DOM 树和 CSSOM 树),然后合并成布局树,才能知道”什么内容画在什么位置”。
📋 开篇自测:你已经知道多少?
- DOM 树和你在 Elements 面板中看到的结构是一回事吗?
- CSS 的”层叠”规则是如何决定最终样式的?
specificity你能手动计算吗?- 布局树(Layout Tree)和 DOM 树有什么区别?哪些 DOM 节点不会出现在布局树中?
一、HTML 解析:从字节到 DOM 树
1.1 字节流到字符流
网络进程把服务器返回的原始字节传递给渲染进程时,渲染进程首先要做的是确定编码方式。它会从以下几个来源获取编码信息(优先级从高到低):
- HTTP 响应头中的
Content-Type: text/html; charset=utf-8 - HTML 文档开头的
<meta charset="utf-8"> - 浏览器的自动编码检测(根据字节特征猜测)
确定编码后,字节流被转换为字符流——也就是你在文本编辑器中看到的 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 文档的结构化表示。它有三个本质特征:
- 数据结构:DOM 是一棵树形数据结构,每个节点代表文档中的一个元素、属性或文本
- 编程接口:DOM 提供了 JavaScript 可调用的 API,让脚本能够查询和修改文档结构
- 渲染基础: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 操作是”昂贵”的,原因在于:
- DOM 树存在于渲染引擎(C++ 实现的 Blink)中
- JavaScript 运行在 V8 引擎中
- 每次 JS 操作 DOM,都需要跨越 JS 引擎和渲染引擎的边界
JavaScript 操作 DOM 的开销
┌───────────────┐ 桥接层 ┌───────────────┐
│ V8 引擎 │ ◄──────────► │ Blink 引擎 │
│ (JavaScript) │ 跨引擎通信 │ (DOM 树) │
│ │ 每次调用 │ │
│ element.style │ ──有开销──→ │ 修改 DOM 属性 │
│ = "red" │ │ │
└───────────────┘ └───────────────┘
这就是为什么”批量 DOM 操作”和”虚拟 DOM”技术如此重要——它们本质上都是在减少跨引擎边界的调用次数。
三、CSS 解析:从规则文本到结构化样式
3.1 CSS 的来源
浏览器需要处理三种来源的 CSS:
- 外部样式表:通过
<link rel="stylesheet" href="style.css">引入 - 内联样式表:
<style>标签中的 CSS - 行内样式:元素的
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 中有些属性是可继承的(如 color、font-size、line-height),有些则不是(如 width、height、border、padding)。
样式继承示意
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: hidden 和 opacity: 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 面板中观察解析
- 打开 Chrome DevTools,切换到 Performance 面板
- 点击录制按钮,然后刷新页面
- 停止录制后,在时间线中找到 Parse HTML 任务
- 观察它是否被 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 / 预加载扫描器
└── 增量解析: 边接收边解析
📝 结尾自测:检验你的学习成果
- HTML 解析的三个主要步骤是什么?词法分析器的核心机制是什么(状态机)?
- CSS 样式计算的三个步骤分别是什么?什么是 specificity?如何计算?
- 布局树和 DOM 树有什么区别?列举至少 3 种不会出现在布局树中的节点。
- 为什么
<script>标签会阻塞 HTML 解析?CSS 是如何间接造成阻塞的?- 什么是增量解析?它对页面首次绘制时间有什么影响?
下一章预告:布局树生成之后,页面离呈现到屏幕上还有好几步。下一章我们将深入渲染流水线的后半程——分层、绘制指令生成、图层合成、GPU 光栅化,彻底理解重排(Reflow)、重绘(Repaint)和合成(Composite)的区别与触发条件。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90