现代浏览器工作原理(三)——渲染流程

渲染流程就是浏览器展示页面的流程,在这个流程里,浏览器通过获取到服务端返回的HTML、CSS和JS等静态资源进行处理并展示出页面,这里经过的流程按时间线分的话有:构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成,是为一个渲染流水线。其中面试的时候经常会问到的回流(重排reflow)和重绘(repaint)也是出在这个渲染流程里,下面来逐个步骤的说明讲解。

一、构建DOM树(DOMTree)

我们知道,浏览器是无法直接理解和使用HTML的,为了能让浏览器理解我们写的HTML,所以需要将HTML转换为浏览器能够理解的结构,这个就被称为DOM树。这个过程里,我们输入HTML代码,在经过HTML解释器进行解析之后,就会输出DOM树,如下图,左图是HTML代码,右图是经过HTML解释器解析之后的DOM树:

我们可以在控制台上输入document也可以看到经过HTML解析器解析之后完整的DOM树的结构:

图中的 document 就是 DOM 结构,你可以看到,DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。

在这一步通常会遇见几个面试中会问到的问题,比如JavaScript会不会阻塞DOM树的解析,现在就来回答一下。当我们在body标签里加入JavaScript代码,比如script标签下就是一段写好的JavaScript代码,这个时候HTML解释器执行到这里是会执行完JavaScript脚本,之后在往下解析,所以是会阻塞DOM解析器的解析。如图:

生成了DOM树之后,下一步就是给节点计算样式。

二、样式计算(StyleSheets)

正如上面所说,其实样式计算就是为了给每个DOM树的结点计算样式。

首先,是把CSS转换成浏览器能够理解的结构,即样式表。因为浏览器也是不理解我们写的CSS代码的。通常我们的CSS样式的来源主要是有三种方式,一是直接在style标签上写上样式,第二是在link里引入外部的样式文件,第三则是在元素的style属性内写上内联的样式。CSS解释器会将我们写入的CSS样式转成styleSheets这种浏览器能够看懂的结构。我们可以在浏览器控制台里输入document.styleSheets来看到经过解析生成的styleSheets结构:

在这个图里,我们可以看到css样式转换成styleSheets结构,这个结构具备了查询和修改功能,为以后的样式操作提供了便利。

其次,转换样式表中的属性值,使其标准化。比如我们写的样式里,字体大小fong-size设置的单位为rem,字体颜色color设置是一个单词red或者是一个色值#fffffff,字体粗细font-weight设置为bold等等,这些值在浏览器中是不会被理解的,需求经过解释器转换成渲染引擎容易理解切标准化的计算值,这个过程就是属性值标准化。如刚刚说的rem会转换成px,色值会转换成rgb()函数,字体粗细的bold转换成具体的值700。

最后,计算出DOM树中每个节点的具体样式。节点的样式其实是会受到继承和层叠的影响,继承的影响主要是来自父级及之上的祖先级元素的样式影响,甚至还有可能是浏览器默认的样式(UserAgent)的影响。而层叠规则的影响主要是不同优先级的层叠规则存在覆盖的问题,其实层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。所以,在样式计算阶段是要遵循继承和层叠。

经过上面三个步骤之后,每个节点的样式会保存到ComputedStyle里,可以在控制台的Computed里查看元素的ComputedStyle,也可以通过getComputedStyle(element)这个方法来查看某个元素的ComputedStyle:

在这里也会有一个面试遇到的一个问题,这个问题是上面问题的升级版。在HTML的script里有个代码块,如果这个代码的作用是通过document.getElementById等形式获取一个元素,并给该元素的某个属性赋值,如ele.style.color = 'red',这个时候上面说了js会影响DOM树的解析,如果这里访问某个元素的样式或者改写它的样式,都会等待CSS资源加载完成才会执行访问某个元素的样式或者改写它的样式,在这里CSS的加载也会影响DOM树的渲染。如图:

关于DOM树的渲染下面就来讲讲它的布局。

三、布局阶段(Layout)

上面的dom树和styleSheet(可以理解成CSSOM树,不过这是16年前的概念)构建完成之后,就到了布局阶段。在布局阶段就是生成一个布局树(可以理解成渲染树renderTree),这个布局树只包含了可见的元素,即body元素及它元素内所有可见的元素。在构建布局树的过程中,浏览器执行的工作有:遍历DOM树种的所有可见节点,忽略到不可见的节点,把可见的节点添加到布局树上,然后把styleSheets上对应节点的computedStyle给赋值上去,构建完布局树之后就进行布局计算。

在布局计算阶段,浏览器就会计算布局树节点的坐标位置,并将这些信息保存在布局树中。不过到这里其实还没完,渲染流程会走到下一步的分层里,进行更加精细的操作。

在这里也有一个面试经常问到的一个问题,如果在HTML里引入一个JavaScript文件,而不是上面那样只是在script标签里写入JavaScript代码,我引入的JavaScript文件里会有读写dom的操作,比如document.write,其实在HTML解析器解析文本的时候,遇到脚本文件是会发起请求,等待请求完成之后再立即执行脚本,这里是会影响到dom树的解析的,如图:

这里还引申了关于script标签的async和defer标签的区别和使用,这个问题在本文最后讨论。

四、分层

在页面里,开发者可能会写入一些比较复杂的特效动画,如复杂的3D转换、页面滚动或使用z-index做z轴排序等,这个时候就产生了层级,为了能够更好和更方便的实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并且生成一棵对应的图层树(layerTree)。下面我们以微博为例,打开控制台,选择layers就能看见图层:

我们可以看见渲染引擎给页面分了很多层,这些图层一层一层按顺序叠在一起后就形成了我们看到的页面。不过图层树和布局树是有不同的,布局树是把所有可见节点元素都添加上去,而图层树的话如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。要想要成为一个单独的图层就要拥有层叠上下文属性的元素会被提升为单独的一层。什么是层叠上下文呢,在mdn里是这样解释的:

我们假定用户正面向(浏览器)视窗或网页,而 HTML 元素沿着其相对于用户的一条虚构的 z 轴排开,层叠上下文就是对这些 HTML 元素的一个三维构想。众 HTML 元素基于其元素属性按照优先级顺序占据这个空间。

形成层叠上下文可以是通过opacity、transform、z-index等等,详细的话请看mdn的这篇文章:层叠上下文

五、图层的绘制(paint)

完成了布局树和图层树之后,渲染引擎就会开始对图层树上的每个图层进行绘制了。在图层绘制中,每个图层的绘制会拆分成很多小的指令,然后再把这些指令按照顺序组成一个待绘制的列表。具体的指令你可以打开控制台选择layers然后展开document,点击paint profiler即可查看到绘制流程。

 

六、栅格化(raster)

走到了这一步后,其实上面五步属于渲染进程里的主线程,上面的绘制也只是生成绘制列表,具体的绘制操作是主线程把绘制列表提交给合成线程,让合成线程里绘制图层,所以下面这两步都在合成线程里。

那为什么会有栅格化这个概念呢,在讲解这个概念之前我们先了解一下场景。开发者开发一个页面的时候可能图层会非常的大,需要滚动滚动条很久,如果渲染引擎把整个图层给全部绘制出来的话,其实没有必要,因为开销太大。所以合成线程会将图层划分为图块(title),然后合成线程会按照视口(viewport,指的是整个页面中用户看到的可视区域部分)附近的图块来优先生成位图,将图块生成位图的过程就是栅格化。在栅格化里,图块是其最小的单位。由于栅格化的时候计算量特别大,通常栅格化是渲染进程发送生成图块的指令给GPU,使用GPU加速,生成的位图保存于GPU内存中;如果没有GPU或者GPU资源占满,则渲染进程里则会维护一个栅格化的线程池用来生成位图。

七、合成(composite)

所有的图块完成光栅化之后,合成线程会发出一个DrawQuad的绘制图块指令给浏览器进程,浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。到这里之后,浏览器的渲染进程就会把页面给输出出来。

上面讲到了回流和重绘,在这两个概念里,还有一个合成的概念。上面说到回流和重绘其实是在渲染进程里的主线程里的,而合成了其实是在渲染进程的合成线程和栅格化线程池里,如果gpu资源没占满会调用到gpu进程进行优化,所以合成它是不会阻塞渲染进程里的主线程的。那什么情况是会直接走合成这个步骤而不走回流或者重绘呢?触发合成的有transform、opacity、will-change和3D transforms等。

八、其他的补充

1.HTML解析器的解析过程

当浏览器从服务端上获得静态资源文件的时候,第一步就是先将字节流按照meta头上的设置转换成对应的字符。比如meta的charset属性设置为utf-8,这个时候整个html文本就被转换成utf-8字符。

第二步就是通过分词器通过分词器将上面的html文本转化成大量的token,一个标签会分为开始的token和结束的token,并去除其中无关的字符,如空格,这一步是为词法解析。

第三步即为语法解析阶段。这时HTML解析器维护了一个token的栈结构(默认压入document节点)和创建一个根为document的dom树。以上面构建dom树的HTML代码为例:

1.首先先往token栈里压入startTaghtml的token,这个token是html标签的开始token,对应的dom树就在document上添加了html节点。

2.接下来压入head标签的开始token,同理对应的dom树也会在html节点上接入head节点。

3.然后token栈里压入meta标签的开始token,dom树里也在head节点里接入meta。由于识别到了它的结尾,在token栈里,meta的token出栈,这时下标指向head的token,所以dom树里的指针也对应的指向了head。

4.然后token栈里压入title标签的开始token,过程也和上面一样,由于识别到了它的结尾,所以title标签也在token栈里出栈,dom树里在往head节点添加上title节点后指针指回head节点。

5.当token栈要压入的元素是head的结束token的时候,head会出栈,对应的dom树里指针会指回html。然后如此类推的操作直到生成一个完整的dom树。

2.script标签里defer和async的用法和区别

上面我们知道了在HTML里,如果往body里的元素中间插入script标签的话,HTML解析器是解析到script标签之后是会下载并执行脚本,这里是会阻塞到我们的dom树的继续渲染的,所以这里就延申了script标签里的defer和async的用法。

defer:defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。

async:async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。在加载多个JS脚本的时候,async是无顺序的加载,而defer是有顺序的加载。如图:

DOMContentLoaded事件是HTML的dom树解析完成之后触发的,而load事件是页面所有静态资源加载完成后触发的。

发布了72 篇原创文章 · 获赞 44 · 访问量 15万+

猜你喜欢

转载自blog.csdn.net/Tank_in_the_street/article/details/105245861