一 DOM
从网络进程传给渲染引擎的 HTML 文件字节流,需要转化为渲染引擎能够理解的结构,这个结构就是 DOM。在渲染引擎中,DOM 有三层作用:
- 从页面视角来看,DOM 是生成页面的基础数据结构
- 从 Javascript 脚本视角来看,DOM 是提供 Javascript 脚本操作的接口,通过该接口,Javascript 可以访问操作 DOM,改变文档的结构、样式和内容
- 从安全视角来看,DOM 是一道安全防线,一些不安全的内容在 DOM 解析阶段就被过滤掉
DOM 是表述 HTML 的内部数据结构,将 Web 页面和 Javascript 连接起来,并过滤一些不安全的内容。
二 DOM 树是如何生成的
在渲染引擎内部,通过HTML解析器(HTMLParser)将 HTML 字节流转换为 DOM。
2.1 HTML 的解析时机
网络进程接收到响应头之后,根据响应头的 content-type 字段判断文件的类型,如果是text/html,浏览器会判断这是一个 HTML 类型的文件,然后请求选择或创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间建立一个共享的数据管道,网络进程将接收的数据实时的放到这个管道里,渲染进程实时的读取管道里的数据,并将数据给 HTML 解析器,实时解析为 DOM。
总之,网络进程加载了多少数据,HTML 解析器就解析多少数据,边加载边解析,而不是等文档加载完再解析。
2.2 HTML 字节流转换为 DOM
字节流转为 DOM 的原理及步骤如下。
2.2.1 通过分词器将字节流转换为 Token
词法分析,通过分词器将字节流转换为 Token,分为 Tag Token 和 文本 Token。上图中代码生成的 Token 如下:
2.2.2 将 Token 解析为 DOM 节点,并将 DOM 节点 添加到 DOM 树中
HTML 解析器维护了一个 Token 栈结构,用来计算节点之间的父子关系,具体处理规则如下:
- 如果压入栈的是 StartTag,HTML 解析器为该 Token 创建一个 Token 节点,然后将该节点加入到 DOM 树中, 它的父节点就是栈中相邻的那个元素生成的节点
- 如果分词器解析出来是 文本Token,那么生成一个文本节点,然后将该节点加入到 DOM 树中,不需要压入到栈,它的父节点就是当前栈顶 Token 对应的 DOM 节点
- 如果分词器解析出来的是 EndTag 标签,HTML 解析器会查看 Token 栈顶的元素是否是对应的 StartTag, 如果是,则将 StartTag 弹出
HTML 解析器开始工作时,会默认创建一个根为 document 的空 DOM 结构。当解析器完成工作时,栈内是空的。
解析到第一个 <div> 时的状态:
解析到第一个</div>时的状态:
三 DOM 解析的阻塞因素
Javascript 会阻塞 DOM 的解析,CSS 文件会阻塞 Javascript 的执行。
CSS 不阻塞 DOM 的解析,也不阻塞 JS 的加载,只是因为 JS 可能引用了 CSS,这种依赖关系会阻塞 JS 的执行。
在解析 DOM 的时候,CSS 的解析与之并行;在下载 JS 文件时,CSS 文件的下载也可能与之并行,所以一般将 CSS 放在前面加载解析;如果放在后面,浏览器会先渲染一个没有样式的页面,花费额外的时间解析 CSS,再渲染一个有样式的页面,拖累渲染的时间还会造成明显的闪动。
3.1 Javascript
3.1.1 内嵌脚本
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>
如上代码,当解析到 <script> 标签时,渲染引擎判断这是 Javascript 脚本,HTML 解析器暂停 DOM 解析,Javascript 引擎介入,此时的状态如下图:
这段脚本改变了 DOM,执行完之后,DOM 被更新,HTML 解析器继续执行。
3.1.2 外联脚本
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
当执行到 <script> 标签时,暂停 DOM 解析,先下载 Javascript 代码,后执行代码。
通常情况下,受到网络环境、Javascript 文件大小等因素的影响,下载比较耗时。如果脚本中没有操作 DOM 的相关代码,可以添加 async(脚本一旦加载完成立即执行) / defer(DOMContentLoaded 事件之前执行) 将脚本设为异步加载,以优先进行 DOM 解析。
Chrome 浏览器有一个“预解析操作”的优化,当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 Javascript / CSS 相关文件,解析到相关文件之后,提前下载。
3.2 CSS
//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>
CSS 对 DOM 解析的阻塞是通过 Javascript 脚本影响的。
在执行 Javascript 之前,需要先解析 Javascript 语句上所有的 CSS 样式,如果代码中引用了外部的 CSS, 那么在执行之前,要将 CSS 文件下载完成,并解析成 CSSOM 对象之后,才能继续执行 Javascript。
3.3 白屏优化
网络进程与渲染进程建立传输数据的“管道”,待数据传输完成之后,渲染进程返回“确认提交”的消息给浏览器进程。浏览器进程接收到消息之前,页面上呈现的还是之前的页面,接收到消息之后,开始更新浏览器的界面状态并开始更新页面,这时渲染进程会创建一个空白页面,即“白屏”,然后等待 CSSOM 和 DOM ,合成布局树,之后渲染。
如果白屏的时间太久,会影响到用户的体验。影响白屏时长的关键点在于:CSS 文件的下载、JS 文件的下载和 JS 的执行。缩短白屏的策略主要有:
- 内联 JS 和 CSS
- 通过压缩等方式尽量减少外联 JS / CSS 文件的体积
- 将某些不操作 DOM 的 JS 加上 async 或 defer
- 对于大的 CSS 文件,可以通过媒体查询将其分拆为不同用途的 CSS,特定场景下只会加载特定的 CSS
四 虚拟 DOM
真实 DOM 的操作,会引发一系列的连锁反应:重排(样式计算,布局,绘制,栅格化等)、重绘、合成等,牵一发而动全身。另外还可能引发强制同步布局和布局抖动等问题。频繁的操作 DOM 会大大降低渲染效率。
虚拟 DOM 的作用:将页面改变的内容应用到虚拟 DOM 上,而不是直接应用的真实 DOM 上,虚拟 DOM 收集到足够的变化后(通过相应算法,只更新变化的地方),再把变化一次性应用到真实 DOM 上去渲染。从而大大降低操作 DOM 带来的性能消耗。