浏览器渲染流程深度解析
核心思想:浏览器并非在接收到 HTML 文档后一瞬间就将页面呈现出来,其背后经历了一个复杂且精细的流水线过程。理解这个过程是前端性能优化的基石。整个流程可以概括为:从网络获取文档,然后由渲染主线程和合成线程协作,经过多个阶段,最终将页面绘制到屏幕上。
一、 整体流程概览
浏览器从接收到 HTML 文档到最终在屏幕上显示页面,大致分为以下几个核心步骤。每一步都有其特定的输入和输出,并将输出作为下一步的输入。
- 解析 HTML (Parse HTML):将 HTML 文本解析成 DOM 树 (DOM Tree)。
- 样式计算 (Style Calculation):结合 CSS 规则,计算出 DOM 树中每个节点的最终样式,生成带样式的 DOM 树。
- 布局 (Layout):根据节点的样式计算其在屏幕上的精确位置和大小,生成 布局树 (Layout Tree)。
- 分层 (Layer):将布局树划分为不同的图层,生成 图层树 (Layer Tree)。
- 生成绘制指令 (Paint):为每个图层生成绘制指令列表。
- 分块 (Tiling):将每个图层划分为更小的图块。
- 光栅化 (Rasterization):将图块转换为位图(像素信息)。
- 绘制 (Draw):将位图发送给 GPU,最终显示在屏幕上。
二、 详细步骤拆解
阶段一:解析 HTML,生成 DOM 树和 CSSOM 树
当渲染主线程拿到 HTML 文档后,它的首要任务是将其解析成浏览器能够理解的结构——DOM 树。
- 字节流转字符串 (Bytes -> Characters):网络传输的是 0 和 1 的字节数据。浏览器首先根据文件的编码格式(如 UTF-8)将其转换为可读的字符串。
- 标记化 (Tokenization):渲染引擎对字符串进行词法分析(“拆词”),将其拆解成一个个的 标记 (Token),例如
<html>、<p>、文本内容等。 - 构建节点 (Tokens -> Nodes):根据 Token 创建对应的 DOM 节点。
- 构建 DOM 树 (Nodes -> DOM Tree):将各个节点根据其父子关系链接起来,形成一个树状结构,即 DOM 树。
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<p>Hello World</p>
</body>
</html>上述 HTML 会被解析成类似下图的 DOM 树结构:
documenthtmlheadtitle"My Page"
bodyp"Hello World"
CSS 解析与 CSSOM
在解析 HTML 的过程中,如果遇到 <link> 标签或 <style> 标签,浏览器会并行处理 CSS。
- 预解析线程:为了提高效率,浏览器会启动一个 预解析线程,快速扫描 HTML 文档,寻找外部资源链接(如 CSS、JS 文件),并提前开始下载。
- CSSOM 树:与解析 HTML 类似,浏览器会将 CSS 规则解析成一个树形结构——CSS 对象模型 (CSSOM, CSS Object Model)。这棵树记录了所有的样式规则、选择器和具体的样式声明。
结论:解析阶段完成后,我们得到了两个独立的树形结构:DOM 树(内容结构)和 CSSOM 树(样式规则)。
JavaScript 的影响
当主线程解析到 <script> 标签时,情况有所不同:
- 阻塞行为:主线程会 暂停 HTML 的解析,等待 JavaScript 文件下载(如果外链)并执行完毕。
- 原因:JavaScript 代码可能会修改当前的 DOM 树(例如使用
document.write()或document.createElement())。为了避免后续解析的 DOM 结构不正确,浏览器必须等待 JS 执行完成。 - 优化建议:这就是为什么我们通常建议将
<script>标签放在<body>的末尾,或者使用async和defer属性来避免阻塞渲染。
<body>
<script src="app.js"></script>
</body>阶段二:样式计算 (Computed Style)
现在我们有了 DOM 树和 CSSOM 树,需要将它们结合起来,确定每个 DOM 节点最终应该应用哪些样式。
这个过程是 为 DOM 树的每个节点计算出其所有 CSS 属性的最终值。
- 输入:DOM 树 + CSSOM 树
- 输出:一个带样式的 DOM 树
在这一步:
- 值规范化:浏览器会将所有相对单位(如
em,rem)转换成绝对单位(px),将颜色值(如red)转换成统一的rgb(255, 0, 0)格式等。 - 计算所有样式:即使你只为元素设置了
color: red,浏览器也会计算出该元素所有的 CSS 属性值(如font-size,margin,display等),未指定的属性会使用继承值或默认值。
注意:这一步的产出是一棵包含了每个节点完整样式信息的 DOM 树,为后续的布局奠定了基础。
阶段三:布局 (Layout)
虽然我们知道了每个节点的样子(颜色、字体大小等),但还不知道它们在页面上的具体位置和尺寸。布局 阶段就是为了计算出这些几何信息。
- 输入:带样式的 DOM 树
- 输出:布局树 (Layout Tree),也叫渲染树 (Render Tree)
这个过程会递归地遍历 DOM 树,为每个可见节点计算出其坐标(x, y)和盒子大小(width, height)。
布局树与 DOM 树并非一一对应:
display: none的节点不会出现在布局树中,因为它不占据任何空间。- 伪元素(如
::before,::after)虽然不在 DOM 树中,但会出现在布局树中。- 某些不可见的元素(如
<head>)也不会出现在布局树中。
这一步的操作也常被称为 重排 (Reflow) 或 回流。
阶段四:分层 (Layering)
为了优化渲染,特别是在处理复杂的动画、滚动等场景时,浏览器会引入分层的概念,类似于 Photoshop 中的图层。
- 输入:布局树
- 输出:图层树 (Layer Tree)
主线程会遍历布局树,识别出需要被提升为单独图层的节点,并创建一棵图层树。
为什么需要分层? 将页面内容分成多个图层后,如果某个图层发生变化(例如一个 transform 动画),浏览器只需要重绘这一个图层,而不需要影响其他图层,从而极大地提升了渲染效率。
哪些情况会创建新图层? 拥有特定 CSS 属性的节点会被浏览器视为独立的图层,常见情况包括:
- 拥有 3D 变换的元素 (
transform: translateZ(...),rotate3d(...)等) - 拥有
position: fixed的元素 - 拥有
will-change属性的元素 - 拥有
z-index且position为absolute/relative的元素 - 拥有
opacity,filter等属性的元素
阶段五:生成绘制指令 (Paint)
分层完成后,渲染主线程 的工作基本告一段落。它会为每个图层生成一份详细的绘制指令列表。
- 输入:图层树
- 输出:每个图层的绘制指令列表 (Paint Records)
这个指令列表非常简单,类似于:
- 将画笔移动到 (x1, y1)
- 绘制一个矩形,颜色为蓝色
- 将画笔移动到 (x2, y2)
- 写入文本 "Hello"
关键点:此时,渲染主线程 会将这些指令列表提交给 合成线程 (Compositor Thread),接下来的工作将由合成线程和 GPU 接管。
阶段六、七、八:合成与绘制 (Tiling, Raster, Draw)
合成线程 接手后,将执行最后的光栅化和绘制流程。
6. 分块 (Tiling)
由于图层可能很大,一次性处理整个图层会非常耗时。因此,合成线程会将每个图层划分成若干个小的 图块 (Tiles)(通常是 256x256 或 512x512 像素)。
7. 光栅化 (Rasterization)
- 合成线程将每个图块的绘制任务分发给 光栅线程 (Raster Threads)。
- 光栅线程负责将图块转换成位图,即填充每个像素点的具体颜色信息(RGB 值)。
- 这个过程通常会优先处理视口(viewport)内的图块,并且可以由 GPU 加速完成,速度极快。
8. 绘制 (Draw / Compositing)
- 所有图块光栅化完成后,合成线程会收集到每个图块的位图信息,这些信息被称为 绘制四边形 (Draw Quads)。
- 合成线程将这些
Draw Quads信息打包,通过一个命令发送给 GPU。 - GPU 接收到命令后,将所有图层、所有图块的位图信息按照正确的位置、顺序 composite(合成)起来,最终显示在屏幕上。
三、 相关面试题
Q1: 什么是重排 (Reflow) 和重绘 (Repaint)?
- 重排 (Reflow):当元素的几何属性(如宽度、高度、边距、位置)发生变化,导致浏览器需要 重新计算布局树,这个过程就是重排。重排是一个成本非常高的操作,因为它会从 布局 (Layout) 阶段开始,完整地走一遍后续流程。
- 重绘 (Repaint):当元素的非几何属性(如颜色、背景、
visibility)发生变化,浏览器会跳过布局和分层阶段,直接 重新生成绘制指令 并执行后续的绘制流程。 - 关系:重排一定会触发重绘,但重绘不一定会触发重排。重排的性能开销远大于重绘。
Q2: 为什么 transform 的效率高?
传统的 left 或 margin 动画会不断修改元素的几何属性,频繁触发 重排 (Reflow),这会消耗大量 渲染主线程 的计算资源,导致动画卡顿。
而 transform 的效率之所以高,是因为:
- 分层:对元素应用
transform通常会将其提升为一个独立的合成层。 - 跳过重排和重绘:
transform的变化不会触发渲染主线程的重排和重绘。 - 合成线程处理:动画的每一帧变化,都是由 合成线程 直接处理的。它只需要向 GPU 发送新的矩阵变换信息来移动图层,完全不占用主线程。
下面的代码可以清晰地展示这个区别:
<style>
.ball { width: 50px; height: 50px; border-radius: 50%; background: red; position: absolute; }
.ball-transform { top: 50px; left: 0; animation: move-transform 3s linear infinite; }
.ball-left { top: 150px; left: 0; animation: move-left 3s linear infinite; }
@keyframes move-transform { from { transform: translateX(0); } to { transform: translateX(300px); } }
@keyframes move-left { from { left: 0; } to { left: 300px; } }
</style>
<body>
<div class="ball ball-transform"></div>
<div class="ball ball-left"></div>
<button onclick="blockMainThread()">阻塞主线程5秒</button>
<script>
function blockMainThread() {
const start = Date.now();
while (Date.now() - start < 5000) {
// 死循环,阻塞渲染主线程
}
}
</script>
</body>当你点击按钮时,会发现使用 left 动画的小球卡住不动了(因为主线程被阻塞,无法进行重排),而使用 transform 动画的小球依然流畅运动(因为它由未被阻塞的合成线程驱动)。