浏览器渲染原理
渲染的时间点
浏览器的网络线程获取到网页的 HTML 后,会向渲染主线程的消息队列中添加一个渲染任务。在事件循环机制的作用下,渲染主线程接收到了渲染任务,便开启了渲染流程。
渲染流程
现代浏览器的渲染流程分为以下几个阶段,每个阶段都有明确的输入输出,它们按顺序执行,最终将 HTML 字符串转变为屏幕上的像素:
- 解析 HTML(Parse HTML)
- 样式计算(Compute Style)
- 布局(Layout)
- 分层(Layer)
- 绘制(Paint)
- 分块(Tiling)
- 光栅化(Raster)
- 屏幕绘制(Draw)
解析 HTML(Parse HTML)
HTML 只提供语义化,样式全部由 CSS 定义
HTML 中的元素通过嵌套可以形成非常明确的层级嵌套关系,每个父元素都可能拥有任意多个子元素,这是一个典型的树形结构,HTML 中的元素形成的结构就被称为 DOM 树。
与之对应的,层叠样式表 CSS 也具有树形结构,称作 CSSOM 树(样式表对象具有一个parentStyleSheet
属性,可以形成一个树形的结构,但通常只具有有限的层级)。
以下是样式表的一个示例(来自百度document.styleSheets[0]
):
/* 以下省略了大部分的属性,只留下了比较关键的部分属性 */
const styleSheet = {
rules: [ // 一个 ArrayLike 对象
{
cssText: "#form .bdsug { top: 39px; }", // 样式规则的原始文本
selectorText: "#form .bdsug", // CSS 选择器
style: {
0: "top", // 数字 key 储存哪些样式被声明了
top: "39px", // 被声明的样式对应的值
accentColor: "", // 其余样式值均为空字符串
/* 剩余的所有样式 */
},
styleMap: { // 这是一个 map,和 style 中相对应,此处只存储解析后的值
"top": { // 解析后的样式值
value: 39,
unit: "px",
}
}
},
/* 更多 rule 对象 */
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
获取 DOM 树和样式表
- 通过
document
对象可以获取到 DOM 树 - 通过
document.styleSheets
对象可以获取到页面中声明的所有样式表
CSS 并不会影响 DOM 树的结构,所以 HTML 的解析可以和 CSS 的解析并行。
因此,为了提高解析效率,浏览器会启动一个预解析线程,它会快速浏览 HTML,并提前下载其中的外部 CSS 和 JS,并将结果通过消息队列返回给渲染主线程。此外,预解析线程还会负责 CSS 的解析。
当渲染主线程在 HTML 中遇到 CSS 代码时,它会直接跳过,因为下载和解析都在预解析线程中完成了。但是如果遇到的是 JS 代码,默认情况下渲染主线程会停止解析 HTML,转而等待 JS 文件下载完成并执行完全局代码。这是因为 JS 代码有可能引起 DOM 树的变化,为了保证一致性,DOM 树的生成必须暂停。
在这一步完成后,渲染主线程会得到网页的 DOM 树和 CSSOM 树,浏览器的默认样式以及网页的内部样式、外部样式、内联样式都会包含在 CSSOM 树中。
DOM 树和 CSSOM 树结构并不一致!
样式计算(Compute Style)
在这一步,渲染主线程会遍历所有元素,并给它们添加一个计算后的样式(Computed Style)。
层叠
在这一步,首先遇到的一个问题就是样式表中定义的样式可能有冲突,这就需要使用到层叠规则,且层叠规则只有在无法得出结果时才会向下一层传递:
- 比较源的重要性(Importance)
- 比较优先级(Specificity)
- 比较源代码顺序
重要性(W3C)
首先是比较源的重要性,W3C 给出的顺序如下(重要性从高到低):
- CSS 过渡(CSS transitions)
- 重要的用户代理样式(
!important
) - 重要的用户样式(
!important
) - 重要的作者样式(
!important
) - CSS 动画(CSS animations)
- 作者样式
- 用户样式
- 用户代理样式
优先级(W3C)
如果源的重要性相同,此时需要比较优先级,样式的优先级来源于其 CSS 选择器,优先级有三个计数,计数较大者优先级更高,比较顺序为A > B > C
:
- 以下选择器的个数之和记为
A
:id
选择器:#foo
- 以下选择器的个数之和记为
B
:- 类选择器:
.bar
- 属性选择器:
[class*="dark"]
- 伪类选择器:
:hover
- 类选择器:
- 以下选择器的个数之和记为
C
:- 类型选择器:
span
- 伪元素选择器:
::before
- 类型选择器:
特殊规则
- 通配符不计数(
*
) - 如果一个样式没有选择器(内联样式等),则直接获得最高优先级
:is()
、:not()
、:has()
的优先级取括号内优先级最高的一个组合,自身不计:nth-child()
、:nth-last-child
的优先级同上,但加上自身(B += 1
):where
的优先级为0
源代码顺序
当样式的重要性和优先级都相同时,进行最后的比较,也就是在代码中样式的声明顺序。因为这一步不可能再相同了,所以最终生效的样式也就得到了确定,样式的冲突到此解决。
继承
在层叠的阶段,我们解决了样式冲突的问题,但是此时并非所有的样式都有值了。浏览器想要渲染出网页,必须要确保所有元素的所有样式属性都有一个确定的值,而我们在样式表中声明的值通常只占了这些属性的一小部分,此时需要有额外的规则来为这些属性进行填充。
在继承这一阶段,渲染主线程会尝试给默认能够继承的属性赋值为其父元素的属性值:
font-size
、color
等属性会默认继承padding
、background
等属性默认不继承
样式属性的继承不考虑优先级,只从最近的父元素身上得到值!
使用默认值
当能够继承的样式完成继承后,剩余的属性则会使用默认值。浏览器内为所有的属性都设置了一个默认值,所以不必担心在这一步会有样式无法得到默认值。
使用绝对单位
在完成上述步骤后,DOM 元素的绝大部分样式属性都有值了,但它们可能是一些预设值(red
)或相对值(1em
),在这一步都会被转换为绝对单位(rgb
、px
等)。
WARNING
此时并非所有的样式属性都有确定的值了,部分元素的几何属性(width
、height
等)在这一步还无法完全确定,因为它们可能同时受到父元素(子元素设置height: 100%
)和子元素(父元素设置height: fit-content
)的影响,需要在后续的阶段才能完全确定。
布局(Layout)
在这一步,渲染主线程会根据带有样式的 DOM 树进行计算,得出一棵布局树,布局树中所有节点的尺寸和位置(相对包含块)在此时就完全确定了。由于布局树的计算过程非常复杂,因为每一个元素几何信息的变化都可能引起许多其他元素几何信息的变化,在此不再展开。
DOM 树与布局树并不一定完全对应,以下是一些导致变化的规则:
- 布局树中的节点一定具有几何信息:
display: none
的节点因为没有几何信息,所以不在布局树中 - 具有几何信息的节点一定在布局树中:
::before
等伪元素如果具有几何信息,虽然它不在 DOM 树中,但在布局树中存在 - 内容(文本)必须在行盒中:默认情况下
div
是块盒,如果其内部有文本信息,则布局树中的div
和文本信息之间会多出一个匿名行盒 - 行盒和块盒不能相邻:默认情况下如果
div
和文本信息相邻,文本信息的父节点下会先添加一个匿名块盒(与div
相邻),匿名块盒下会再添加一个匿名行盒(内容必须在行盒中),匿名行盒中才是文本信息
分层(Layer)
分层是浏览器进行的一步优化,即使不分层,页面也能正常渲染
现代网页,在绝大多数情况下都是动态的,而页面一旦发生变化,渲染主线程就需要进行重绘,给用户呈现变化后的页面。如果不进行分层,重绘就需要将整个页面重新绘制一遍,这时可能就会重绘了许多不必要的、没有变化的内容,为了提高重绘的效率,渲染主线程将整个页面进行了分层,当页面发生变化时,只需要重绘发生变化的那一层,其它层不需要重绘,从而提高了重绘的效率。这种做法类似 PS 中的图层,一系列图层通过组合、覆盖最终形成了一个完整的图片。
浏览器的分层策略非常复杂,我们也无法直接控制分层结果,但是可以通过一些 CSS 属性影响分层结果:
- will-change
- transform
- opacity
- z-index
- 其他与堆叠上下文相关的属性
绘制(Paint)
在绘制这一步,渲染主线程会为分层结果的每一层生成绘制指令,用来描述这一层该如何绘制。这些绘制指令类似 canvas,或者说 canvas 就是浏览器暴露出来的一些可以直接使用的绘制指令。
到这一步位置,渲染主线程的工作基本完成了
分块(Tiling)
通常情况下,浏览器的视口的尺寸是远远小于网页的尺寸的,为了尽快地给用户呈现出网页,浏览器会优先将用户可以看到的页面画出来,在这之前就需要先进行分块。
浏览器首先会在渲染进程内启动合成线程,渲染主线程完成绘制后,会将每一层的绘制信息交给合成线程。合成线程会从线程池中取出一些线程作为分块器,分块器会并行地将图层进行分块,并最终由合成线程合并成为分块信息。
光栅化(Raster)
合成线程在完成分块之后,会对每一块进行光栅化。在这一步合成线程会通过 GPU 进程来调用 GPU 的资源加速光栅化,并最终得到页面的位图信息。
合成线程会优先处理靠近视口的块,以尽快给用户呈现网页
屏幕绘制(Draw)
合成线程得到每一层、每一块的位图信息后,会生成一个个的指令,说明位图该绘制在屏幕的哪个位置,并且其中会含有旋转、缩放等变形信息。
最终,合成线程通过 GPU 进程调用系统的硬件资源完成屏幕的绘制。
变形发生在屏幕绘制这一步,不会影响到渲染主线程,这就是 CSS transform
效率高的本质原因
重排(Reflow)
当我们动态地修改了 DOM 或 CSSOM,导致元素的几何信息发生变化之后,会导致页面元素的位置发生变化,因此渲染主线程需要重新计算布局树,并重新执行之后的流程。
为了避免连续多次的操作导致反复 reflow,浏览器会合并这些操作,在 JS 执行完成后在进行 reflow,所以改动 DOM 和 CSSOM 导致的 reflow 是异步进行的。这也会导致 JS 代码中修改了布局后无法立刻获取到最新的布局信息。为此,当我们尝试获取布局信息时,浏览器会立即触发 reflow。
重绘(Repaint)
当我们动态地修改了可见的样式(例如:color
等属性),页面的呈现效果会发生变化,因此渲染主线程需要根据分层信息重新生成绘制指令,并重新执行之后的流程。