深入浅出浏览器渲染

一、浏览器如何渲染网页

要了解浏览器渲染页面的过程,首先得知道一个名词——关键路径渲染。关键渲染路径(Critical Rendering Path)是指与当前用户操作有关的内容。例如用户在浏览器中打开一个页面,其中页面所显示的东西就是当前用户操作相关的内容,也就是浏览器从服务器那收到的HTML,CSS,JavaScript等相关资源,然后经过一系列处理后渲染出来web页面。实际抽象出来理解可以将这些步骤看作一个函数,就输入HTML,经过一层层的处理,最后输出像素。

而浏览器渲染的过程主要包括以下几步:

  • 浏览器将获取的HTML文档并解析成DOM树。
  • 将 css 文件处理成 StyleSheet 对象,从而进行样式计算。
  • 根据dom树和StyleSheet 生成布局树。
  • 根据具体的节点信息对页面进行分层处理,生成图层树
  • 根据图层树生成绘制列表
  • 合成线程通过主线程提交的绘制列表对图层进行分块,并进行栅格化,生成位图
  • 合成位图,并将其显示

具体如下图过程如下图所示:

渲染流程.PNG

需要注意的是,以上几个步骤并不一定是一次性顺序完成,比如 DOM 被修改时,亦或是哪个过程会重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。而在实际情况中,JavaScript和CSS的某些操作往往会多次修改DOM或者CSSOM。

值得注意的的是,在每个阶段,都会有对应的输入,处理,以及输出。下面我们就来详细的了解一下这几个过程及需要注意的事项。

二、浏览器渲染网页的具体流程

2.1 构建DOM树

因为浏览器无法直接使用HTML/SVG/XHTML,因此当浏览器客户端从服务器那接受到HTML文档后,就会遍历文档节点,然后对这些文档节点通过HTML解析器进行解析,最后生成DOM树,所生成的 DOM 树结构和HTML标签一一对应。需要注意的是,在这其中HTML解析器会进行诸如:标记化算法,树构建算法等操作,其中的规范即遵循了W3C的相应规范,也都有浏览器引擎自己的一些特定的操作,详情可以翻阅这篇非常著名的文章:

How Browsers Work: Behind the scenes of modern web browsers

在此阶段,输入的即是一个HTML文件,然后会有浏览器的HTML解析器对其进行解析,输出树形结构的DOM树。值得注意的是,HTML解析器并不是等整个文档全部加载完之后才开始解析的,而是网络进程加载了多少数据,HTML解析器就会解析多少数据。相当与在网络进程与渲染进程之间会在这期间建立一个数据共享的管道,网络进程每次收到数据都会将其转发到渲染进程,从而保证渲染进程中的HTML解析器可以源源不断的获取到用于渲染的数据。这个过程可以理解为下方这个过程:

[图片上传失败...(image-79abab-1574600053183)]

  1. 将字节流通过分词器转化为 Token
  2. 根据 Token 生成节点 node
  3. 根据生成的节点,组成 DOM 树

每个页面的DOM树,我们也可以直接通过在控制台输入document 来进行访问:

企业微信截图_d70fd07c-795f-4117-ba6f-4daa428c5718.png

对于DOM树,我们需要注意以下几点:

  1. DOM 树从内容上来看和 HTML 几乎一模一样,但 DOM 是保存在内存中的树形结构,可以通过 JavaScript 来查询和修改。

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">document.getElementsByTagName("h2")[0].innerText = "Hello World"</pre>

  1. display:none 的元素也会在 DOM 树中。
  2. 注释也会在 DOM 树中
  3. script 标签会在 DOM 树中
  4. DOM 树在构建的过程中可能会被 CSS 和 JS 的加载而执行阻塞。

此外DOM 树在构建的过程中可能会被 CSS 和 JS 的加载而执行阻塞,也就是我们常说的阻塞渲染。这是因为HTML文件是通过HTML解析器转化成 DOM 树的,而在HTML解析器中如果遇到了 JavaScript 脚本,HTML 解析器会先执行 JavaScript 脚本,待这个脚本执行完成之后,再继续往下解析。因此我们常说,将script标签放在body下面,通常就是基于这种考虑的。但为什么CSS也有可能会阻塞DOM树的构建呢,可以看下面一个栗子:

<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);"><html> <head> <style type="text/css" src = "demo.css" /> </head> <body> <p>demo</p> <script> const p = document.getElementsByTagName('p')[0] p.innerText = 'hello world' p.style.color = 'red' </script> </body> </html></pre>

由于任何script代码都能改变HTML的结构,因此HTML每次遇到script都会停止解析,等待JavaScript脚本被执行完成之后,再进行接下来的解析,而当我们通过 JavaScript 去进行样式操作的时候,这个 JavaScript 脚本执行完成的前提条件就成了需要现将样式信息确定下来。因此在这种情况下,HTML解析器能否继续执行下去,以及继续执行的时间,也需要取决与这个CSS文件给不给面子了。这也是我们常说的,别在 JavaScript 中操作样式的原因。

为了优化这种情况,现代浏览器也做了一些优化,比如预解析操作。当渲染引擎接收到字节流后,会开启一个预解析线程,用来分析 HTML文件的代码中的JS,CSS文件,解析到相关文件的时候,预解析进行会提前下载这些资源。

对于处理这种事情,避免阻塞的产生,我们也有以下几点可以注意的:

  • 在引入顺序上,CSS 资源先于 JavaScript 资源。
  • JavaScript 应尽量少的去影响 DOM 的构建。
  • 可以将 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码

2.2 计算样式

在构建渲染树时,需要计算每一个呈现对象的可视化的属性值。而这个过程就被称为样式计算或者计算样式。这个过程主要是为了 DOM 树中每个节点的具体样式,大致可分为三大步骤:

  1. 将 CSS 解析为浏览器能理解的 StyleSheet
  2. 转换样式表中的属性值,使其标准化
  3. 计算出 DOM 树中每个节点的具体样式

2.2.1 将 CSS 解析为浏览器能理解的 styleSheet

和html一个道理,浏览器也无法直接去理解我们所写的那些CSS样式,因此浏览器在接收到CSS文件后,会将CSS文件转换为浏览器所能理解的 StyleSheet。转化了的 StyleSheet 我们同样也可以通过控制台来访问:

image.png

在这个过程中需要注意的是:

  1. CSS解析可以与DOM解析同时进行。
  2. CSS解析与 script 的执行互斥 。
  3. 在Webkit内核中进行了script执行优化,只有在JS访问CSS时才会发生互斥。
  4. CSS样式不管是来自于 link 的外部引用,还是style标记内的CSS,亦或是元素的style属性内嵌的CSS,都会被解析成styleSheets。

2.2.2 转换样式表中的属性值,使其标准化

在将CSS文转化为浏览器能够理解的 styleSheet 后,就需要对期进行进行属性值的标准化操作了。这里的标准化的意思就是,我们在写css文件的时候,会写一些语义化的属性比如:red/bold等等。但其实这些词对于渲染引擎来说,却不是那么好理解的。因此在进行计算样式之前,浏览器还会这对这些不怎么好计算的值进行标准化,将其转化为渲染引擎容易理解的词,比如将red转化成为 rgb(255, 0, 0)等等。

2.2.3 计算出 DOM 树中每个节点的具体样式

计算出 DOM 树中每个节点的具体样式主要涉及的就是CSS继承规则和层叠规则了,对于继承规则其实比较好理解,就是,每个DOM节点都包含的父节点的样式。

而层叠规则也就是样式层叠就有点麻烦了,MDN是这么描述层叠的:

层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位,CSS的全称层叠样式表正是强调了这一点。

层叠的具体细节在这里也不展开讲了(我自己现在还没搞清楚。。。),大家可以去CSS层叠看看其内部的一些规则。

在有了css继承规则和层叠规则后,样式计算的这个阶段就会在这两个规则的基础上对 DOM 节点中的每个元素计算处具体的样式,这个阶段中最终输出的结果会保存在 ComputedStyle 中,这个同样可以通过控制台进行查看:

image.png

2.3 布局阶段

通过前面两个阶段,我们已经得到了DOM树以及DOM树中具体每个元素的样式了,但对于每个元素所处的几何位置我们现在还是不知道的,因此接下来要做的就是计算出DOM树中可见元素的几何位置。这个过程可以分为两个阶段:

  1. 创建布局树
  2. 布局计算

2.3.1 创建布局树

由于DOM树还包含很多不可见的元素,比如head标签,script标签,以及设置为display:none的属性,因为浏览器势必不能将所有的dom树的元素都全部拿来进行布局计算,因此在这个阶段,浏览器会额外构建一颗只包含可见元素的布局树。在构建布局树期间,浏览器大体会进行以下一些工作:

  • 遍历DOM树中的所有可见节点,并将这些节点加到布局中。
  • 将所有不可见节点忽略掉

下面两个需要注意:

  • display: none的元素不在Render Tree中
  • visibility: hidden的元素在Render Tree中

2.3.2 布局计算

在已经获取了所有可见元素的树之后,就可以计算布局树节点的几何位置了。HTML是基于流的布局方式,因此大多数情况下,只需要进行一次遍历即刻计算出页面的几何信息。通常来说,处于流靠后的元素不会影响到靠前位置元素的几何特征,因此在进行布局计算的时候,通常是按从左至右,从上至下的顺序遍历文档(只是通常而言,比如表格啥的就不是这样)。

布局计算是一个递归的过程,它从根节点出发,然后递归遍历部分或所有的节点,为每一个需要计算的呈现器计算几何信息。这个计算量无疑是庞大的,因此为了避免一些较小的更改也会触发页面的整体布局计算,浏览器将布局方式分为了全局布局和增量布局。

  1. 全局布局:全局布局是指触发了整个布局树的布局计算的布局,包括:屏幕大小改动,字体大小改动等
  2. 增量布局:增量布局是指当某个呈现器发生改变了,只对相应的呈现器进行布局计算。

在执行完布局计算后,会将布局计算的结果写入布局树中,因此这个过程可以理解为一种装饰者模式,输入输出都是一个布局树,只是在这个过程中会将布局计算的结果给加进去。

2.4 分层

在有了布局树之后,浏览器的还是不能直接根据布局树来将页面给画出来,因为页面中还存在中一些特殊的效果,比如页面滚动,z-index等。为了能够方便的实现这些花里胡哨的功能,渲染引擎还需要进行一个分层处理,将特定节点生成转筒的图层,并生成一个图层树(LayerTree),这个我们也能通过浏览器的面板看到:

image.png

如上图所示,浏览器的页面实际上被分成了多个图层,这些图层叠加在一起就形成了我们最终所看到的页面。需要注意的是,并不是布局树中的每一个节点都会包含一个图层,因此如果一个节点没有所对应的图层,那么它就会从属于父节点的图层。如果一个节点需要有自己的图层,通常需要满足以下联合条件

  1. 拥有层叠上下文属性的元素
  2. 需要剪裁(clip)

2.5 图层绘制

在确定好图层之后,浏览器的渲染引擎会对图层树中的每个图层进行绘制,渲染引擎会将一个图层的绘制拆封成很多个小的绘制指令,然后会将这些绘制指令按照一定顺序组成一个待绘制列表。和布局相同,绘制也分为全局和增量两种,也是为了避免部分图层的改变而需要对整个图层树进行绘制。此外,CSS也对绘制顺序做了规定:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 子代
  5. 轮廓

2.6 栅格化(raster)操作

这里的栅格化是指将图转化为位图。绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际绘制操作是由渲染引擎中的合成线程来完成的。实际过程是当图层对应的绘制列表准备好之后,主线程会将绘制列表提交给合成线程。 合成线程会根据用户所能见的窗口范围对一些划分,将一些大的图层化分为图块。然后合成线程会根据用户所见范围附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。图块是栅格化执行的最小单元,渲染进程维护了一个栅格化的线程池,所有的图块栅格化操作都会在这个线程池里进行。

通常,栅格化会使用GPU进程中的GPU来进行加速,使用GPU进程生成位图的过程叫快速栅格化,通过这个方式生成的位图会被保存在GPU内存中。这样做的好处就在于,当渲染进程的主线程发生阻塞的时候,合成线程以及GPU进程不会受其影响,可以正常运行。这也是为啥有时候主线程卡住了,但CSS动画依然可以风骚依旧的原因。

2.7 合成和显示

在所有的图块都被进行栅格化后,合成线程就会生成绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

三、浏览器渲染网页的那些事儿

3.1 回流和重绘(reflow和repaint)

我们都知道HTML默认是流式布局的,但CSS和JS会打破这种布局,改变DOM的外观样式以及大小和位置。因此我们就需要知道两个概念:

  • reflow(回流):当浏览器发现某个部分发生了变化从而影响了布局,这个时候就需要倒回去重新渲染,大家称这个回退的过程叫 reflow。 常见的reflow是一些会影响页面布局的操作,诸如Tab,隐藏等。reflow 会从 html 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置,以确认是渲染树的一部分发生变化还是整个渲染树。reflow几乎是无法避免的,因为只要用户进行交互操作,就势必会发生页面的一部分的重新渲染,且通常我们也无法预估浏览器到底会reflow哪一部分的代码,因为他们会相互影响。
  • repaint(重绘): repaint则是当我们改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸和位置没有发生改变。

需要注意的是,display:none 会触发 reflow,而visibility: hidden属性则并不算是不可见属性,它的语义是隐藏元素,但元素仍然占据着布局空间,它会被渲染成一个空框,这在我们上面有提到过。所以visibility:hidden 只会触发 repaint,因为没有发生位置变化。

我们不能避免reflow,但还是能通过一些操作来减少回流:

  1. 用transform做形变和位移.
  2. 通过绝对位移来脱离当前层叠上下文,形成新的Render Layer。

另外有些情况下,比如修改了元素的样式,浏览器并不会立刻reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

3.2 几条关于优化渲染效率的建议

结合上文和我看到的一些文章,有以下几点可以优化渲染效率

  1. 合法地去书写 HTML 和 CSS ,且不要忘了文档编码类型。
  2. 样式文件应当在 head 标签中,而脚本文件在 body 结束前,这样可以防止阻塞的方式。
  3. 简化并优化CSS选择器,尽量将嵌套层减少到最小。
  4. 尽量减少在 JavaScript 中进行DOM操作。
  5. 修改元素样式时,更改其class属性是性能最高的方法。
  6. 尽量用 transform 来做形变和位移

参考资料:

https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/