什么是DOM
平时我们写的html
标签本质上就是一堆字符串,html
文件组成的字节流实际上是无法被浏览器渲染引擎理解的。为了让渲染引擎能够解析这些字符串,并且让JavaScript
能够动态操纵网页元素而不是直接操作一堆字符串,于是就有了DOM
这个概念。DOM
让html
文档能够有结构化的表述。在渲染引擎中,DOM
主要有三个层面的作用:
- 从页面的角度来看,
DOM
就是生成页面的基本数据结构 - 从
JavaScript
的角度来看,DOM
提供了接口让JavaScript
有能力操作页面的元素,改变页面的结构、样式和内容 - 从安全的角度来看,
DOM
提供了一个安全的容器,让一些不安全的内容直接在DOM
解析的阶段就被排除了
DOM树的生成
上面我们提到渲染引擎无法直接识别html
文档字节流,所以在渲染引擎渲染页面之前html
文档会被交给HTML
解析器,让它先把html
文档转换为DOM
结构,再供渲染引擎使用。
HTML
解析器在解析html
文档时是一边加载一边解析的,也就是说html
文档加载了多少内容它就解析多少内容,而不是等html
文档全部加载完才开始解析内容的。这就像编译型语言和解释型语言,显然HTML
解析器的工作模式是同解释型语言一样的。
在加载页面时,浏览器网络进程接收到响应头后会根据响应头中content-type
字段来判断文件类型,接着启动相应进程去处理接收到的文件。如html
文件的content-type
字段是text/html
,浏览器就会启动一个渲染进程去处理它。渲染进程启动完,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到多少内容就同时往管道里添加多少内容,而渲染进程就一直读取管道里的数据进行解析渲染。
DOM生成
将html
字节流转换为DOM
的过程大致分为三个阶段:
- 通过分词器将字节流转换为
Token
,这一点类似JavaScript
解析 - 生成
Node
节点 - 生成
DOM
在分词器生成Token
阶段,字节流一般会被转换成两种Token
:Tag Token
和文本 Token
。经过分词器处理后Tag Token
会被划分成StartTag
和EndTag
。如:
<html>
<body>
<div>
Asuhe
</div>
</body>
</html>
后续的生成Node
节点和DOM
是同步进行的,将Token
变成Node
节点再将DOM
插入DOM
树中,到这里文档的DOM
树就基本生成完毕了。
利用上面生成的Tokens
,HTML
解析器维护了一个Token
栈结构。利用栈来进行标签匹配完成TagT Token
的闭合,其和括号匹配是一样的。以上面的代码为例,HTML
解析器首先会将html、body、div
的StartTag
入栈,文本Token
会直接拿去生成DOM
加入DOM树
,在遇到EndTag
就弹出栈顶的StartTag
,将其插入DOM
树。HTML
解析器开始工作时,会默认创建一个根为document
的空DOM
结构,同时将一个StartTag document
的Token
压入栈底,后面再装入分词器分类出的token
,文本Token
会被插入在其上一个Tag Token
的后面作为其子节点
每当Token
栈里出栈一个元素的时候DOM
树就会生成相应节点并插入,所以最后文档渲染完毕时Token
栈为空。
分词器解析出Token
后,渲染引擎的XSSAuditor
模块会启动,检查词法安全。例如是否引用了外部脚本、是否符合CSP
规范、是否跨域请求等等,若出现不规范的内容XSSAuditor
会对该脚本或者下载任务进行拦截
JavaScript对dom生成的影响
当HTML
解析器遇到<script>
标签时,渲染引擎判断出这是一段脚本,此时HTML
解析器会停止对DOM
的解析,因为段脚本里的代码可能会对已经生成的DOM树
进行操作。所以渲染引擎会先执行完脚本代码再继续启动HTML
解析器进行DOM
解析。也就是说当有JavaScript
在文档中时,DOM
生成会被阻塞。同时若一个JavaScript
脚本代码中对DOM
进行了操作,但它操作的DOM
位于该段代码的<script>
标签之后那么这行代码就会执行失败,因为此时需要被操作的DOM
并没有渲染出来。这就是为什么通常我们将JavaScript
代码放在html
文档最后的原因。<script>
标签放在html
文档的头部,当<script>
中代码较多所需执行时间很长时我们的页面就会出现白屏。
当我们使用外部链接来加载<script>
代码时,浏览器需要先下载这段代码,而下载过程同样会阻塞DOM
解析,此时如果源js
文件站点网络较差就会导致长时间白屏。
为了解决这个问题Chrome
浏览器做了许多优化,主要的就是预解析操作。当渲染引擎接收到字节流以后会开启一个预解析线程用于分析html
文件中包含的JavaScript、Css
等相关文件,解析到了会提前下载这些文件以防止阻塞
上面我们知道JavaScript
脚本会阻塞DOM
的生成,对此我们也有可以采用一些方法来规避,例如当javascript
代码中没有DOM
操作相关的代码时,就可以将该JavaScript
脚本设置为异步加载,或者使用CDN
加速、代码压缩等方法。
使用async异步加载代码
<script async type="text/javascript" src="foo.js"></script>
使用defer异步加载代码
<script defer type="text/javascript" src="foo.js"></script>
虽然async
和defer
都是异步加载javascript
文件,但是async
加载完js
文件后会立即执行里面的代码而defer
则会在DOMContentLoaded
事件前执行
在页面的JavaScript
代码中我们可能并不会增删DOM
但会修改DOM
的样式,操作CSSOM
。如果js
代码里操作了外部的CSS
那么浏览器同样要等待外部的CSS
文件下载完成并解析生成CSSOM
对象之后才能执行JavaScript
脚本。也就是说单纯的外部css
文件并不会阻塞DOM
渲染,但若是js
代码中操作了外部css
文件则该css
文件就会间接导致DOM
渲染被阻塞
当HTML
解析器发现需要css、js
外部文件时,浏览器会同时发起请求进程,也就是说请求css
和js
文件是并行的,所以在我们计算加载时间时仅需计算最大的那个文件所需传输时长即可
首页白屏优化
通过上面的分析我们知道一般情况下网页性能瓶颈主要体现在css
下载和js
文件下载和代码执行中,所以想要缩短白屏时长我们可以采取以下策略:
- 通过内联
css
和js
来消除文件下载时导致的进程阻塞 - 在不适合内联
css、js
的情况下尽量减小文件大小,例如webpack
的Tree Shaking
- 对于未操作
DOM
的js
文件用async
或defer
异步加载 - 对于大的
css
文件使用媒体查询将其拆分为多个css
文件,需要用的时候再加载相关css
文件
页面渲染全过程
- 渲染进程将
html
文档转换为渲染引擎能够识别的DOM树结构
- 渲染引擎将
css
样式表转换为可以理解的styleSheets
,计算出DOM
节点的样式生成CSSOM
- 创建布局树,并计算元素的分布信息
- 对布局树进行分层并生成分层树
- 为每个图层生成绘制列表,并将其提交到合成线程
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
- 合成线程发送绘制图块命令
DrawQuad
给浏览器进程 - 浏览器进程根据
DrawQuad
消息生成页面,并显示到屏幕上