浏览器之JavaScript如何影响DOM树构建过程

什么是DOM

网络传输的HTML字节流无法被渲染引擎理解,需要转化为内部可以理解的数据结构就是DOM。DOM提供了对HTML文档结构化的表述。

渲染引擎中,DOM有三个层面的作用:

  • 页面角度,DOM是页面的基础数据结构
  • JS脚本来看,DOM是提供JS脚本操作的接口,通过这道接口JS可以对DOM结构进行访问,从而改变文档的结构、样式和内容
  • 从安全视角来看,DOM是一道安全防线,一些不安全内容再DOM解析阶段就被拒之门外

简单说,DOM是表述HTML的内部结构,它会将Web页面和JS脚本连接起来,并过滤一些不安全的内容。

DOM树如何生成

在渲染引擎内部有一个叫做HTML解析器(HTML Parser)的模块,它的职责就是把HTML字节流转换为DOM结构。

开始介绍HTML解析器之前,先交代一个一个疑问:

“HTML解析器是等整个HTML文档加载完成之后开始解析的,还是随着HTML文档边加载边解析的?”

答案是:网络进程加载了多少数据,HTML解析器便解析多少数据。

不是等加载完全部再解析。

详细的过程是怎么样的呢?

网络进程收到响应头之后,会根据 Content-Type字段来判断文件类型,如果其值是 “text/html”那么浏览器会判断这是一个HTML类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立起一个共享的数据通道,网络进程接收到的数据就直接往这个管道里放,而渲染进程则在管道的另一端不断地读取数据,并且喂给HTML解析器。

解析器动态的接收字节流,并将其解析为DOM。

HTML -> 字节流(Bytes)-> 分词器(Tokens)-> 生成节点(Node)-> DOM

字节流转换为DOM的需要三个阶段

第一个阶段,通过分词器将字节流转换为Token

HTML的字节流需要通过分词器转换为一个个Token, 分为Tag Token和文本Token。

<html>
    <body>
        <div>1</div>
        <div>test</div>
    </body>
</html>

上述的HTML代码通过词法分析生成的token如下

html_parser_token

由图可以看出,Tag Token又分为 StartTag和EndTag。

至于后续第二个和第三个阶段是同步的,需要将Token解析为DOM节点,并将DOM节点添加到DOM树中。

HTML解析器维护了一个Token栈结构,这个栈主要是用来计算点之间的父子关系,再第一个阶段生成的Token会被按照顺序压到这个栈中,具体规则如下:

如果压入到栈中是 StartTag Token, HTML解析器会为该Token创建一个DOM节点,然后将该节点加入到DOM树中,它的父节点就是栈中相邻的那个元素生成的节点。

如果分词器解析出来的是文本Token,那么会生成一个文本节点,然后将该节点加入到DOM树中,文本Token是不需要压入栈中。它的父节点就是当前栈顶Token对应的DOM节点。

如果分词器解析出来是EngTag 解析器会查看Token栈顶是否是 StartTag 如果是则将其从栈中弹出,表示该元素解析完成。

通过分词器产生的新Token就是这样不停的压栈和出栈,整个过程就这样一直下去知道分词器将所有字节流分词完成。

<html>
    <body>
        <div>1</div>
        <div>test</div>
    </body>
</html>

举个例子,这段代码以字节流形式传给HTML解析器,经过分词器处理,解析出来的第一个Token是 StartTag html,节气出来的Token会被压入栈中,并同时创建一个html的DOM节点,将其加入DOM树中。

这里需要补充下,HTML解析器开始工作时,会默认创建一个根微document的空DOM结构,同时会将一个StartTag document的Token压入占地。然后经过分词解析出来的第一个StartTag html Token会被压入栈中,并创建一个html的DOM节点。添加到document上,如下图所示:

html_parser_01

然后按照同样流程解析出来 StartTag body和 StartTag div 其中Token和DOM状态如下

html_parser_02

接下来解析出来是第一个div的文本Token渲染引擎会为该TOken床架你个文本节点,并将该Token添加到DOM中,它的父节点就是当前Token栈顶元素对应的节点,如下图:

html_parser_03

接下来分词解析出第一个EndTag div 这时候解析器HTML回去判断当前栈顶元素是否是是StartTag div如果是,则从栈顶弹出,如图所示:

html_parser_04

按照同样规则,一路解析下去最终如图。

html_parser_05

通过上面介绍,相信你已经知道DOM是如何生成的了,不过在实际生产环境中,HTML源文件中既包含CSS和JavaScript又包含图片、音频、视频等文件,所以产生过程远比上面这个示范复杂多了。不过理解简单的DEMO,就可以分析更复杂的场景。

JavaScript是如何影响DOM生成的

更复杂的例子

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

两端div中间插入了一段JS脚本。这段解析过程就有点不一样了。

HTML解析器展厅工作,JS引擎接入,并且执行script标签中这段脚本,因为这段JS修改DOM第一个div的内容,所以执行这段脚本之后,div的脚本内容已经被修改了。 脚本执行完成之后,html解析器恢复执行过程,继续解析后续内容,直至生成最终的DOM。

以上过程还是比较好理解的,不过除了页面中直接嵌入JS脚本之外,还需要通常在页面引入JS文件,这个解析过程就稍微复杂了些

//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'


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

这段代码还是和之前一样但是把内嵌的JS脚本改成了通过JS文件加载。

整个流程还是一样,但是执行到JS标签的时候,暂停DOM解析,执行JS代码,这里执行JS代码的时候,需要先下载这段JS代码。这里需要重点关注下下载环境,因为JS文件下载过程会阻塞DOM解析,而通常下载又是非常耗时的,会受到网络环境,JS文件大小等因素印象。

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

再回到DOM解析上,我们知道引入JS线程会阻塞DOM,不过也有一些相关的策略来规避,比如使用CDN来加速JS文件的加速,压缩JS文件体积,另外如果JS文件中没有操作DOM相关代码,就可以把JS设置为异步加载,通过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事件之前执行

现在我们知道了JS是如何阻塞DOM解析了。

接下来我们来看另外一种情况:

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

JS代码中出现了div1.style.color = 'red'的语句,它是用来操作CSOM的,所以在执行JS前,需要解析JS语句之上素有的CSS样式。

所以如果代码引用了外部CSS文件,那么在执行JS之前,还需要等待外部CSS文件下载完成,并解析成CSSOM对象之后,才能执行JS脚本。

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

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

总结

JS会阻塞DOM生成,样式文件又会阻塞JS的执行。

CSS -阻塞-> JavaScript -阻塞-> DOM

所以实际公车个需要关注JS文件和CSS文件。使用不当会影响性能。

Mark24

Everything can Mix.