什么是 DOM
从网络传给渲染引擎的HTML文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM。DOM 提供了对 HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用。
- 从页面的视角来看,DOM 是生成页面的基础数据结构。
- 从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容。
- 从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了。
简言之,DOM 是表述 HTML 的内部数据结构,它会将 Web 页面和 JavaScript 脚本连接起来,并过滤一些不安全的内容。
DOM 树如何生成
在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。所以这里我们需要先要搞清楚 HTML 解析器是怎么工作的。
HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据,具体流程如下:
网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是"text/html",那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。
渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据"喂"给 HTML 解析器。你可以把这个管道想象成一个"水管",网络进程接收到的字节流像水一样倒进这个"水管",而"水管"的另外一端是渲染进程的 HTML 解析器,它会动态接收字节流,并将其解析为 DOM。
字节流转换为 DOM 需要三个阶段。
第一个阶段,通过分词器将字节流转换为 Token
V8 编译 JavaScript 过程中的第一步是做词法分析,将 JavaScript 先分解为一个个 Token。解析 HTML 也是一样的,需要通过分词器先将字节流转换为一个个 Token,分为 Tag Token 和文本 Token。上述 HTML 代码通过词法分析生成的 Token 如下所示:
由图可以看出,Tag Token 又分 StartTag 和 EndTag,比如就是<div>
StartTag ,就是</div>
EndTag,分别对于图中的蓝色和红色块,文本 Token 对应的绿色块。
第二阶段+第三阶段
后续的第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。
HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
- 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
- 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
- 如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。
通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。
html
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
这段代码以字节流的形式传给了 HTML 解析器,经过分词器处理,解析出来的第一个 Token 是 StartTag<html>
,解析出来的 Token 会被压入到栈中,并同时创建一个 html 的 DOM 节点,将其加入到 DOM 树中。
这里需要补充说明下,HTML 解析器开始工作时,会默认创建了一个根为 document
的空 DOM 结构,同时会将一个 StartTagdocument
的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTaghtml
Token 会被压入到栈中,并创建一个html
的 DOM 节点,添加到 document 上,如下图所示:
然后按照同样的流程解析出来 StartTagbody
和 StartTagdiv
,其 Token 栈和 DOM 的状态如下图所示:
接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,如下图所示:
再接下来,分词器解析出来第一个 EndTagdiv
,这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTagdiv
,如果是则从栈顶弹出 StartTagdiv
,如下图所示:
按照同样的规则,一路解析,最终结果如下图所示:
通过上面的介绍,相信你已经清楚 DOM 是怎么生成的了。
JavaScript 是如何影响 DOM 生成的
我们再来看看稍微复杂点的 HTML 文件,如下所示:
html
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'hello world'
</script>
<div>test</div>
</body>
</html>
我在两段 div 中间插入了一段 JavaScript 脚本,这段脚本的解析过程就有点不一样了。<script>
标签之前,所有的解析流程还是和之前介绍的一样,但是解析到<script>
标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。
通过前面 DOM 生成流程分析,我们已经知道当解析到 script 脚本标签时,其 DOM 树结构如下所示:
这时候 HTML 解析器暂停工作,JavaScript 引擎介入,并执行 script 标签中的这段脚本,因为这段 JavaScript 脚本修改了 DOM 中第一个 div 中的内容,所以执行这段脚本之后,div 节点内容已经修改为 hello world了。脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM。
以上过程应该还是比较好理解的,不过除了在页面中直接内嵌 JavaScript 脚本之外,我们还通常需要在页面中引入 JavaScript 文件,这个解析过程就稍微复杂了些,如下面代码:
javascript
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'hello world'
再看HTML部分:
html
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
这段代码的功能还是和前面那段代码是一样的,不过这里我把内嵌 JavaScript 脚本修改成了通过 JavaScript 文件加载。其整个执行流程还是一样的,执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。这里需要重点关注下载环境,因为 JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。
不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
再回到 DOM 解析上,我们知道引入 JavaScript 线程会阻塞 DOM,不过也有一些相关的策略来规避,比如使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码,使用方式如下所示:
html
<script async type="text/javascript" src='foo.js'></script>
或者:
javascript
<script defer type="text/javascript" src='foo.js'></script>
async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
现在我们知道了 JavaScript 是如何阻塞 DOM 解析的了,那接下来我们再来结合文中代码看看另外一种情况:
css
//theme.css
div {color:blue}
HTML部分:
html
<html>
<head>
<style src='theme.css'></style>
</head>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'hello world' //需要DOM
div1.style.color = 'red' //需要CSSOM
</script>
<div>test</div>
</body>
</html>
在这个示例中,有一行JavaScript代码div1.style.color = 'red'
,它用来操作CSSDOM。因此,在执行JavaScript之前,需要先解析JavaScript语句上方的所有CSS样式。如果代码中引用了外部的CSS文件,那么在执行JavaScript之前,还需要等待外部的CSS文件下载完成,并解析生成CSSDOM对象,然后才能执行JavaScript脚本。
然而,JavaScript引擎在解析JavaScript之前,并不知道JavaScript是否操作了CSSDOM。因此,当渲染引擎遇到JavaScript脚本时,无论该脚本是否操作了CSSDOM,都会执行CSS文件的下载和解析操作,然后再执行JavaScript脚本。
所以说 JavaScript 脚本是依赖样式表的,这又多了一个阻塞过程。
通过上述分析,我们了解到 JavaScript 的执行会阻塞 DOM 的生成,同时样式文件也会阻塞 JavaScript 的执行。因此,在实际的工程中,我们需要特别关注 JavaScript 文件和样式表文件的使用,因为使用不当可能会影响页面的性能。