1

上文我们知道,一旦文档被提交,渲染进程便开始解析页面和加载资源了。
从本文开始,我们分三篇文章来介绍浏览器的渲染流程

按照渲染的先后顺序会经历下面几个阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

本文我们主要介绍 构建 DOM 树、样式计算、布局阶段这三个部分

构建DOM树

渲染进程收到 HTML 文件是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM。
在渲染引擎内部,使用 HTML 解析器(HTMLParser)将 HTML 字节流转换为 DOM 结构。具体的工作流程如下:

  1. 网络进程接收到content-type=“text/html”响应头之后,为该请求创建一个渲染进程。
  2. 渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,渲染进程通过这个管道读取数据,并同时将读取的数据“吐”给 HTML 解析器
  3. 解析器将数据流解析为 DOM树。

    1. 类似Javascript的AST转换,解析器将数据流解析为 DOM的第一步也是通过分词器将字节流转换为 Token
    <div>test</div>
    StartTag test EndTag // 被解析成这样三个Token
    1. 将 Token 解析为 DOM 节点
    2. 将 DOM 节点添加到 DOM 树中

image.png

也正是因为这种流的传输和解析,导致HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。

与此同时,HTML 解析器维护了一个 Token 栈结构,主要用来计算节点之间的父子关系,生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示(过程有点像那道经典的算法题判断括号是不是匹配解法):

  • 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  • 如果是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  • 如果是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

现在我们有了DOM树之后还远远不够,浏览器还是不知道要如何去“绘画”我们的页面,下面的样式计算部分就是告诉浏览器要如何“绘画”页面

样式计算

我们知道页面中css有三种引入形式:

  • 通过 link 引用的外部 CSS 文件
  • <style>标记内部使用
  • 元素通过style的形式内嵌

无论哪种方式引入,像HTML一样,浏览器都不能理解css,还是需要将css转换为浏览器能理解的结构,因此我们看下样式计算的三个步骤

  1. 把 CSS 转换为浏览器能够理解的结构——styleSheets(就是我们经常说的CSSOM)
    在控制台中通过document.styleSheets可以查看

    image.png

  2. 转换样式表中的属性值,使其标准化
    有了styleSheets,接下来就要对其进行属性值的标准化操作。标准化操作就是将类似"em, rem"计算成“px”,#000转换成rgba等
  3. 计算出 DOM 树中每个节点的具体样式
    有了标准化的属性值之后我们就可以为每个DOM节点计算生成样式属性值了。在计算过程中需要遵守 CSS 的继承(有些属性值会继承父节点)和层叠(优先级计算规则,这里不多介绍)两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
    image.png

布局

现在,我们有 DOM 树和 DOM 树中元素的样式,但仍然不能显示页面,因为我们还不知道 DOM 元素的几何位置信息。
那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。
这个阶段需要完成两个任务:创建布局树(Render Tree)和布局计算。

  1. 创建布局树

    在 DOM 树中会包含部分不会显示的节点,比如 head 标签,还有使用了 display:none 等属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。流程如下:

    • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
    • 而不可见的节点会被布局树忽略掉。
  2. 布局计算

    现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。布局的计算过程非常复杂,我们这里先跳过不讲,等到后面章节中我再做详细的介绍。

javascript是如何影响 DOM 的解析和渲染?

上面的 DOM 构建过程是最简单的结构,当dom中插入或者引入js脚本,那么会如何影响 DOM 的解析和渲染呢,我们通过例子来详细说一下:

内嵌脚本


<html>
    <body>
        <div>test1</div>
        <script>
            let div1 = document.getElementsByTagName('div')[0]
            div1.innerText = '被修改的文字'
        </script>
        <div>test2</div>
    </body>
</html>

两段 div 中插入了一段 JavaScript 脚本,这段脚本的解析过程就有点不一样了。<script>标签之前,所有的解析流程还是和之前介绍的一样,但是解析到<script>标签时,渲染引擎判断这是一段脚本,HTML 解析器暂停工作,JavaScript 引擎介入,并执行 script 标签中的这段脚本,因为这段 JavaScript 脚本修改了 DOM 中第一个 div 中的内容,所以执行这段脚本之后,div 节点内容已经修改为 '被修改的文字' 了。脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM。

引入外部脚本


//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = '被修改的文字'

<html>
    <body>
        <div>test1</div>
        <script type="text/javascript" src='./foo.js'></script>
        <div>test2</div>
    </body>
</html>

我们把内嵌 JavaScript 脚本修改成了通过 JavaScript 文件加载。其整个执行流程还是一样的,执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码。和内嵌脚本的形式不同的是,这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。Chrome 浏览器针对下载做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

使用defer或者async优化

通过上面的例子我们知道JavaScript 脚本的执行会阻止DOM的解析,那么在开发中应该怎么避免呢。
除了使用 CDN 加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积等。最重要的是,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码,使用方式如下所示:


 <script async type="text/javascript" src='foo.js'></script>
 
 <script defer type="text/javascript" src='foo.js'></script>

async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;
而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。

CSS是如何影响 DOM 的解析和渲染?

首先在这里对前文样式计算部分做一个补充。看下面的代码:


//theme.css
div{ 
    color: red;
    background-color:black
}


<html>
    <head>
        <link href="theme.css" rel="stylesheet">
    </head>
    <body>
        <div>test</div>
    </body>
</html>

前面说到引入的外部脚本会有一个与解析的过程,css也不例外,也会对外部引入的css开启一个预解析线程提前下载数据。对于上面的代码,预解析线程会解析出来一个外部的 theme.css 文件,并发起 theme.css 的下载。这里可能会产生一个空白时间段,就是在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。

回到正文,我们再来结合例子看看另外一种情况:


<html>
    <head>
        <style src='theme.css'></style>
    </head>
    <body>
        <div>test1</div>
        <script>
            let div1 = document.getElementsByTagName('div')[0]
            div1.innerText = '被修改的文字' //需要DOM
            div1.style.color = 'red'  //需要CSSOM
        </script>
        <div>test2</div>
    </body>
</html>

该示例中,JavaScript 中 div1.style.color = 'red' 语句用来操纵 CSSOM ,所以在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。
所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。
而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。所以说 JavaScript 脚本是依赖样式表的。

这时我们再变一下,把插入的脚本换成引入脚本的方式:


<html>
    <head>
        <style src='theme.css'></style>
    </head>
    <body>
        <div>test1</div>
        <script src='foo.js'></script>
        <div>test2</div>
    </body>
</html>

我们上图来解释具体的执行过程:
image.png

HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求(这两个文件的下载过程是重叠的,所以下载时间以最久的为准)。后面的流水线就和前面是一样的了,不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再接着继续构建 DOM,构建布局树,绘制页面。

总结

文章到这里,深入浅出渲染流程的第一部分就结束了,回顾上面的内容。我们分析了 DOM 和 CSS 的解析、渲染流程,同时还分析了JavaScript和css是如何阻塞HTML渲染的(首页的白屏时间),真心希望大家多看几遍好好消化。大家敬请期待深入浅出渲染流程的第二部分。


wens
272 声望4 粉丝