当浏览器的网络线程收到HTML文档后,会产生一个渲染任务,并将其传递给渲染线程的消息队列,在事件循环的作用下,渲染主线程取出消息队列中的渲染任务,开始渲染流程。
渲染流程分为多个阶段,分别是:构建DOM树、样式计算、布局、分层、绘制、分块、光栅化、(合成)、画、像素信息,每个阶段都有明确的输入和输出,上个阶段的输出会成为下个阶段的输入。输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线。
根据文档定义构建一棵 DOM 树,DOM 树是由 DOM 元素及属性节点组成的。
为什么要构建DOM树?这是因为浏览器无法直接理解和使用HTML,所以需要将HTML转换为浏览器能够理解的结构-DOM树
解析的过程中遇到CSS解析CSS,遇到JS执行JS。
为了提高解析效率,浏览器在开始解析之前,会启动一个预解析的线程,率先下载HTML中的外部CSS文件和外部JS文件。如果主线程解析到link位置,此时外部的CSS文件还没有下载解析好,主线程不会等待,继续解析后续的HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本原因。
如果主线程解析到script位置,会停止解析HTML,转而等待JS文件下载好,并将全局代码解析执行完后才能后,才能继续解析HTML。这是因为JS代码的执行过程可能会修改当前的DOM树,所以DOM树的生成必须暂停。这就是JS会阻塞HTML解析的根本原因。
好了,现在我们已经生成 DOM 树了,但是 DOM 节点的样式我们依然不知道,要让 DOM 节点拥有正确的样式,这就需要样式计算了。
样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成:
- 解析CSS
- 转换样式表中的属性值,使其标准化
- 计算DOM节点的具体样式
和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。
为了加深理解,你可以在 Chrome 控制台中查看其结构,只需要在控制台中输入 document.styleSheets
,然后就看到如下图所示的结构:(可以尝试一下其他的形式哦~~~)
CSS文件的主要来源:
渲染引擎会把获取到的CSS文本全部转换成CSSStyleSheet结构中的数据,上图三种来源的CSS都可以转换成CSSStyleSheet结构中的数据。
现在我们已经把现有的CSS文本转化为浏览器可以理解的结构了,下一步就要对其进行属性值的标准化操作
什么是标准化?就是将如2em、blue、bold这些不容易被渲染引擎理解的的属性值转换成渲染引擎容易理解的、标准化的属性值,这个过程就是属性值标准化。
这就涉及到CSS的继承规则和层叠规则了。
CSS继承就是每个DOM节点都包含有父节点的样式。
将过程2中的样式表最终应用到DOM节点的效果如下:
CSS层叠规则(Cascading rules)是用于解决相同选择器对同一个元素应用多个样式规则时的冲突情况的一套规则。
浏览器会根据选择器的优先级确定应用哪个样式规则。当多个样式规则具有相同的优先级时,后定义的规则将覆盖先定义的规则。
总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。**那么接下来就需要计算出 DOM 树中可见元素的几何位置,例如节点的宽高、相对包含块的位置,我们把这个计算过程叫做布局。**布局树的每个节点都记录了x,y坐标和边框尺寸。
DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。
为了构建布局树,浏览器大体上完成了下面这些工作:
大部分时候,DOM树和布局树不一定是一一对应的
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。
如果是首次渲染,那就是分层,如果是更新操作,那就是update layer tree
现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?答案依然是否定的。
因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。浏览器页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。
我们可以通过开发者工具中的图层来查看当前页面的分层
可以移动或者旋转查看当前页的分层
图层和布局树的节点之间有什么关系?
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如下图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。
什么条件下渲染引擎会为特定节点创建新图层呢?
页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。
当文字内容很多,超出规定的显示区域,设置了overflow属性,这时候就产生了剪裁,渲染引擎会把没有被裁掉的的内容显示出来。出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
这一步实际上是更新Render Layer的层叠排序关系
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,主线程会为每一个层单独产生绘制指令集,用于描述这一层的内容如何画出来。
绘制列表只是用来记录绘制顺序和绘制指令的列表,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。
那么接下来合成线程是怎么工作的呢?
合成线程将每个图层栅格化,然后只需要把可视区的内容组合成一帧,展示给用户即可。由于可能有的图层可能和整个页面一样大,所以合成器线程将图层先进行分块,然后将每个块发送给栅格化进程,栅格进程栅格每个图块,并将它们存储在GPU内存中。
什么是视口(viewport)?
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。基于这个原因,合成线程就会将图层划分成图块(tile)。
合成线程会从线程池中拿出多个线程来完成分块工作
栅格化是将视口附近的图块来优先生成位图(优先处理靠近视口的块), 而图块是栅格化执行的最小单位。栅格化的本质是坐标变换、几何离散化、然后再填充。
名词解释:位图
就是数据结构里常说的位图。你想在绘制出一个图片,你应该怎么做,显然首先是把这个图片表示为一种计算机能理解的数据结构:用一个二维数组,数组的每个元素记录这个图片中的每一个像素的具体颜色。所以浏览器可以用位图来记录他想在某个区域绘制的内容,绘制的过程也就是往数组中具体的下标里填写像素而已。
渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:
当图块栅格化完成后,合成线程拿到每个层、每个块的位图后,生成一个个的指引(quad)信息,这些信息记录了图块在内存中的位置,和每个位图应该画到屏幕的那个位置,以及会考虑到旋转、缩放等变形,变形发生在合成线程,与主线程无关,这就是transform效率高的本质原因。合成线程会根据指引(quad)信息生成合成器帧,然后将合成器帧传送给浏览器进程,接着浏览器进程将合成器帧传送到GPU进程,然后GPU渲染展示到屏幕上,最终完成屏幕成像。
名词解释:event
Input event handlers
Compositor线程接收用户的交互输入(比如touchmove、scroll、click等)。然后commit给Main线程,这里有两点规则需要注意:
- 并不是所有event都会commit给Main线程,部分操作比如单纯的滚动事件,打字等输入,不需要执行JS,也没有需要重绘的场景,Compositor线程就自己处理了,无需请求Main线程
- 同样的事件类型,不论一帧内被Compositor线程接收多少次,实际上commit给Main线程的,只会是一次,意味着也只会被执行一次。(HTML5标准里scroll事件是每帧触发一次),所以自带了相对于动画的节流效果!scroll、resize、touchmove、mousemove等事件,由于Compositor Thread的机制原因,都会每一帧只执行一次。
reflow的本质就是重新计算layout树。当进行了会影响布局树的操作后,需要重新计算布局树,会引发布局。为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当JS代码全部完成后再进行统一计算。所以,改动属性造成的reflow是异步完成的。也同样因为如此,当JS获取布局属性时,就可能造成无法获取到最新的布局信息。浏览器在反复权衡下,最终决定获取属性立即reflow。
例如:获取父级元素左边界的偏移值(Element.offsetLeft),但在此之前我们进行了样式或者dom修改,这个操作还在队列中没有执行,那么浏览器为了让我们获取正确的offsetLeft(虽然之前的操作可能不会影响offsetLeft的值),就会立即执行队列里的操作。
所以我们知道了,就是这个特殊操作会影响浏览器正常的执行和渲染,假设我们频繁执行这样的特殊操作,就会打断浏览器原来的节奏,增大开销。
而这个特殊操作,具体指的就是:
从上图可以看出,如果你通过JavaScript或者CSS修改元素的集合位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。
repaint的本质就是重新根据分层信息计算了绘制指令。当改动了可见洋是皇后,就需要重新计算,会引发repaint。由于元素布局信息也属于可见样式,所以reflow一定会引起repaint。
从上图可以看出,如果修改了元素的背景颜色,那么布局阶段将不被执行,因为并没有引起集合位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率回避重排操作高一些。
因为transform既不要布局也不要绘制,渲染引擎将跳过布局和绘制,只执行渲染流程最后一个draw阶段。
由于draw阶段在合成线程中,所以transform的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响transform的变化。
集合位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率回避重排操作高一些。
因为transform既不要布局也不要绘制,渲染引擎将跳过布局和绘制,只执行渲染流程最后一个draw阶段。
[外链图片转存中…(img-lRndy2wJ-1696423853929)]
由于draw阶段在合成线程中,所以transform的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响transform的变化。