# 什么是 DOM

平时我们写的 html 标签本质上就是一堆字符串, html 文件组成的字节流实际上是无法被浏览器渲染引擎理解的。为了让渲染引擎能够解析这些字符串,并且让 JavaScript 能够动态操纵网页元素而不是直接操作一堆字符串,于是就有了 DOM 这个概念。 DOMhtml 文档能够有结构化的表述。在渲染引擎中, 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 ,浏览器就会启动一个渲染进程去处理它。渲染进程启动完,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到多少内容就同时往管道里添加多少内容,而渲染进程就一直读取管道里的数据进行解析渲染。

html响应头类型

# DOM 生成

html 字节流转换为 DOM 的过程大致分为三个阶段:

  1. 通过分词器将字节流转换为 Token ,这一点类似 JavaScript 解析
  2. 生成 Node 节点
  3. 生成 DOM

DOM生成

在分词器生成 Token 阶段,字节流一般会被转换成两种 TokenTag Token文本 Token 。经过分词器处理后 Tag Token 会被划分成 StartTagEndTag 。如:

<html>
    <body>
        <div>
            Asuhe
        </div>
    </body>
</html>

Tokens

后续的生成 Node 节点和 DOM 是同步进行的,将 Token 变成 Node 节点再将 DOM 插入 DOM 树中,到这里文档的 DOM 树就基本生成完毕了。

利用上面生成的 Tokens , HTML 解析器维护了一个 Token 栈结构。利用栈来进行标签匹配完成 TagT Token 的闭合,其和括号匹配是一样的。以上面的代码为例, HTML 解析器首先会将 html、body、divStartTag 入栈, 文本Token 会直接拿去生成 DOM 加入 DOM树 ,在遇到 EndTag 就弹出栈顶的 StartTag ,将其插入 DOM 树。 HTML 解析器开始工作时,会默认创建一个根为 document 的空 DOM 结构,同时将一个 StartTag documentToken 压入栈底,后面再装入分词器分类出的 token ,文本 Token 会被插入在其上一个 Tag Token 的后面作为其子节点

Token栈与DOM树

每当 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>

虽然 asyncdefer 都是异步加载 javascript 文件,但是 async 加载完 js 文件后会立即执行里面的代码而 defer 则会在 DOMContentLoaded 事件前执行

在页面的 JavaScript 代码中我们可能并不会增删 DOM 但会修改 DOM 的样式,操作 CSSOM 。如果 js 代码里操作了外部的 CSS 那么浏览器同样要等待外部的 CSS 文件下载完成并解析生成 CSSOM 对象之后才能执行 JavaScript 脚本。也就是说单纯的外部 css 文件并不会阻塞 DOM 渲染,但若是 js 代码中操作了外部 css 文件则该 css 文件就会间接导致 DOM 渲染被阻塞

DOM渲染流程图

HTML 解析器发现需要 css、js 外部文件时,浏览器会同时发起请求进程,也就是说请求 cssjs 文件是并行的,所以在我们计算加载时间时仅需计算最大的那个文件所需传输时长即可

浏览器渲染进程

# 首页白屏优化

通过上面的分析我们知道一般情况下网页性能瓶颈主要体现在 css 下载和 js 文件下载和代码执行中,所以想要缩短白屏时长我们可以采取以下策略:

  • 通过内联 cssjs 来消除文件下载时导致的进程阻塞
  • 在不适合内联 css、js 的情况下尽量减小文件大小,例如 webpackTree Shaking
  • 对于未操作 DOMjs 文件用 asyncdefer 异步加载
  • 对于大的 css 文件使用媒体查询将其拆分为多个 css 文件,需要用的时候再加载相关 css 文件

# 页面渲染全过程

  • 渲染进程将 html 文档转换为渲染引擎能够识别的 DOM树结构
  • 渲染引擎将 css 样式表转换为可以理解的 styleSheets ,计算出 DOM 节点的样式生成 CSSOM
  • 创建布局树,并计算元素的分布信息
  • 对布局树进行分层并生成分层树
  • 为每个图层生成绘制列表,并将其提交到合成线程
  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
  • 合成线程发送绘制图块命令 DrawQuad 给浏览器进程
  • 浏览器进程根据 DrawQuad 消息生成页面,并显示到屏幕上