一、JavaScript是如何影响DOM树构建的

1. DOM 树如何生成

在渲染引擎内部,有一个叫HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。

HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据

字节流转换为DOM。

image.png

第一个阶段,通过分词器将字节流转换为 Token。
第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。
**

2. JavaScript 是如何影响 DOM 生成的

如下代码JS内嵌形式,DOM在遇到JS脚本后HTML解析器就会暂停 DOM 的解析,等待脚本执行完成之后,继续解析。

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

如下代码改成JavaScript文件加载形式。

  1. <html>
  2. <body>
  3. <div>1</div>
  4. <script type="text/javascript" src='foo.js'></script>
  5. <div>test</div>
  6. </body>
  7. </html>

执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码,JavaScript 文件的下载过程会阻塞 DOM 解析。

不过 Chrome 浏览器做了很多优化,其中一个主要的优化是
预解析操作**。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码。

  1. <script async type="text/javascript" src='foo.js'></script>
  1. <script defer type="text/javascript" src='foo.js'></script>

async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。

  1. <html>
  2. <head>
  3. <style src='theme.css'></style>
  4. </head>
  5. <body>
  6. <div>1</div>
  7. <script>
  8. let div1 = document.getElementsByTagName('div')[0]
  9. div1.innerText = 'time.geekbang' // 需要 DOM
  10. div1.style.color = 'red' // 需要 CSSOM
  11. </script>
  12. <div>test</div>
  13. </body>
  14. </html>

该示例中,JavaScript 代码出现了 div1.style.color = ‘red' 的语句,它是用来操纵 CSSOM 的,所以在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。

而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。

所以说 JavaScript 脚本是依赖样式表的,这又多了一个阻塞过程。至于如何优化,我们在下篇文章中再来深入探讨。

结论: JavaScript 会阻塞 DOM 生成,而样式文件又会阻塞 JavaScript 的执行

二、CSS如何影响首次加载时的白屏时间?

css渲染流程:
image.png

CSSOM 的两个作用,第一个是提供给 JavaScript 操作样式表的能力,第二个是为布局树的合成提供基础的样式信息

image.png

从发起 URL 请求开始,到首次显示页面的内容三个阶段:

  • 第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容
  • 第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。
  • 第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。

影响第一个阶段的因素主要是网络或者是服务器处理这块儿,
重点关注第二个阶段,这个阶段的主要问题是白屏时间,如果白屏时间过久,就会影响到用户体验。为了缩短白屏时间,我们来挨个分析这个阶段的主要任务,包括了解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作。
通常情况下的瓶颈主要体现在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript

所以要想缩短白屏时长,可以有以下策略:

  • 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
  • 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
  • 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer。
  • 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。

三、为什么CSS动画比JavaScript高效?

  • 通常渲染引擎生成一帧图像有三种方式:重排、重绘和合成。其中重排和重绘操作都是在渲染进程的主线程上执行的,比较耗时;而合成操作是在渲染进程的合成线程上执行的,执行速度快,且不占用主线程。
  • 浏览器是怎么实现合成的:分层、分块和合成。
  • 合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的
  • 使用will-change 来告诉渲染引擎你会对该元素做一些特效变换。

总结:CSS 动画比 JavaScript 动画高效的原因是渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。

四、如何系统地优化页面?

1. 页面生存周期的不同阶段

通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段

  • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
  • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
  • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

重点关注加载阶段和交互阶段。

2. 加载阶段

image.png

我们称阻塞网页首次渲染的资源称为关键资源。

影响页面首次渲染的核心因素是:

  • 第一个是关键资源个数
  • 第二个是关键资源大小
  • 第三个是请求关键资源需要多少个 RTT


优化策略:总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数**。

  • 如何减少关键资源的个数?一种方式是可以将 JavaScript 和 CSS 改成内联的形式。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 sync 或者 defer 属性;同样对于 CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。当 JavaScript 标签加上了 sync 或者 defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。
  • 如何减少关键资源的大小?可以压缩 CSS 和 JavaScript 资源,移除 HTML、CSS、JavaScript 文件中一些注释内容,也可以通过前面讲的取消 CSS 或者 JavaScript 中关键资源的方式。
  • 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

3. 交互阶段

  • 如果在计算样式阶段发现有布局信息的修改,那么就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。
  • 同样如果在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,那么就不会涉及到布局相关的调整,所以可以跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。不过重绘阶段的代价也是不小的。
  • 还有另外一种情况,通过 CSS 实现一些变形、渐变、动画等特效,这是由 CSS 触发的,并且是在合成线程上执行的,这个过程称为合成。因为它不会触发重排或者重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。

优化:

1. 减少 JavaScript 脚本执行时间:
**

  • 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
  • 另一种是采用 Web Workers。你可以把 Web Workers 当作主线程之外的一个线程,在 Web Workers 中是可以执行 JavaScript 脚本的,不过 Web Workers 中没有 DOM、CSSOM 环境,这意味着在 Web Workers 中是无法通过 JavaScript 来访问 DOM 的,所以我们可以把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行。


2. 避免强制同步布局

所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中

3. 避免布局抖动
**
所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。

4. 合理利用 CSS 合成动画

主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。
另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。

5. 避免频繁的垃圾回收
**
JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。