# 浏览器渲染原理
# 渲染
所谓的渲染,就是通过 HTML 文件,计算出页面上每一个像素点的颜色。
当我们在浏览器地址栏输入一个 URL 之后,回车,就会出现一个页面,这个过程,我们可以拆分为网络和渲染两部分。
网络部分其实就是通过 http 请求,拿到 HTML 文件的过程。这里我们主要说渲染过程。
在网络进程拿到 HTML 文件之后,他会将 HTML 文件包装成一个渲染任务,加入到消息队列中。之后,在事件循环机制的作用下,JS 主线程(也就是渲染主线程)会拿到这个任务,并开始执行。自此,渲染过程开始。
整个渲染流程可以分为 8 个阶段,分别是:
- 解析 HTML(HTML文件 --> DOM 树 和 CSSOM 树)
- 样式计算(DOM 树 和 CSSOM 树 --> 渲染树)
- 布局(渲染树 --> 布局树)
- 分层(布局树 -> 图层)
- 生成绘制指令(图层 -> 绘制指令)
- 分块(绘制指令 -> 图层块)
- 光栅化(图层块 -> 位图)
- 绘制(位图 -> 最终渲染)
每一个阶段都有明确的输入和输出,上一个阶段的输出,会成为下一个阶段的输入
# 解析 HTML
渲染主线程开始执行渲染任务时,第一步就是解析 HTML 文件。本阶段的输入与输出:
- 输入:HTML 文件
- 输出:DOM 树 和 CSSOM 树
之所以要将 HTML 文件处理成 DOM 树 和 CSSOM 树,主要是为了后续步骤的方便,操作一个超长的字符串肯定远远没有直接操作一个对象来的方便。同时,也开放了 JS 操作 DOM 树 和 CSSOM 树的能力。
# 处理 HTML
首先我们拿到的 HTML 文件,其实就是一个字符串。因为计算机之间的网络传输,实际上只能传输二进制字节码数据。浏览器接收到这些字节码数据之后,将其转换为字符串。
拿到字符串之后,我们需要对字符串进行拆解,给每一个单元做上标记,也就是标记化。标记化的作用是为了理解每一个小的字符串单元的语义。
标记化完成之后,我们就可以在此基础上构建出 DOM 树了。
# 处理 CSS
在 HTML 文件中,有很多不同的样式表,常见的有:
- 内部样式(
<style>) - 外部样式(
<link>) - 内联样式表(
style=‘ ’)
我们来看最终生成的 CSSOM 树的结构:
根节点 StyleSheetList 是所有样式表的集合,根节点下的子节点 CSSStyleSheet 是各个不同的样式表,每个样式表中有多个 CSSStyleRule,也就是 CSS 选择器。每个选择器对象包含了选择器的名称和属性。
CSS 的处理有一个特殊之处,在于 link 所对应的外链 CSS。我们都知道,link 标签下载和解析 CSS 不会阻塞 HTML 的解析和渲染,这是为什么呢。
原来,为了提升 HTML 的解析效率,在开始解析 HTML 文件之前,浏览器会先开启一个预解析线程,该线程会快速浏览当前的 HTML 文件,将其中的外链 CSS 和 JS 交给网络进程去下载。
当渲染主线程解析到 link 标签时,不会等待 CSS 资源的下载和解析,而是直接继续向下解析 HTML 文件。
当网络进程将 CSS 资源下载完成之后,会将 CSS 文件交给预解析线程进行解析,解析完成之后,再将结果交给渲染主线程。
所以,CSS 的下载和解析不会阻塞 HTML 文件解析,因为他们根本不在同一个线程中!CSS 的下载在网络进程中执行,CSS 的解析由预解析线程执行,而 HTML 的解析由渲染主线程执行。
# 处理 JS
对于 JS 代码,尤其是外链的 JS 文件,我们在前面已经说过,会由预解析线程提前将下载任务交给网络进程去进行下载。
但是,与 CSS 文件不同的是,预解析线程不能执行或处理 JS 文件。因为 JS 在设计之初就被设计成了单线程的语言,一般来说,同一时间,只能有一处地方在执行 JS 代码。而浏览器的多个线程中,只有渲染主线程可以执行 JS 代码。预解析线程不能,也没有能力去执行 JS 代码。
所以,最终的 JS 文件,依然要交给渲染主线程去进行处理。
另外,JS 和 CSS 相比还有一个独特的能力,那就是 JS 代码可以修改当前 DOM 树结构。所以,当渲染主线程解析到 <script> 标签时,必须停止解析,阻塞起来,等待当前 <script> 对应的 JS 资源下载完成,然后执行这个 JS 文件,执行完成之后,才可以继续向下解析 HTML 文件。
所以,JS 的加载和执行都是会阻塞 HTML 文件的解析的:
- 加载阶段:虽然 JS 资源是在网络进程中下载的,但是渲染主线程必须阻塞起来,等待 JS 资源下载完成
- 执行阶段:渲染主线程转而执行 JS 代码,线程被 JS 占用,无法继续解析 HTML 文件。
所以说,如果想要首屏加载快,就不应该把 JS 文件放在 HTML 文件的头部。
对于放在头部的 JS 文件,我们可以采用 defer 和 async 这两个资源提示符来避免主线程的阻塞。当主线程解析到含有 defer 和 async 的 script 标签时,不会阻塞等待 JS 资源的下载,而是继续向下解析 HTML 文件。
这两个资源提示符的差别在于 JS 文件的执行时机:
- async 无法保证 JS 文件的执行顺序和时机,他的执行时机完全取决于 JS 文件何时下载完成,一旦下载完成,就会立即被执行
- defer 可以保证 JS 文件的执行顺序,他会在 JS 文件下载完成,并且 HTML 文件已经解析完成,且该
<script>前的所有<script>标签都已经被执行完成之后,才会执行。
# 样式计算
样式计算过程,就是遍历每一个 DOM 树中的节点,分析这些节点受哪些样式规则的影响,它最终的样式应该是什么。本阶段的输入和输出为:
- 输入:DOM 树 和 CSSOM 树
- 输出:每个节点都带有样式规则的 DOM 树(渲染树)
这个过程比较复杂,具体过程可以参阅之前的博客[《CSS 属性值的计算过程》](https://nwu23787.github.io/vuepress-blog/chat/CSS 属性值的计算.html),这里我们简单说一次此过程的流程:
- 收集所有样式表中的样式规则
- 根据来源和重要性,进行层叠排序
- 对于排序相同的样式规则,比较其优先级
- 对于没有声明值,或声明值为继承/默认值的属性,进行继承或赋予默认值。
在这个过程完成之后,也就是经过样式计算之后,我们可以得到一个带有样式规则的 DOM 树,也可以看做是 CSSOM 树 和 DOM 树合并之后的结果。
# 布局
得到带有样式规则的 DOM 树之后,会进行布局过程。此阶段的输入输出为:
- 输入:带有样式规则的 DOM 树
- 输出:布局树
在布局阶段,浏览器会遍历 DOM 树的每一个节点,根据节点上的 CSS 规则计算出每一个节点的几何信息,包括节点的宽、高和位置信息等。布局树上每个节点会有它在页面上的 x,y 坐标以及盒子大小(bounding box sizes)的具体信息。
另外,布局树中的每一个节点是一个 C++ 对象,JS 是获取不到的。我们只能间接的获取一些信息,比如 document.body.clientWidth 等等
布局树中的每一个节点,最终都是要渲染到页面上的,所以,布局树中只含有可见的节点。例如设置了 display: none 的节点,在布局树中是不存在的。
此外,我们知道,伪元素是不存在于 DOM 树中的,也无法使用 JS 获取和操作。但是,一个可见的伪元素是可以存在于布局树中的。
# 分层
分层就是将要绘制的页面划分为多个图层,是浏览器对页面的一种优化方式,因为用户会和页面进行交互,那么页面就可能会发生变化,在发生变化时,如果不分层,我们就需要重新渲染整个页面。
本阶段的输入输出为:
- 输入:布局树
- 输出:分层结果
分层的好处在于,当一个层发生变化之后,我们只需要对这个层做后续的处理,而不影响其他层。
至于如何分层,每个浏览器的策略不同,我们不必关心。我们在编写代码的时候,也无法手动指定分层。
但是,我们可以通过一些 CSS 属性,来影响浏览器的分层结果,至于最后是否分层,还是需要浏览器根据自身的策略去自行决策。
可以影响分层结果的 CSS 属性:
- z-index
- opacity
- transform
- will-change
也就是和堆叠上下文有关的属性,都可以在一定程度上影响浏览器的分层结果。
其中,will-change 对浏览器分层结果的影响较大,也就是添加了 will-change 属性的元素,其被单独分为一层的可能性较大。
will-change 会提前告知浏览器,该元素的哪一个属性会改变,从而让浏览器提前做好优化的准备,例如分层。下面我们来举个例子,对于这样一个 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>
</head>
<body>
<div class="main">
<p>Lorem.</p>
<p>Ullam.</p>
<!-- 省略一百个p标签-->
...
</div>
</body>
</html>
我们可以在浏览器控制台的图层工具中看到分层结果:
可以看到,这个 HTML 页面被浏览器分为了两层,其中 #19 这一层,其实是侧面的滚动条。因为滚动条的位置经常变化,所以经常被单独设为一层。
如果我们希望将 .main 盒子单独设置为一层,那么我们可以尝试使用 will-change 去改变浏览器的分层结果:
.main{
will-change:opacity
}
此时我们再查看分层结果,可以看到,.main 盒子已经被单独设置为了一层。
# 生成绘制指令
分层工作结束之后,主线程会为每个层生成绘制指令集,指令集用于描述这层的内容如何绘制出来。
本阶段的输入输出为:
- 输入:分层结果
- 输出:绘制指令集
生成的绘制指令类似于 canvas :
context.beginPath(); // 开始路径
context.moveTo(10, 10); // 移动画笔
context.lineTo(100, 100); // 绘画出一条直线
context.closePath(); // 闭合路径
context.stroke(); // 进行勾勒
此过程只是生成了绘制指令,并没有执行这些指令。
在生成绘制指令之后,主线程就不再参与页面的渲染了,而是交给其他线程去进行后续出来,其中比较重要的就是合成线程。
# 分块
生成绘制指令后,渲染主线程将绘制指令交给合成线程。合成线程是渲染进程中的一个线程,合成线程会根据绘制指令,将每一个图层进行分块。
分块的主要目的是为了区分不同区域绘制时的优先级。比如在视口内或靠近视口的块,我们可以优先绘制。而不在视口内的块或距离视口较远的块,在绘制时的优先级就低。
分块并不是合成线程亲力亲为的,合成线程会开启多个分块线程,同时进行分块操作。
# 光栅化
光栅化就是将每一个块变成位图,或者是就是计算出块中每一个像素点的颜色。
此阶段的输入输出:
- 输入:图层块
- 输出:位图
光栅化并不是由合成线程完成的,而是提交给 GPU 来完成的,因为 GPU 的运算速度快。
GPU 会同时开启多个线程来处理光栅化,并优先处理靠近视口的图层块(这样就是为什么上一步要进行分层)。处理完成之后,会将所有的位图信息返回给合成线程。
# 绘制
合成线程拿到所有的位图之后,会计算出所有的位图相对于屏幕的位置(之前我们计算的位置都是相对于页面而言的),生成指引信息。生成指引信息之后,合成线程会把这些指引信息交给 GPU 进程,由 GPU 进程调用硬件渲染页面。
在计算位图的位置时,还会考虑到 transform 变换。正因为 transform 在最后一个绘制阶段才被处理,而且并不由渲染主线程处理,所以效率才会高,无论渲染主线程如何忙碌,都不会影响 transform 。
# 总结

- 解析 HTML:网络进程将 HTML 文件交给渲染主线程,主线程解析 HTML 文件,解析开始前启动一个预解析线程,提前加载外链 JS 和外链 CSS。最终将 HTML 文件解析成 DOM 树和 CSSOM 树。
- 计算样式:渲染主线程通过层叠、继承和默认值两个过程,计算出每个 DOM 节点的最终样式,合并到 DOM 树上,最终生成渲染树(带有 样式规则的 CSSOM 树)
- 布局:渲染主线程根据渲染树,计算出每个元素的宽高、位置等布局信息,生成一颗布局树,布局树中只含有可见元素。
- 分层:渲染主线程根据布局树,将页面分为多个图层,每个图层之间互不影响。一个图层发生变化,只需要对这个图层做后续的处理,而不影响其他图层。
- 生成绘制指令:渲染主线程为每一个图层生成对应的绘制指令,并将这些绘制指令交给合成线程。
- 分块:合成线程根据 绘制指令,将页面分块。分块的目的是为了确定渲染时的优先级,在渲染时,靠近视口的块会优先渲染。
- 光栅化:合成线程将分好的图层块交给 GPU 进程,GPU 进程调用多个线程进行位图计算,计算完成之后,将位图传递回合成线程。
- 绘制:合成线程根据位图,计算位图相对于屏幕的位置,并生成指引信息。并将指引信息传递给 GPU 进程,GPU 进程再调用硬件进行页面渲染。