更快地构建 DOM: 使用预解析, async, defer 以及 preload - 众成翻译

3,161 阅读12分钟
原文链接: www.zcfy.cc

在 2017年,保证我们的页面能够快速加载的手段包括压缩,资源优化到缓存,CDN,代码分割以及 tree shaking 等。 然而,即便你不熟悉上面的这些概念,或者你感到无从下手,你仍然可以通过几个关键字以及精细的代码结构使得你的页面获得巨大的性能提升。

这些新的 web 标准 <link rel="preload">,使你能够实现更快的关键资源的加载,在这个月晚些时候,Firefox 就能看到这些特性。在 Firefox Nightly 版本中或者 开发者版本 已经可以使用这些功能。于此同时,这也是回顾基本原理,深入了解 DOM 解析相关性能的一个好时机。

理解浏览器的内部机制是每个 web 开发者强有力的工具。我们会看看浏览器是如何解释代码以及如何使用推测解析(speculative parsing)来帮助页面快速加载的。我们会分析 deferasync 是如何生效的以及如何利用新的关键字 preload

构建模块

HTML 描述了一个页面的结构。为了理解 HTML,浏览器首先会将它转换成一种他们能够理解的格式 – 文档对象模型(Document Object Model) 或者简称为 DOM。 浏览器引擎有这么一段特殊的代码叫做解析器,用来将数据从一种格式转换成另外一种格式。一个 HTML 解析器就能数据从 HTML 转换到 DOM。

在 HTML 当中,嵌套定义了不同标签的父子关系。In HTML, nesting defines the parent-child relationships between different tags. 在 DOM 当中,对象被关联在树(一种数据结构)中来捕获这些关系。每一个 HTML 标签都对应着树种的某个节点。I

浏览器一个比特一个比特地构建 DOM。一旦第一个代码块加载到浏览器当中,它就开始解析 HTML,添加节点到树中。

DOM 扮演着两种角色:它又是 HTML 文档的对象表示,也充当着外界和页面交互的接口,比如通过 JavaScript。 当你调用 document.getElementById(),返回的元素是一个 DOM 节点。每个 DOM 节点都有很多函数可以用来访问和改变它,用户可以看到相应的变化。

页面上的 CSS 样式被映射到 CSSOM 上 – CSS 对象模型(CSS Object Model)。它就像 DOM,但是只针对于 CSS 而不是 HTML。不像 DOM,它不能增量地构建。因为 CSS 规则会相互覆盖,所以浏览器引擎要进行复杂的计算来确定 CSS 代码如何应用到 DOM 上。

标签的历史

当浏览器构建 DOM 的时候,如果在 HTML 中遇到了一个 <script>...</script>标签,它必须立即执行。如果脚本是来自于外部的,那么它必须首先下载脚本。

在过去,为了执行一个脚本,HTML 的解析必须暂停。只有在 JavaScript 引擎执行完代码之后它才会重新开始解析。

那位为什么解析必须要暂停呢?那是因为脚本可以改变 HTML以及它的产物 —— DOM。 脚本可以通过 document.createElement()方法添加节点来改变 DOM 结构。为了改变 HTML,脚本可以使用臭名昭著的document.write()方法来添加内容。它之所以臭名昭著是因为它能以进一步影响 HTML 解析的方式来改变 HTML。比如,该方法可以插入一个打开的注释标签来使得剩余的 HTML 都变得不合法。

脚本还可以查询关于 DOM 的一些东西,如果是在 DOM 还在在构建的时候,它可能会返回意外的结果。

document.write() 是一个遗留的方法,它能够以预料之外的方式破坏你的页面,你应该避免使用它。处于这些原因,浏览器开发出了一些复杂的方法来应对脚本阻塞导致的性鞥你问题,稍后我会解释。

那么 CSS 会阻塞页面吗 ?

JavaScript 阻塞页面解析是因为它可以修改文档。CSS 不能修改文档,所以看起来它没有理由去阻塞页面解析,对吗?

那么,如果脚本需要样式信息,但样式还没有被解析呢?浏览器并不知道脚本要怎么执行——它可能会需要类似 DOM 节点的background-color 属性,而这个属性又依赖于样式表,或者它期望能够直接访问 CSSOM。

正因为如此,CSS 可能会阻塞解析,取决于外部样式表和脚本在文档中的顺序。如果在文档中外部样式表放置在脚本之前,DOM 对象和 CSSOM 对象的构建可以互相干扰。 当解析器获取到一个 script 标签,DOM 将无法继续构建直到 JavaScript 执行完毕,而 JavaScript 在 CSS 下载完,解析完,并且 CSSOM 可以使用的时候,才能执行。

另外一件要注意的事是,即使 CSS 不阻塞 DOM 的构建,它也会阻塞 DOM 的渲染。直到 DOM 和 CSSOM 准备好之前,浏览器什么都不会显示。这是因为页面没有 CSS 通常无法使用。如果一个浏览器给你显示了一个没有 CSS 的凌乱的页面,而几分钟之后又突然变成了一个有样式的页面,变换的内容和突然视觉变化使得用户体验变得非常糟糕。

具体可以参考由 Milica (@micikato) 在 CodePen 上制作的例子 —— Flash of Unstyled Content。

这种不好的用户体验有一个名字 — Flash of Unstyled Content 或是 FOUC

为了避免这个问题,你应该尽快地呈现 CSS。记得流行的“样式放顶部,脚本放底部”的最佳实践吗?你现在知道它是怎么来的了!

回到未来 – 预解析(speculative parsing)

每当解析器遇到一个脚本就暂停意味着每个你加载的脚本都会推迟连接到 HTML 的其他资源的发现。

如果你有几个类似的脚本和图片要加载,例如:

``<script src="slider.js">``</script>
``<script src="animate.js">``</script>
``<script src="cookie.js">``</script>
<img src="slide1.png">
<img src="slide2.png">

这个过程过去是这样的:

这个状况在 2008 年左右改变了,当时 IE 引入了一个概念叫做 “先行下载”。 这是一种在同步的脚步执行的时候保持文件的下载的一种方法。Firefox,Chrome 和 Safari 随后效仿,如今大多数的浏览器都使用了这个技术,它们有着不同的名称。Chrome 和 Safari 称它为 “预扫描器” 而 Firefox 称它为预解析器。

它的概念是:虽然在执行脚本时构建 DOM 是不安全的,但是你仍然可以解析 HTML 来查看其它需要检索的资源。找到的文件会被添加到一个列表里并开始在后台并行地下载。当脚本执行完毕之后,这些文件很可能已经下载完成了。

上面例子的瀑布图现在看起来是这样的:

以这种方式触发的下载请求称之为 “预测”,因为很有可能脚本还是会改变 HTML 结构(还记得document.write吗?),导致了预测的浪费。虽然这是有可能的,但是却不常见,所以这就是为什么预解析仍然能够带来很大的性能提升。

而且其他浏览器只会对链接的资源进行这样的预加载。在 Firefox 中,HTML 解析器对 DOM 树的构建也是算法预测的。有利的一面是,当推测成功的时候,就没有必要重新解析文件的一部分了。缺点是,如果推测失败了,就需要更多的工作。

(预)加载的东西

这种资源加载的方式带来了显著地性能提升,你不需要做任何事情就可以使用这种优势。然而,作为一个 web 开发者,了解预解析是如何工作的能帮你最大程度地利用它。

可以预加载的东西在浏览器之间有所不同,所有的主要的浏览器都会预加载:

  • 脚本

  • 外部 CSS

  • 来自 img 标签的图片

Firefox 也会预加载 video 元素的 poster 属性,而 Chrome 和 Safari 会预加载 @import 规则的内联样式。

浏览器能够并行下载的文件的数量是有限制的。这个限制在不同浏览器之间是不同的,并且取决于不同的因素,比如:你是否从同一个服务器或是不同的服务器下载所有的文件,又或者是你使用的是 HTTP/1.1 或是 HTTP/2 协议。为了更快地渲染页面,浏览器对每个要下载的文件都设置优先级来优化下载。为了弄清这些的优先级,他们遵守基于资源类型、标记位置以及页面渲染的进度的复杂方案。

在进行预解析时,浏览不会执行内联的 JavaScript 代码块。这意味着它不会发现任何的脚本注入资源,这些资源会排到抓取队列的最后面。

var script = document.createElement('script');
script.src = "//somehost.com/widget.js";
document.getElementsByTagName('head')[0].appendChild(script);

你应该尽可能使浏览器能更轻易地访问到重要的资源。你可以把他们放到 HTML 标签当中或者将要加载的脚本内联到文档的前面。然而,有时候你需要一些不重要的资源晚一点被加载。这种情况,你通过 JavaScript 来加载他们来避免预解析。

你也可以看看这个 MDN 指南,里面讲述了如何针对预解析优化你的页面。

defer 和 async

不过,同步的脚本阻塞解析器仍旧是个问题。并不是所有的脚本对用户体验都是同等的重要,例如那些用于监测和分析的脚本。解决方法呢?就是去尽可能地异步加载这些不那么重要的脚本。

deferasync 属性 提供给开发者一个方式来告诉浏览器哪些脚本是需要异步加载的。

这两个属性都告诉浏览器,它可以 “在后台” 加载脚本的同时继续解析 HTML,并在脚本加载完之后再执行。这样,脚本下载就不会阻塞 DOM 构建和页面渲染了。结果就是,用户可以在所有的脚本加载完成之前就能看到页面。

deferasync 之间的不同是他们开始执行脚本的时机的不同。

deferasync 要先引入。它的执行在解析完全完成之后才开始,它处在DOMContentLoaded事件之前。 它保证脚本会按照它在 HTML 中出现的顺序执行,并且不会阻塞解析。

async 脚本在它们完成下载完成后的第一时间执行,它处在 window 的load 事件之前。 这意味着有可能(并且很有可能)设置了 async 的脚本不会按照它们在 HTML 中出现的顺序执行。这也意味着他们可能会中断 DOM 的构建。

无论它们在何处被指定,设置async 的脚本的加载有着较低的优先级。他们通常在所有其他脚本加载之后才加载,而不阻塞 DOM 构建。然而,如果一个指定async 的脚本很快就完成了下载,那么它的执行会阻塞 DOM 构建以及所有在之后才完成下载的同步脚。

注: async 和 defer 属性只对外部脚本起作用,如果没有 src 属性它们会被忽略。

preload

如果你想要延迟处理一些脚本,那么asyncdefer 非常棒。那网页上那些对用户体验至关重要的东西呢?预解析器很方便,但是它们只会预加载少数类型的资源并遵循其逻辑。通常的目的都是首先交付 CSS,因为它会阻塞渲染。同步的脚本总是比异步的脚本拥有更高的优先级。视口中可见的图像会比那些底下的图片先下载完。还有字体,视频,SVG... 总而言之 — 这个过程很复杂。

作为作者,你知道哪些资源对你的页面渲染来说是最重要的。它们其中一些经常深藏在 CSS 或者是脚本当中,甚至浏览器需要花上很长一段时间才会发现他们。对于那些重要的资源,你现在可以使用<link rel="preload"> 来告诉浏览器你需要尽快地加载它们。

你只需要写上:

<link rel="preload" href="very_important.js" as="script">

你几乎可以链接到任何东西上,as 属性告诉浏览器要下载的是什么。一些可能的值是:

  • script
  • style
  • image
  • font
  • audio
  • video

你可以在MDN上查看剩余的内容类型。

字体可能是隐藏在CSS中最重要的东西。它们对页面上文字的渲染非常地关键,但是它们知道浏览器确认它们会被使用之前都不会被加载。 这个检查只发生在 CSS 已经被解析,应用,并且浏览器已经将 CSS 规则匹配到对应的 DOM 节点上时。这个过程在页面加载的过程中发生的相当晚,并且常常导致文字渲染中不必要的延迟。你可以通过使用 preload 属性来避免。

有一点要注意,要预加载字体你还必须设置crossorigin 属性,即使字体在同一个域名下:

<link rel="preload" href="font.woff" as="font" crossorigin>

preload 特性目前只有有限的支持度,因为其他浏览器还在推出它的过程中。你可以在这里查看进度。

Conclusion

浏览器是自 90 年代以来一直在进化的野兽。我们已经讨论了一些遗留的下来的问题以及 web 开发中的一些最新标准。根据这些指南书写你的代码能够帮助你选择最好的策略来提供更加流畅的浏览器体验。

如果你想了解更多关于浏览器的工作原理,你可以查看其他的文章:

走进 Quantum : 什么是浏览器引擎?

深入了解一个超级快的 CSS 引擎: Quantum CSS (也称作 Stylo)

关于

Milica Mihajlija

更多 Milica Mihajlija 的文章…