你了解页面渲染吗?

704 阅读14分钟

来看一看浏览器的渲染流程

当我们编写好html,css,js等文件后,经过浏览器就会显示出我们想要看到的页面。现在我们就来看看这些文件是如何转化成页面的。

由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们将这一个处理流程叫做渲染流水线,其大致流程如下图:

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。下来我们依次来看一下各个子阶段。

构建DOM树

什么是DOM?

浏览器是无法直接理解和使用HTML,它需要将其转化为他能理解的结构,那便是DOM树。 DOM 和 HTML 内容几乎是一样的,但是和HTML不同的是,DOM是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。

DOM的有什么作用?

  1. DOM是页面生成的基本数据结构
  2. 从Js脚本视角来看,DOM提供了Js脚本操作的接口,通过这套接口,Js脚本便可对DOM结构进行访问进而改变DOM结构,及其样式。
  3. 从安全视角来看,DOM是一道安全防护线,一些不安全的内容在DOM解析阶段就被拒之门外了。

DOM树是如何生成的?

下面我们来看一下DOM树的构建过程是怎么样的。

DOM树构建过程
在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。首先我们需要知道的是HTML解析器不是等待HTML文件全部加载完成后在解析的,而是其从网络进程加载了多少数据,HTML解析器便解析多少数据。下面我们来看一看具体流程:

  1. 当网络进程接收到响应头之后,会根据响应头中的content-type字段来判断文件的类型,当其值是“text/html”,那浏览器便会判断这是一个HTML类型的文件,然后它为该请求选择或者创建一个渲染进程。后来渲染进程便会和网络进程之间建立一个数据共享的通道,用于数据传输。
  2. 网络进程是以字节流的方式来传输数据的,而后序我们如何将其转化为DOM呢?我们会进行这样几个阶段。首先其需要通过分词器先将字节流转换为一个个 Token,分为 Tag Token 和文本 Token。后来我们需要将 Token 解析为 DOM 节点(这块涉及栈操作,不做多讲),并将 DOM 节点添加到 DOM 树中。

Javascript是如何影响DOM的?

为了不影响文章的主要思路,这块将放到文章末来说明

现在我们的DOM解构有了,后来需要做的就是让每个DOM节点都有其正确的样式。下面就来看看样式计算

样式计算

样式计算要计算出DOM节点中每个元素的具体样式,这个阶段可分为三个小阶段来完成。

1. 将CSS转化成浏览器能理解的结构

同HTML一样,浏览器也是无法直接理解CSS的。所以当渲染引擎接收到 CSS 文本时,会将 CSS 文本解析为浏览器可以理解的结构CSSOM,这个结构体现在DOM中就是document.styleSheets。这个结构有两个作用:

  1. 提供给 JavaScript 操作样式表的能力。
  2. 布局树的合成提供基础的样式信息。

css文件主要来源有三种:

  1. 通过 link 引用的外部 CSS 文件
  2. style标签标记内的 CSS
  3. 元素style 属性内嵌的 CSS

而当浏览器将css文本转化为styleSheets时,它已经把那三种来源的样式都包含进去了。

2. 将样式表中的属性值标准化

这个是什么意思呢?我们来看一下,当我们需要为一个div盒子的字体设置其大小时,我们可以用很多种方法来设置,比如:

div {
    font-size: 20px;
    font-size: 2em;
    font-size: 2rem;
}

但是像em,rem这些类型数值不容易被渲染引擎理解,所以需要将这些值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

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

计算DOM树中每个节点的具体样式就涉及到了css的继承规则和层叠规则。样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠这两个规则。

继承规则

CSS 继承就是每个 DOM 节点都包含有父节点的样式。

样式层叠

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

这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。 你可以通过下图的方式看到其最后的样式结果。

布局阶段

现在我们已经有了DOM树和DOM树节点元素的最终样式,下来我们还需要一个东西便是DOM元素在页面的几何位置。我们接下来计算出DOM树中可见元素的几何位置,我们把这个计算过程叫做布局。布局阶段也可分为两个子阶段:创建布局树布局计算

1.创建布局树

DOM 树中含有很多不可见的元素,比如head标签,还有使用了display:none属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
  • 而不可见的节点会被布局树忽略掉,如head标签下面的全部内容,再比如某个元素的属性包含 dispaly:none,所以这个元素也没有被包进布局树。

2.布局计算

现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了,而在执行布局操作的时候,会把布局运算的结果重新写回布局树中。

分层

现在我们已经有了布局树,且元素的节点的位置信息也计算了出来,下面就要进入分层阶段了。

页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等。为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。

我们可以这么说,我们所看到的浏览器页面其实并不是二维的其实是三维,他是由很多图层进行叠加而形成的。但是,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。下面我们来看一看满足什么情况下,渲染引擎才会为特定的节点创建新的图层:

  1. 拥有层叠上下文属性的元素会被提升为单独的一层。 我们来具体看一下这些元素都有什么:
  • 设置了z-index的元素 z-index: 3;
  • 明确定位属性的元素 position: fixed;
  • 定义透明属性的元素 opacity: 0.7;
  • 使用CSS滤镜的元素等 filter: blue(5px)
  1. 需要裁减(clip)的地方也会被创建为图层。
    如上图,我们将div盒子设置为150px*150px的大小,而div里面的内容较多,无法全部展示在这个盒子里面,如下图:
    出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,随其出现的滚动条也会被提升为单独的层。

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,下面我们来看渲染引擎如何实现图层绘制的:

渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图:

绘制列表
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。这里我们需要知道的是合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。下来我们再来看一看合成线程都干了什么?

分块

通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。 基于这种情况,合成线程会将每个图层分为大小相同的图块,然后优先绘制最接近视口的图块生成位图,这样就可以大大加速页面的显示速度。

栅格化

而生成位图这个操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。 通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫viz的组件,用来接收合成线程发过来的DrawQuad命令,然后根据DrawQuad命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过这一系列的阶段,编写好的HTML、CSS、JavaScript等文件,经过浏览器就会显示出漂亮的页面了。

补充问题

聊一聊重排,重绘和合成

1.重排(更改了元素的几何属性)

重排
当我们通过Js或CSS修改元素的几何位置属性,浏览器会触发重新布局,解析之后一系列子阶段,这便是重排。重排需要更新完整的渲染流水线,所以其开销也是最大的。

2.重绘(更新元素的绘制属性)

当我通过Js或者CSS更改了某些元素的绘制属性,比如背景颜色,浏览器便会进行重绘。

重绘
重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

3.直接合成阶段

那如果更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。

合成
上图我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

Javascript是如何影响DOM的

我们先来看一段html文件的代码:

<html>
<body>
    <div>test</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'lst'
    </script>
    <div>test</div>
</body>
</html>

我在上面代码的两个div标签中间加了一段js脚本,这样的代码就和之前的解析有点不同了。

  1. 首先它在遇见script标签之前和之前一样正常解析,但是当解析到script标签时,渲染引擎会判断这是一段脚本,此时HTML解析器会停止DOM的解析,因为他不知道当前的脚本文件是否要修改当前的DOM结构。
  2. 当HTML解析器停止工作后,Js引擎会开始介入并执行script标签中的这段脚本,因为这段 JavaScript 脚本修改了DOM中第一个div中的内容,所以执行这段脚本之后,div节点内容已经变为了'lst'。
  3. 当脚本执行完后,HTML解析器会恢复解析过程,知道DOM树构建完成。

我们知道页面中除了可以内嵌Js脚本,有时我们也需要导入js文件,来满足我们的需求。当HTML解析器遇见导入的Js文件时又会怎么做呢?我们还是来先看代码:

//change.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'lst'
<html>
<body>
    <div>test</div>
    <script type="text/javascript" src='change.js'></script>
    <div>test</div>
</body>
</html>

这里我前一个代码一样,只不过我把内嵌 JavaScript 脚本修改成了通过 JavaScript 文件加载。和之前的解析一样,不过这里执行Js代码的时候会先下载Js文件,这时候我们就需要特别来关注Js的下载环境,因为Js的文件下载过程是会阻塞DOM解析的,如果下载耗时过久,就会带来很不好的用户体验。

其实Chrome浏览器在这方面也做了很多的优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

我们现在知道了引入Javascript会阻塞DOM的解析,那有什么方法来来规避他呢?

  1. 我们使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。
  2. 如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码。
 <script async type="text/javascript" src='change.js'></script>
<script defer type="text/javascript" src='foo.js'></script>

这里我们需要注意的是 async 和defer标记的脚本文件的区别

  1. async: async 标志的脚本文件一旦加载完成,会立即执行;
  2. defer: 使用了 defer标记的脚本文件,需要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

DOMContentLoaded,这个事件发生后,说明页面已经构建好 DOM 了,这意味着构建 DOM 所需要的 HTML 文件、JavaScript 文件、CSS 文件都已经下载完成了。

当我们了解了上述的内容我们再来看另一种情况:

//style.css
div {color:red}
<html>
    <head>
        <style src='style.css'></style>
    </head>
<body>
    <div>test</div>
    <script>
            let div1 = document.getElementsByTagName('div')[0]
            div1.innerText = 'lst' //需要DOM
            div1.style.color = 'yellow'  //需要CSSOM
        </script>
    <div>test</div>
</body>
</html>

上面代码中,JavaScript 代码出现了 div1.style.color = 'yellow' 的语句,它是用来操纵 CSSOM 的,所以在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。

而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。

所以说 JavaScript 脚本是依赖样式表的,这又多了一个阻塞过程。

参考文章

李兵老师浏览器工作原理与实践课程

渲染流程上下篇

DOM树:JavaScript是如何影响DOM树构建的?