浏览器知识点整理(八)DOM 和 JS、CSS 不得不说的故事

2,130 阅读11分钟

前言

上一篇文章 是在宏观视角下介绍了浏览器的渲染流程,而其中一些值得细讲的地方还没有详细整理,比如:

  • DOM 树是怎么生成的?
  • 在解析 HTML 过程中遇到了 JavaScript 会怎么样?
  • 在 JavaScript 代码里面加入对 CSS 的处理又会怎么样呢?
  • 为什么要把 CSS 放在头部和把 JavaScript 放在尾部?

HTML 解析器

我们知道,从网络进程传给渲染进程的 HTML 文件字节流是无法直接被渲染引擎理解的,所以需要将其转化为渲染引擎能够理解的内部结构 DOM。那么渲染引擎是如何将 HTML 字节流转化为 DOM 结构的呢?

原来,在渲染引擎内部,有一个叫 HTML 解析器HTMLParser)的功能模块,它的职责就是 负责将 HTML 字节流转换为 DOM 结构。那么在解析的这个过程中,HTML 解析器是等整个 HTML 文档加载完成之后开始解析的,还是随着 HTML 文档边加载边解析的?

它的答案是:HTML 解析器并不是等整个文档加载完成之后再解析,而是网络进程加载了多少数据,HTML 解析器便解析多少数据

详细的流程就是:

  • 网络进程 接收到服务端返回的响应头之后,根据响应头中的 content-type 字段来判断文件的类型,如果 content-type 字段的值是 text/html,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个 渲染进程
  • 渲染进程 准备好之后,网络进程和渲染进程之间会 建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传给 HTML 解析器

DOM 树是怎么生成的?

在网络中传输的内容是 01 这些 字节流 数据,而将 字节流转换为 DOM 需要三个阶段。

  • 在第一个阶段,是通过分词器将字节流转换为标记(Token

这一过程在词法分析中叫做 标记化(tokenization)。标记还是字符串,它是构成代码的最小单位。在这一过程中会将代码分拆成一块块,并给这些内容打上标记,便于理解这些最小单位的代码是什么意思。

Token 分为 Tag Token文本 Token,而 Tag Token 又分 StartTagEndTag

顾名思义,比如 在 <div>文本</div> 中,<div> 就是 StartTag,标记为开始一个 div 标签;</div> 就是EndTag,标记为结束一个 div 标签;而 文本 就是 文本 Token,标记为标签内的文本。

  • 第二阶段是将这些标记转换为 DOM 节点

  • 最后一个阶段是将这些 DOM 节点根据不同 DOM 节点之间的联系构建为一棵 DOM 树

后续的两个阶段是同步进行的,它将 Token 解析为 DOM 节点,并将 DOM 节点逐步添加到 DOM 树中

在 HTML 解析器内部维护了一个 Token 栈结构,该 Token 栈主要 用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。

具体的处理过程如下:

  • 如果分词器解析出来是 StartTag Token,会将它压入栈中,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  • 如果分词器解析出来是 文本 Token,那么 会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中的,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  • 如果分词器解析出来的是 EndTag Token,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就 StartTag div 从栈中弹出,表示该 div 元素解析完成。
  • 通过分词器产生的新 Token 就这样 不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。

可以结合以下简单的 HTML 代码来直观地理解这个过程:

<html>
<body>
  <div>文本</div>
  <div>test</div>
</body>
</html>

如下动图(通过 PPT 实现的动画,通过 GifCam.exe 录屏的 gif 动图,新技能 Get√)所示:

GIF 2021-6-19 18-30-27.gif

  • HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底;
  • 以上 HTML 代码以字节流的形式传给了 HTML 解析器,经过分词器处理,解析出来的第一个 Token 是 StartTag html,会被压入到栈中,并同时创建一个 html 的 DOM 节点,将其加入到 DOM 树中;
  • 然后按照同样的流程解析出来 StartTag bodyStartTag div,按顺序压入栈中;
  • 当解析出来第一个 div文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点;
  • 再接下来,分词器解析出来第一个 EndTag div,这时 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div,如果是则从栈顶弹出 StartTag div
  • 按照同样的规则,一路解析,最终 Token 清空,DOM 节点构建成了一棵 DOM 树。

以上是简单 DOM 树的生成过程,不过在实际生产环境中,HTML 文件不会这么简单,它可能会既包含 CSS 和 JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个示范 Demo 要复杂。

在解析 HTML 过程中遇到了 JavaScript 会怎么样?

当在上面的 HTML 代码中加入 JS 代码:

<html>
<body>
  <div>文本</div>
  <script>
    let div1 = document.getElementsByTagName('div')[0];
    div1.innerText = 'hello world!';
  </script>
  <div>test</div>
</body>
</html>

这里在两个 div 标签之间插入了一段 JS 脚本,会使这段 HTML 代码的解析编的不一样。在 <script> 标签之前,所有的解析流程还是和之前介绍的一样,但是解析到 <script> 标签时,渲染引擎会判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为 接下来的 JS 脚本可能会修改当前已经生成的 DOM 结构

通过前面 DOM 生成流程分析,当解析到 <script> 标签时,其 DOM 树结构如下所示:

image.png

这时候 HTML 解析器会暂停工作,JavaScript 引擎介入,并执行 script 标签中的这段代码,因为这段 JS 代码修改了 DOM 中第一个 div 中的内容,所以在执行这段代码之后,div 节点内容已经变为 hello world!脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM

解析 HTML 生成 DOM 树是渲染引擎的工作,而执行 JS 代码是 JS 引擎的工作,它们是运行在不同的线程上面的;当通过 JS 去操作 DOM 时,会涉及到两个线程之间的通信,并带来一些性能上的损耗

如果 script 里引入的是 JavaScript 文件呢?

上面的例子是在 HTML 中直接内嵌 JS 脚本,而我们通常都是在 HTML 中通过 src 属性引入 JS 文件,如下面代码:

// index.js
let div1 = document.getElementsByTagName('div')[0];
div1.innerText = 'hello world!';
<html>
<body>
  <div>文本</div>
  <script type="text/javascript" src='index.js'></script>
  <div>test</div>
</body>
</html>

这里把内嵌 JS 代码修改成了通过 JS 文件加载,整个执行流程还是一样的,执行到 script 标签时,还是会暂停 HTML 的解析,去切换到 JS 引擎执行 JS 代码。不一样的是,这里执行 JS 脚本时,需要先下载这个 JS 文件

而去下载 JS 文件的话,过程就变得复杂了,会受到网络环境、JS 文件大小等因素的影响, 在下载的这个过程中 HTML 的解析是暂停的,所以我们说 JS 文件的下载会阻塞 DOM 树的生成

这其实也是我们看到很多 JS 脚本代码放在 HTML 尾部加载 的原因,就是为了防止下载 JS 文件阻塞 DOM 树的生成。

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

如何减少 JavaScript 脚本阻塞 DOM 树生成的情况呢?

那我们知道,引入 JS 脚本会阻塞 DOM 树的生成,那么是否有相关的策略来规避减少这种情况的出现呢?

那自然是有的,最直接的做法是 将 JS 脚本放在文档尾部加载,还有就是 可以将 JS 脚本设置为异步加载,通过 asyncdefer 来标记代码,使用示例如下:

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

asyncdefer 虽然都是异步加载,不过会有一些差异:

  • 使用 async 标志的脚本文件一旦加载完成,会立即执行;
  • 而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
    • DOMContentLoaded 事件是在初始的 HTML 文档被完全加载和解析完成之后被触发,而无需等待样式表、图像和子框架的完全加载。

另外,其它的一些优化比如通过减少 JS 文件体积来加快下载速度,使用 CDN 来加速 JavaScript 文件的加载,使用缓存等等,都是可以的。

在 JavaScript 代码里面加入了对 CSS 的处理会如何呢?

在 JS 代码中加入对 CSS 的处理又会怎么样呢?代码如下:

// index.css
div {
  color: blue;
}
<html>
<head>
  <link rel="stylesheet" href="index.css">
</head>
<body>
  <div>文本</div>
  <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'hello world!' // 需要 DOM
    div1.style.color = 'red'  // 需要 styleSheets/CSSOM
  </script>
  <div>test</div>
</body>
</html>

在以上代码中,JS 代码出现了操纵 CSS 的语句(div1.style.color = 'red'),所以 在执行 JS 之前,需要解析 JS 代码上面所有的 CSS 样式,如果是引用了外部的 CSS 文件,还需要等待这个外部的 CSS 文件下载完成并解析生成 CSSOM 树(styleSheets)之后才能执行 JS 脚本。

而 JS 引擎在解析 JS 之前,是不知道 JS 是否操纵了 CSSOM 的,所以 渲染引擎在遇到 JS 脚本时,不管该脚本是否操纵了 CSS,都会先下载 CSS 文件并解析生成 CSSOM 树之后,才会去执行 JS 脚本

JS 脚本依赖 CSS ,这又多了一个阻塞过程。JS 会阻塞 DOM 树的生成,而 CSS 又会阻塞 JS 的执行,所以在实际的工作中需要重点关注 JS 文件和 CSS 文件加载的顺序,使用不当是会影响到页面性能的。

为什么要把 CSS 放在头部和把 JavaScript 放在尾部?

首先要区分开阻塞 DOM 树的生成和阻塞页面的渲染,这是两回事。

DOM 树的生成是发生在 HTML 解析过程中的事情,而页面的渲染显示是经过了构建 DOM 树、样式计算、获取布局树、生成图层树、图层绘制、栅格化处理、合成显示等一系列操作之后在浏览器显示出最终页面的过程,要区分开来。

关于阻塞 DOM 树的生成和阻塞页面的渲染,我的理解是这样的:

  • CSS 本身并不会直接阻塞 DOM 树的生成,会阻塞页面的渲染
  • JS 会阻塞 DOM 树的生成,也会阻塞页面的渲染
  • CSS 会阻塞 JS 的执行,所以会间接阻塞 DOM 树的生成

CSS 本身并不会阻塞 DOM 树的生成,因为 CSSOM 树的生成和 DOM 树的生成是 并行处理 的;

CSS 会阻塞页面的渲染,因为布局树是需要 DOM 树和 CSSOM 树合成的,由于布局树的生成依赖 DOM 树和 CSSOM 树,因此 CSS 必然会阻塞 DOM 的渲染;更加严谨的说法是 CSS 会阻塞布局树的生成,进而阻塞 DOM 的渲染。

JS 会阻塞 DOM 树的生成,在上面已经有说明了,因为 JS 有修改 DOM 的可能,而对于页面渲染 JS 无论放在那里都会阻塞页面的渲染(可以尝试一下:页面底部执行一个 for 循环 100000 次打印,在打印结束前,页面是没有内容的),因为 JS 有可能操作修改 DOM,所以浏览器一定会等 JS 执行完再渲染。

CSS 会阻塞 JS 的执行,所以会间接阻塞 DOM 树的生成,当生成 DOM 时遇到了 JS,而 JS 又需要获取 CSS 样式再去修改 DOM,那么这时候需要去加载 CSS 文件资源,是会阻塞 DOM 树的生成的,所以说是 CSS 会间接阻塞 DOM 树的生成。

script 放到尾部,把 CSS 放在头部,那么 JS 越晚解析,CSS 就越不容易阻塞 JS,从而让构建 DOM 树的所需要的时间相对减少了(因为构建 DOM 树和构建 CSSOM 树是并行的,只要没有遇到 JS 或者说 JS 里面没有操作 CSS 的动作,两者之间不会相互影响)。最后等到加载 JS 的时候,CSSOM 可能已经构建完成了,这个时候 CSS 不会影响 JS 的执行,那么能阻塞 DOM 树构建的就只有 JS 加载解析这个因素了。

所以会把 CSS 放在文档的头部,尽可能的提前加载 CSS;把 JS 放在文档的尾部,这样 JS 不会阻塞 DOM 树的生成。CSS 解析也尽可能的不去阻塞 JS 的执行,从而使页面尽快的渲染完成

总结

  • HTML 解析器负责将 HTML 字节流转换为 DOM 结构
  • 生成 DOM 树的过程是先将 HTML 字节流转化为标记(Token),然后将 Token 解析为 DOM 节点,并将 DOM 节点逐步添加到 DOM 树中
  • 在解析 HTML 的过程中遇到了 script 会暂停解析,然后交由 JS 引擎去执行 JS 代码,因为 JS 有操作修改 DOM 的可能。
  • 通过 asyncdefer 来标记代码,将 JS 脚本设置为异步加载,减少阻塞生成 DOM 树的可能
  • CSS 会阻塞 JS 的执行,因为 JS 有操作 CSSOM 的可能
  • 把 CSS 放在文档的头部,尽可能的提前加载 CSS;把 JS 放在文档的尾部或异步加载,这样 JS 不会阻塞 DOM 树的生成。CSS 解析也尽可能的不去阻塞 JS 的执行,从而使页面尽快的渲染完成

以上就是这篇文章的全部内容了,如有错误,敬请指正,欢迎来评论区交流!

浏览器系列专栏目录