DOM 树:JavaScript 是如何影响 DOM 树的构建的?
什么是 DOM
从网络传给渲染引擎的 HTML 文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM。DOM 提供了对 HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用:
- 从页面的视角来看,DOM 是生成页面的基础数据结构
- 从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容
- 从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了
DOM 树如何生成
HTML 解析器(HTMLParser):负责将 HTML 字节流转换成 DOM 结构。
HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据
字节流转化成 DOM 需要三个阶段,其中2、3阶段同步进行:
- 通过分词器将字节流转化成 Token
- 将 Token 转化成 DOM 节点
- 将 DOM 节点添加到 DOM 树中
JavaScript 如何影响 DOM 生成
<div>Test</div>
<script>
let divs = document.getElementsByTagName('div')
console.log(divs.length)
</script>
<div>晴天</div>
我们看这一段 HTML 代码,查看面板:
我在两段 div 中间插入了一段 JavaScript 脚本,这段脚本的解析过程就有点不一样了。<script>标签之前,所有的解析流程还是和之前介绍的一样,但是解析到<script>标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。
如果把中间的内嵌代码,改为引入文件:
其整个执行流程还是一样的,执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。这里需要重点关注下载环境,因为JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。
对此,Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
引入 JavaScript 线程会阻塞 DOM,不过也有一些相关的策略来规避:
- 使用 CDN 来加速 JavaScript 文件的加载,
- 压缩 JavaScript 文件的体积
- 如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码
async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
<head>
<link type="text/css" rel="styleSheet" href="dom.css" />
</head>
<body>
<div>Test</div>
<script>
let divs = document.getElementsByTagName('div')
console.log(divs.length)
divs[0].style.color = '#990099'
</script>
<div>晴天</div>
</body>
在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。 而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。
- load: 当整个页面及所有依赖资源如样式表、图片都完成加载时,触发该事件
- DOMContentLoaded: HTML 文档被完全加载和解析完成,触发该事件
渲染流水线:CSS 如何影响首次加载的白屏时间?
渲染流水线视角下的 CSS
影响页面展示的因素以及优化策略
因为渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验,所以我们找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。
从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历的三个阶段:
- 第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容
- 第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染
- 第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来
在第二阶段中,如果白屏时间过长,会影响用户体验。为了缩短白屏时间,我们来挨个分析这个阶段的主要任务,包括了解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作。
通常情况下的瓶颈主要体现在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript。
所以要想缩短白屏时长,可以有以下策略,但是具体还是应该根据实际情况来调整:
- 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了
- 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件
- 将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer
- 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件
页面性能:如何系统地优化页面?
通常一个页面有三个阶段:加载阶段、交互阶段、关闭阶段
- 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本
- 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本
- 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作
加载阶段
在加载阶段的优化原则就是减少关键资源的个数,降低关键资源大小,降低关键资源的 RTT 次数。
当使用 TCP 协议传输一个文件时,比如这个文件大小是 0.1M,由于 TCP 的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT 就是这里的往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。
交互阶段
交互阶段的优化原则:
减少 JavaScript 脚本执行时间
避免强制同步布局
正常情况下的布局操作:通过 DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,不过这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。参考:
<body>
<div id="mian_div">
<li id="li_s">sunny</li>
<li>晴天</li>
</div>
<p id="demo"> 强制布局 demo</p>
<button onclick="foo()"> 添加新元素 </button>
<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("太阳")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}
</script>
</body>
计算样式和布局都是在当前脚本执行过程中触发的,这就是强制同步布局。那么强制同步布局情况下:
<body>
<div id="mian_div">
<li id="li_s">sunny</li>
<li>晴天</li>
</div>
<p id="demo"> 强制布局 demo</p>
<button onclick="foo()"> 添加新元素 </button>
<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("太阳")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
//强制同步布局
console.log(main_div.offsetHeight)
}
</script>
</body>
避免布局抖动
所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。
function foo() {
let time_li = document.getElementById("time_li")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("sunny")
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById("mian_div").appendChild(new_node);
}
}
合理利用 CSS 合成动画
对比重排、重绘,如果可以利用 CSS 合成动画,就用 CSS 处理。
避免频繁地垃圾回收
我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。所以我们应尽可能优化储存结构,尽可能避免小颗粒对象的产生。