DOM(Document Object Model——文档对象模型)是用来呈现以及与任意 HTML 或 XML 文档交互的 API。DOM 是载入到浏览器中的文档模型,以节点树的形式来表现文档,每个节点代表文档的构成部分(例如:页面元素、字符串或注释等等)。现在就让我们了解一下dom是怎么生成的,了解这些也有助于我们优化关键渲染路径

html 解析原理

当浏览器响应进程识别到一个响应的content-typetext/html时,浏览器就会为此响应指定一个渲染进程,之后网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并把数据推送给渲染进程内部的 HTML 解析器(HTMLParser,它的职责就是负责将 HTML 字节流转换为 DOM 结构)。就这样边接受数据边解析,一个 DOM 树就此形成。这样也说明了浏览器是边加载边解析 html,而不是等待 html 数据加载完毕再渲染。

生成 DOM 过程

前面我们说过代码从网络传输过来是字节流的形式,那么后续字节流是如何转换为 DOM 的呢?你可以参考下图:


从图中你可以看出,字节流转换为 DOM树 过程。

字节流转 token

分词器先将字节流转换为一个个 Token,分为 Tag Token (又分StartTag和EndTag)和文本 Token。HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,分词器生成的 Token 会被按照顺序压到这个栈中。上述图1中的 html 代码通过词法分析生成的 Token 如下所示:

生成 DOM 树

HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后就是生成dom节点并组成dom树,这两步是同时进行的,具体流程如下:

  • 如果压入到栈中的是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  • 如果分词器解析出来的是EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

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

token 出入栈详细分析

为了更加直观地理解整个过程,下面我们结合一段 HTML 代码(如下),来一步步分析 DOM 树的生成过程。

1
2
3
4
5
6
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>

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

然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:

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

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

按照同样的规则,一路解析,最终结果如下图所示:

通过上面的介绍,相信你已经清楚 DOM 是怎么生成的了。不过在实际生产环境中,HTML 源文件中既包含 CSS 和 JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个示范 Demo 复杂。

其他 DOM 相关知识点

  • HTML 中的所有内容,甚至注释,都会成为 DOM 树的一部分,他们有不同的 dom 类型,12 种节点类型
  • 空格、换行符、注释等都是文本 token,被解析为文本 DOM。但一般情况下会忽略空格和换行符转化的 DOM 。
  • HTML 开头的 <!DOCTYPE…> 指令也是一个 DOM 节点,一般也会忽略。
  • 如果浏览器遇到格式不正确的 HTML,它会在形成 DOM 时自动更正它。比如一个html文件中只有一个字符串 hello,浏览器则会把它包装到 html 和 body 中,并且会添加所需的 head。
  • 如果我们在 </body> 之后放置一些东西,那么它会被自动移动到 body 内,并处于 body 中的最下方,因为 HTML 规范要求所有内容必须位于 <body> 内。所以 </body> 之后不能有空格。
  • 表格是一个有趣的“特殊的例子”。按照 DOM 规范,它们必须具有 <tbody>,但 HTML 文本却(官方的)忽略了它。然后浏览器在创建 DOM 时,自动地创建了 <tbody>

通过上面的介绍,相信你已经清楚 DOM 是怎么生成的了。不过在实际生产环境中,HTML 源文件中既包含 CSS 和 JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个示范 Demo 复杂。不过理解了这个简单的 Demo 生成过程,我们就可以往下分析更加复杂的场景了。

JavaScript 影响 DOM 生成

内嵌 JavaScript 脚本

1
2
3
4
5
6
7
8
9
10
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'I am changed!'
</script>
<div>test</div>
</body>
</html>

我在两段 div 中间插入了一段 JavaScript 脚本,这段脚本的解析过程就有点不一样了。script标签之前,所有的解析流程还是和之前介绍的一样,但是解析到script标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。
通过前面 DOM 生成流程分析,我们已经知道当解析到 script 脚本标签时,其 DOM 树结构如下所示:

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

外链 JavaScript 脚本

以上过程应该还是比较好理解的,不过除了在页面中直接内嵌 JavaScript 脚本之外,我们还通常需要在页面中引入 JavaScript 文件,这个解析过程就稍微复杂了些,如下面代码:

1
2
3
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'I am changed!'
1
2
3
4
5
6
7
<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 文件大小等因素的影响。

所以一般情况下js脚本都是放在 body 的最后部分,主要是防止加载js文件阻碍dom树的生成。
其他解决方法有 CDN 加速、压缩文件体积、异步加载(async、defer)等。
async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行js代码;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行js代码。

CSSOM

CSSOM 具有两个作用,第一个是提供给 JavaScript 操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。这个 CSSOM 体现在 DOM 中就是document.styleSheets。

CSSOM 的生成

当渲染进程接收 HTML 文件字节流时,会先开启一个预解析线程,如果遇到 CSS 文件,那么预解析线程会提前下载这些数据并解析成 CSSOM,此过程和 DOM 生成不冲突。对于上面的代码,预解析线程会解析出来一个外部的 theme.css 文件,并发起 theme.css 的下载。这里也有一个空闲时间需要注意一下,就是在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。

CSSOM 结构一般是下面这种:

CSSOM的生成过程不会暂停DOM的解析,除了一些特殊情况,下面会讲到。

CSS 如何间接影响 DOM 解析

例如下面这段代码:

1
2
3
4
5
//theme.css
div{
color : coral;
background-color:black
}
1
2
//foo.js
console.log('test2')
1
2
3
4
5
6
7
8
9
10
<html>
<head>
<link href="theme.css" rel="stylesheet">
</head>
<body>
<div>test1</div>
<script src='foo.js'></script>
<div>test3</div>
</body>
</html>

上面我们提到在解析 DOM 的过程中,如果遇到了 JavaScript 脚本,那么需要先暂停 DOM 解析去执行 JavaScript,因为 JavaScript 有可能会修改当前状态下的 DOM。但是因为 JavaScript 有修改 CSSOM 的能力,而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。
通过上面的分析,我们知道了 JavaScript 会阻塞 DOM 生成,而CSS文件又会阻塞 JavaScript 的执行,所以在实际的工程中需要重点关注 JavaScript 文件和CSS文件,使用不当会影响到页面性能的。

生成渲染树 Render Tree

DOM 和 CSSOM 是独立的树形结构,当 DOM 树和 CSSOM 树都构建完成的时候,他们就会合并在一起构建 render tree(渲染树)。例如下图:

在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,例如如果某个节点是 display: none 的,那么就不会在渲染树中显示。

当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做重排)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。就这样一个HTML就被渲染成我们看到的页面了。
当然渲染过程不可能如此简单,具体更深入的了解请参考下面的文章:
渲染流程(下):HTML、CSS和JavaScript是如何变成页面的
浏览器层合成与页面渲染优化

本文参考链接

浏览器工作原理与实践
前端性能优化之关键路径渲染优化
DOM树