前言
理解浏览器如何解析 HTML 文件,不仅能帮助我们分析性能问题,还能更深入地理解浏览器的工作机制。
过去网上的讲解五花八门、各执一词,所以这篇文章基于我自己的理解与验证整理而成( + GPT 指正),如有不当之处,欢迎指正。
update: 2025.12 - 最近看了下渡一关于这块的讲解,把里面的一些细节补充了进来。
好了,咱们就直接看看浏览器拿到 HTML 文件后究竟干了什么。
HTML 字符解析
从字符到 DOM 树
当网络进程(Network Process)下载完 HTML 文件后,会产生一个渲染任务,推给渲染进程(Renderer Process)的主线程(Main Thread)的消息队列,在事件循环机制的作用下,取出任务,开始解析 HTML。
整个流程大概是这样:
- 主线程读取 HTML 字符串;
- 按照自上而下的顺序进行词法分析(一会解释);
- 根据节点的层级关系生成对应的 DOM 节点;
- 最终形成一棵 DOM 树(Document Object Model Tree)。
冷知识:
DOM 节点在底层是 C++/Rust 实现的对象。JavaScript 访问 DOM 时,通过浏览器的 WebIDL/V8 Bindings 层生成 wrapper(包装对象),这个 wrapper 才是 JS 世界里的可操作对象。DOM wrapper 通常是惰性创建的。(知道这个意思就行了)
此时,DOM 树只是文档的结构信息,还没有任何样式。
举个例子吧,假设 HTML 文件是这样的:
<html>
<head>
<title>Hello World</title>
</head>
<body>
<div class="container">
<h1>Welcome</h1>
<p>Let's learn how the DOM works.</p>
</div>
</body>
</html>
🌳 那么 DOM 树的伪代码表示:
Document
└── html
├── head
│ └── title
│ └── "Hello World"
└── body
└── div(class="container")
├── h1
│ └── "Welcome"
└── p
└── "Let's learn how the DOM works."
是不是一目了然~
词法分析
刚刚还提到了“词法分析”,什么是词法分析?
浏览器内部有一个叫做 HTML 解析器(HTML Parser) 的东西。 它会:
- 读取一小段 HTML 文本;
- 判断这部分内容的“类型”;
- 生成相应的 token 对象;
- 把这些 token 交给 DOM 构建器(DOM Tree Builder) 去拼出树。
你可以把“词法分析”理解成:
🧩 “分词” + “打标签”的过程。
比如浏览器读一句话:
<div class="container">Hello</div>
就像语文老师在批改作文时做标注:
<(开始标签)
div(标签名)
class(属性名)
=
"container"(属性值)
>(标签结束符)
Hello(文本内容)
</div>(结束标签)
总结一句话:词法分析是浏览器把 HTML 源码从一串纯文本,分割成可以理解的最小语法单元(token)的过程。
这些 token 之后会被“语法分析器(Parser)”拼装成 DOM 树结构。
同时生成 CSSOM 树
主线程在解析 DOM 的同时,会解析样式相关的内容,包括:
<link>引用的外部样式;<style>标签内的样式;- DOM 节点上的行内样式;
- 浏览器默认样式。
解析的结果会形成一棵 CSSOM(CSS Object Model)树,表示所有选择器与样式声明的关系。
用伪代码或树状结构来表示,大致是这样的:
StyleSheetList
├── CSSStyleSheet (样式表 1)
├── CSSRule (规则 1)
│ ├── selectorText: "body"
│ └── style:
│ ├── color: black
│ └── font-size: 16px
│
├── CSSRule (规则 2)
│ ├── selectorText: ".container"
│ └── style:
│ ├── display: flex
│ └── justify-content: center
│
└── CSSRule (规则 3)
├── selectorText: "#app .title"
└── style:
├── font-weight: bold
└── color: red
├── CSSStyleSheet (样式表 2)
🔍 小技巧:如何查看 CSSOM?
虽然浏览器内部的 CSSOM 不直接暴露给开发者,但可以通过 JavaScript 的 document.styleSheets 对象窥探到一部分:
// 查看所有样式表
console.log(document.styleSheets);
// 查看第一个样式表的规则
console.log(document.styleSheets[0].cssRules);
// 动态修改样式
document.styleSheets[0].addRule("p", "color: green");
注意:
- CSSOM 只是存储规则本身,此时还没算出哪个元素最终用什么样式。
- 主线程在解析 HTML 构建 DOM 树的同时,也会解析 CSS 来构建 CSSOM 树。这两个过程是并行且相互独立的。
解析过程中的阻塞与优化
不阻塞解析的:图片、预解析线程
当主线程在解析 HTML 时,还会有一个 预解析线程(Preload Scanner) 并行运行。
它会快速扫描 HTML,提前发现可并行加载的资源,比如 CSS、JS、图片等。
预解析线程不会构建 DOM 或 CSSOM,只是让网络进程先去下载,减少等待时间。
图片的加载也不会阻塞 HTML 解析,只会影响最终渲染显示。
阻塞解析的:<script>
HTML 规范规定,当解析器遇到 未标记为 async 或 defer 的 <script> 时,必须:
- 暂停 DOM 解析;
- 执行脚本(带连接的要等到下载完成后才执行脚本);
- 执行完后再继续解析 HTML。
这是因为 JS 代码可能会修改前面已构建的 DOM。
示例:
<body>
<div>1</div>
<script>
debugger;
</script>
<div>2</div>
</body>
页面会先显示 “1”,然后因为脚本执行暂停,导致 “2” 暂时不会显示。
同样:
<body>
<div>1</div>
<script src="外部链接"></script>
<div>2</div>
</body>
如果执行到 script 标签的时候,还没完成下载,那么同样也会阻塞解析。
至于为什么会显示 1 在页面上,后面会有解释。
CSS 是否阻塞解析?
CSS 下载不会阻塞 HTML 解析,但会阻塞渲染。
也就是说,页面结构能先构建,但浏览器一定要等 CSSOM 构建完成后,才能与 DOM 树一起完成后续工作。
所以如果 <link> 的 CSS 文件太大或加载太慢,页面就会白屏很久。
样式计算(Style Computation)
当浏览器有了:
- 一棵 DOM 树(元素结构)
- 一棵 CSSOM 树(样式规则)
它就会进入样式计算阶段,算出每个元素的最终样式(也就是控制台里的 Computed 栏里看到的):
-
解析所有 CSS 规则(选择器 + 声明),在 CSSOM 里已经准备好了,直接用。
-
匹配选择器 & 计算优先级(specificity) 浏览器会遍历每个 DOM 节点,去匹配 CSSOM 中的所有选择器。
当多个规则命中同一个元素时:
- 先看优先级(specificity,也就是我们在编辑器里的样式选择器里看到的提示,例如
(0,1,1),是一个很重要的知识点,建议不了解的可以学习下) - 再看顺序(后写的覆盖前写的)
-
应用继承关系(Inheritance),某些属性(比如
color、font-family)会自动从父节点继承。浏览器在这一阶段会判断是否需要继承。 -
用浏览器默认样式(User Agent Stylesheet),对于没被定义的属性,浏览器会从自己的默认样式表中拿值。比如
<a>默认是蓝色有下划线,<div>默认是display: block。
此阶段会把诸如
red这样的相对值转换为绝对值(如rgb(255,0,0))。
算完后,这些样式信息都会在每个 DOM 节点上以属性的形式保存起来。
布局阶段(Layout phase)
最终样式计算出来了,其实还差一些几何信息(高度,宽度、位置),这个时候就需要计算出一个布局树(Layout Tree)的东西。
大概过程如下:
- 遍历每个 DOM 的节点;
- 确定包含块(containing block);
- 根据盒模型计算 margin、border、padding;
- 把相对值(%、em)换算成绝对像素;
它只包含 可见的元素,并为每个节点附上计算好的样式。
举个例子:
<body>
<div style="display:none;">Hidden</div>
<p>Hello</p>
</body>
此时 Layout Tree 会长这样:
LayoutRoot
└── LayoutBody
└── LayoutParagraph (computedStyle: {...}, geometry:{...})
<div> 被 display: none 掉了,所以它不会进入布局树。
当你在 F12 中,选中一个元素,看 Computed 面板和 Box Model,显示的是这个元素在页面上的 最终几何信息(宽、高、margin、padding、border 等)以及上面提到过的最终样式信息。
F12 看到的可视化 DOM 不是 Layout Tree,你可以看到各种被 display: none 掉的元素(head、link 等),但是它们在 Layout Tree 中是不存在的。GPT 说这是 DOM 树的实时视图,它展示的是内存中的 DOM 对象结构,会随着 JS 的操作实时更新。
DOM 树和布局树不是一一对应的
除了上面说的 display 问题,还有其他说明:
-
伪元素:伪元素在 DOM 树不存在,但是在 Layout Tree 中存在。
-
内容必须在行盒中:例如一个
<p>a</p>,在布局树中会是这样的:
p 块盒
└── 匿名行盒
└── a
匿名行盒不是任何 HTML 标签对应的元素类型,而是浏览器在构建布局树(Layout Tree)时临时生成的一种“没有对应 DOM 节点的行级盒子”。
- 行盒和块盒不能相邻:例如一个
<p></p>a,在布局树中会是这样的:
p 块盒
匿名块盒
└── 匿名行盒(内容必须在行盒中)
└── a
这里的匿名块盒同理。
Layout Tree 的节点也是 C++ 实现的内部结构,但它没有任何 JS 暴露的包装对象,也不能通过 JavaScript 直接访问。
当布局阶段完成后,布局树中的每个节点就同时拥有了:
- 计算好的样式
- 计算好的几何信息
这棵树就是浏览器用来后续绘制和合成的最终蓝图。
分层(Compositing Layer)
浏览器会将布局树中的某些节点 提升为独立的合成层(Compositing Layer),类似于 Photoshop 的图层系统。
为什么要分层?
为了加速后续的绘制与合成。当某个图层的内容发生变化(如动画),浏览器只需重新光栅化该层,并由合成线程独立处理,无需重排或重绘其他层。
哪些情况会触发分层?
- 用了
transform(比如移动、旋转)、opacity(透明度)、filter(滤镜)这些属性; - 显式告诉浏览器“我可能会变”:比如加了
will-change: transform;这就告诉浏览器我将来可能会改变 transform 属性,你看着要不要分层,浏览器会自己决定。 - 是视频(
<video>)、画布(<canvas>)、iframe 这类自带图层的元素; - 用了
position: fixed或sticky的定位; - 创建了新的“堆叠上下文”(比如有
z-index的定位元素),并且和其他内容有重叠。
🔍 附加小知识:
- 可在 Chrome DevTools 的 Layers 面板(需在 Rendering 面板中开启)查看页面的分层情况;
- 使用
transform: translateZ(0)或translate3d(0,0,0)是过去常用的“强制硬件加速” hack,但现代浏览器已不推荐,优先用will-change。 - 每个合成层都会占用额外的内存(存储位图),并增加合成阶段的开销。过度分层反而会导致性能下降,应避免滥用。
绘制(Paint)
主线程会为每个图层生成一份“画画说明书”——比如“这里画个红色方块,那里写一行文字”。
这份说明书叫 绘制记录(Paint Records) ,它不直接画图,只是告诉别人“该怎么画”,例如:
- 第一条:“在 (100, 100) 位置画一个红色方块,宽度 100px,高度 100px”。
- 第二条:“在 (200, 200) 位置写一行文字,内容为 'Hello World',字体为 16px 宋体,颜色为 #000000”。
- ...
每一个图层的说明书叫做指令集。
小知识:
- 如果一个元素只是在动(比如用
transform移动),但内容没变,那它甚至不需要重新生成“画画说明书”,直接跳过这一步! - canvas 其实就是用了浏览器内置的绘制。
分块(Tiling)
主线程将每个图层的指令集交给合成线程(Compositor Thread),主线程后面不参与了。
合成线程是属于浏览器的渲染进程(Render Process)。
合成线程会根据视口(viewport)的位置和缩放比例,计算出哪些层(Layers)很大,需要被切分成一块一块的小方格(通常是 256x256 或 512x512 像素)。
光栅化(Rasterization)
接着,把这些小块交给多个 光栅化线程(Raster Threads) ,它们通常跑在 GPU 进程里。会优先把视口内要显示的分块处理成像素图(位图) 。每个分块就和那些 jpg 图片一样,例如一个 8080 的图片,有 8080 的像素。
光栅化线程在 GPU 进程中。
🔍 补充:光栅化可以用 CPU 也可以用 GPU,取决于设备性能。低端手机可能用 CPU,高端机用 GPU 加速。
合成 (DrawQuad/Compositing)
所有小块都变成图片后,合成线程会根据它们的位置、大小、旋转、缩放等信息,生成一套“拼图指令”(叫 Draw Quads)。 有的也直接叫 Compositing。 然后,它把这些指令交给 GPU,GPU 把所有图层像拼图一样叠在一起,最终合成一帧完整的画面,显示到你的屏幕上。
其他重要知识点补充
重排(Reflow)与重绘(Repaint)
重排(Reflow,也叫回流)
-
定义:当页面的布局(Layout)发生变化时,浏览器需要重新计算元素的几何信息(如位置、宽高),也就是要从布局阶段往后走,这个过程叫 重排。
-
触发条件(只要影响“布局”的操作):
- 改变元素的
width、height、padding、margin、border; - 添加/删除 DOM 节点;
- 改变
font-size(可能影响行高和宽度); - 浏览器窗口大小改变;
- 调用
offsetWidth、clientHeight等强制同步读取布局属性(会触发“刷新队列”)。
- 改变元素的
不过浏览器也是会自动做一些优化的,例如:
box.style.width = "200px";
box.style.height = "200px";
box.style.backgroundColor = "red";
console.log(box.offsetWidth);
浏览器的历程会是这样的:读第一行,哦,改元素属性啊,先记录在本子上;读第二行,也改元素属性,继续记录在本子上;读第三行,也改元素属性,继续记录在本子上;读到第四行,发现卧槽要读取布局属性了,必须重排了,这时候浏览器会先把前三条合并一起处理,只重排一次,然后生成一个渲染任务推入消息队列,但这个时候第四行代码的打印其实还是旧的内容,因为此时渲染任务排在它后面。
重绘(Repaint)
-
定义:当元素的外观样式改变但不影响布局时,浏览器只从分层或者绘制阶段往后走,这个过程叫 重绘。
-
触发条件(只改“样子”,不改“位置/大小”):
- 改变
color、background-color、visibility、box-shadow; - 改变伪类样式(如
:hover); - 但 不包括
transform、opacity(它们走合成,连重绘都跳过!)。
- 改变
⚠️ 重绘不一定触发重排(比如只改颜色,布局没变)。
对比
它们的关系:包含 vs 独立
| 操作 | 是否重排 | 是否重绘 | 说明 |
|---|---|---|---|
改width | ✅ 是 | ✅ 是 | 布局变了 → 重排 → 必然重绘 |
改color | ❌ 否 | ✅ 是 | 外观变但布局不变 → 只重绘 |
改transform | ❌ 否 | ❌ 否 | 由合成线程处理,跳过重排和重绘 |
| 添加 DOM 节点 | ✅ 是 | ✅ 是 | 布局结构变化 → 重排 → 重绘 |
结论
- 重排 ⇒ 一定重绘
- 重绘 ⇏ 一定重排
为什么大家说“尽量避免重排”?
- 性能开销大:重排需要重新计算整个文档或部分子树的布局,复杂度高;
- 连锁反应:一个元素重排,可能引发父元素、兄弟元素甚至整个页面重排;
- 阻塞主线程:重排/重绘都在主线程执行,会和 JS 抢时间,导致卡顿。
📌 举例:
如果你在 1 秒内频繁修改一个元素的left值(用 JS 循环),浏览器会反复重排 → 卡成幻灯片。
但如果改transform: translateX(),则由合成线程处理 → 60fps 丝滑。
如何优化?
- 批量修改样式:用
class一次性切换,而不是逐个改属性; - 避免强制同步布局:不要在循环中读取
offsetWidth等; - 使用
transform/opacity做动画:完全绕过重排重绘; - 离线操作 DOM:用
DocumentFragment或display: none(此时修改不触发重排); - 利用 BFC 隔离布局影响。
🌟 一句话总结关系:
重排是“重新算位置”,重绘是“重新画颜色”;
位置一变,颜色肯定要重画;但颜色变了,位置未必动。
而现代高性能动画的核心思路就是:能用合成(Compositing)解决的,就别碰重排和重绘!
补充
对于重绘和重排,浏览器非常智能,假设某个重排的影响范围很小,例如只影响到一个 layer,其他 layer 都没有受到影响,那么浏览器会只重排这一个 layer,其他 layer 都不会重排。
为什么 transform 动画特别流畅?
因为它只改“拼图指令”里的位置或角度,不需要重新画图、不需要主线程参与,全由合成线程搞定。哪怕你的 JS 正在疯狂计算,动画也不会卡!
它最极致的情况就是只走最后一步合成 (DrawQuad)。
滚动条也是只是在合成线程中处理的,不会阻塞主线程,所以你会发现有时候页面卡了,但是滚动条还可以继续滚动。
渲染可能会增量执行
浏览器渲染不是固定一次性全量生成。就拿前面脚本标签阻塞 HTML 解析的例子来试试就知道了:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.box {
color: red;
}
</style>
</head>
<body>
<div class="box">1</div>
<script>
debugger;
</script>
<div>2</div>
</body>
</html>
运行下发现,虽然断点卡住了,但是页面上仍然正确的显示出了红色的 1,难道不需要等待整个页面的 HTML 解析,CSSOM 创建,Render Tree 创建...才能显示页面内容吗?
答案是在这种情况下是不需要的,当浏览器执行到 <script>debugger</script> 时:
- DOM 节点 已经生成
- Render Tree 节点 已经生成
- Layout Tree 节点 对于
<div class="box">1</div>已经生成(至少几何信息已经计算好)可以绘制 - 所以你看到的 “1” 能显示,并且颜色是红色
那是不是只要解析到哪就 Paint 下?答案不是的。
浏览器 不会每解析一个节点就立即 Paint。通常会有以下机制:
- 解析到一定量 或 遇到 script/style 等阻塞点 时,才可能触发一次“增量渲染”(incremental rendering)。
- 但很多浏览器(尤其移动端)会 延迟首次渲染,直到关键资源(如首屏 CSS)加载完成,避免“闪屏”。
如果一个按钮发生变化,背后会怎样?
情况 1:元素没有独立合成层(最常见)
button {
background-color: blue;
}
button:hover {
background-color: red;
}
流程:
- ✅ Paint:重新绘制 button 的显示列表(display list)
- ✅ Rasterization:重新光栅化该元素的位图
- ✅ Compositing:重新合成整个页面(但浏览器很聪明,因为这种情况比较简单,所以只更新变化区域)
这种情况下会经历 Paint → Rasterization → Compositing
情况 2:元素被提升为独立合成层
button {
background-color: blue;
/* 触发层提升的属性 */
will-change: transform;
/* 或者 */
transform: translateZ(0);
}
流程:
- ✅ Paint:重新绘制该层的显示列表
- ✅ Rasterization:重新光栅化该层
- ✅ Compositing:只重新合成这个特定图层
优势:不会影响其他图层的光栅化
情况 3:浏览器的进一步优化
某些情况下,浏览器可能会:
- 跳过重新光栅化:如果只是颜色变化且使用了 GPU 加速,可能直接在 GPU 上修改纹理
- 增量更新:只更新发生变化的像素区域
JS 执行与动画优化
如果主线程被 JS 长时间占用(比如死循环),页面的重排与重绘会被推迟,导致卡顿。
解决方式:
- 使用
requestAnimationFrame()让 JS 与渲染节奏同步; - 避免在动画中频繁读取和写入布局属性;
- 尽量把动画放在合成线程执行,例如用
transform、opacity。
拥有独立合成层的元素(如
<video>、<canvas>)在 GPU 上单独绘制,不占用主线程,因此不会触发重排重绘。
defer 和 async 属性
<script defer src="..."></script>:
- 加载:与 HTML 解析并行进行(异步加载)。
- 执行:在 HTML 解析完成之后,DOMContentLoaded 事件触发之前执行。
- 顺序:多个 defer 脚本会按照它们在页面中出现的顺序依次执行。
- 结论:defer 脚本非常适合需要操作 DOM 的脚本,它保证了执行时 DOM 是可用的,且不会阻塞页面渲染。
<script async src="..."></script>`:
- 加载:与 HTML 解析并行进行(异步加载)。
- 执行:加载完毕后立即执行,此时 HTML 解析可能还未完成。
- 顺序:多个 async 脚本的执行顺序是不确定的,取决于谁先加载完。
- 结论:async 适合独立的脚本,比如统计代码或广告脚本,它们不依赖 DOM,也不被其他脚本依赖。
DOMContentLoaded vs window.onload
| 特性 | DOMContentLoaded | window.onload |
|---|---|---|
| 触发时机 | DOM 树(HTML 结构)解析完成。 | 页面所有资源(DOM, CSS, JS, 图片, iframe 等)加载完成。 |
| 等待资源 | 不等待样式表、图片、iframe 等。 | 必须等待所有外部资源加载完成。 |
| 触发速度 | 更早 | 更晚 |
例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Event Load Order</title>
<script>
// 1. 这里的脚本会立即执行,此时 body 还未解析
console.log("1. Script in head is executing.");
// 2. 注册 DOMContentLoaded 事件
document.addEventListener("DOMContentLoaded", () => {
console.log("3. DOM is fully loaded and parsed.");
});
// 3. 注册 window.onload 事件
window.onload = () => {
console.log("5. Everything (including images) is fully loaded.");
};
</script>
</head>
<body>
<h1>Test Page</h1>
<p>Check the console for the order of events.</p>
<!-- 使用一个大图或一个不存在的图片来模拟加载延迟 -->
<img src="https://via.placeholder.com/3000x3000.png" alt="A large image" />
<script>
// 4. 这里的脚本在 body 解析到这里时执行
console.log("2. Script at the end of body is executing.");
</script>
</body>
</html>
控制台输出顺序:
- Script in head is executing.
- Script at the end of body is executing.
- DOM is fully loaded and parsed. (此时 HTML 已解析完,但图片可能还在加载)
- (等待图片加载…)
- Everything (including images) is fully loaded. (图片加载完成后触发)
如何优化加载与阻塞
<script>使用defer或async;<link>资源合理拆分,剔除无用的 CSS,压缩 CSS 代码;- 将关键 CSS(Critical CSS)内联在
<head>;让浏览器尽早开始下载和解析 CSS,这样当遇到 JS 时,CSSOM 很可能已经准备好了,减少了等待时间。 - 图片定义宽高;
- 避免频繁修改样式,可一次性批量修改;
- 使用
DocumentFragment批量操作 DOM; - 利用 BFC 减少不必要的布局影响。
主线程阻塞示例
一个简单的阻塞函数:
function delay(duration = 1000) {
const start = Date.now();
while (Date.now() - start < duration) {}
}
delay(3000); // 阻塞 3 秒
执行时页面完全无响应,因为主线程被 JS 占满。
结语
浏览器从 HTML 到屏幕成像,是一个复杂但高效的流程。
理解这些原理不仅能帮我们优化性能,也能让我们写出更“懂浏览器”的代码。
唉呀妈呀,真不容易写完!
要是这篇帮你理清了渲染机制,别忘了点个赞~