更快地构建,过程与原理
分类:前端技术

更快地构建 DOM: 使用预解析, async, defer 以及 preload

2017/09/28 · JavaScript · async, defer, DOM, preload

原文出处: Milica Mihajlija   译文出处:众成翻译   

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

新的 Web 标准 ``使你能够更快地加载关键资源,这个月晚些时候,Firefox 就会支持这个特性。同时在 Firefox Nightly 版本或者 开发者版本 上已经可以使用这些功能。与此同时,这也是回顾基本原理,深入了解 DOM 解析相关性能的一个好时机。

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

浏览器的渲染:过程与原理

2017/10/18 · 基础技术 · 2 评论 · 浏览器

原文出处: 天方夜   

 图片 1内内容说明

本文不是关于浏览器渲染的底层原理或前端优化具体细节的讲解,而是关于浏览器对页面的渲染——这一过程的描述及其背后原理的解释。这是因为前端优化是一个非常庞大且零散的知识集合,一篇文章如果要写优化的具体方法恐怕只能做一些有限的列举。

然而,如果了解清楚浏览器的渲染过程、渲染原理,其实就掌握了指导原则。根据优化原则,可以实现出无数种具体的优化方案,各种预编译、预加载、资源合并、按需加载方案都是针对浏览器渲染习惯的优化。

浏览器渲染页面的过程

从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:

DNS 查询

TCP 连接

HTTP 请求即响应

服务器响应

客户端渲染

本文讨论第五个部分,即浏览器对内容的渲染,这一部分(渲染树构建、布局及绘制),又可以分为下面五个步骤:

处理 HTML 标记并构建 DOM 树。

处理 CSS 标记并构建 CSSOM 树。

将 DOM 与 CSSOM 合并成一个渲染树。

根据渲染树来布局,以计算每个节点的几何信息。

将各个节点绘制到屏幕上。

需要明白,这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM,下面就来看看它们的影响方式。

原文出处: 淘宝前端团队(FED)- 妙净   

构建模块

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

在 HTML 当中,嵌套(nesting)定义了不同标签的父子关系。在 DOM 当中,对象被关联在树(一种数据结构)中用于捕获这些关系。每一个 HTML 标签都对应着树种的某个节点(DOM节点)。

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

图片 2

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

图片 3

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

图片 4

关键渲染路径

提到页面渲染,有几个相关度非常高的概念,最重要的是关键渲染路径,其他几个概念都可以从它展开,下面稍作说明。

关键渲染路径(Critical Rendering Path)是指与当前用户操作有关的内容。例如用户刚刚打开一个页面,首屏的显示就是当前用户操作相关的内容,具体就是浏览器收到 HTML、CSS 和 JavaScript 等资源并对其进行处理从而渲染出 Web 页面。

了解浏览器渲染的过程与原理,很大程度上是为了优化关键渲染路径,但优化应该是针对具体问题的解决方案,所以优化没有一定之规。例如为了保障首屏内容的最快速显示,通常会提到渐进式页面渲染,但是为了渐进式页面渲染,就需要做资源的拆分,那么以什么粒度拆分、要不要拆分,不同页面、不同场景策略不同。具体方案的确定既要考虑体验问题,也要考虑工程问题。

阻塞渲染:CSS 与 JavaScript

谈论资源的阻塞时,我们要清楚,现代浏览器总是并行加载资源。例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。

同时,由于下面两点:

默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。

JavaScript 不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。

存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。另外:

当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。

JavaScript 可以查询和修改 DOM 与 CSSOM。

CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。

所以,script 标签的位置很重要。实际使用时,可以遵循下面两个原则:

CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。

JavaScript 应尽量少影响 DOM 的构建。

浏览器的发展日益加快(目前的 Chrome 官方稳定版是 61),具体的渲染策略会不断进化,但了解这些原理后,就能想通它进化的逻辑。下面来看看 CSS 与 JavaScript 具体会怎样阻塞资源。

图片 5

关于``标签的历史

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

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

图片 6

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

图片 7

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

图片 8

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

浏览器渲染页面的过程

从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:

  1. DNS 查询
  2. TCP 连接
  3. HTTP 请求即响应
  4. 服务器响应
  5. 客户端渲染

本文讨论第五个部分,即浏览器对内容的渲染,这一部分(渲染树构建、布局及绘制),又可以分为下面五个步骤:

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

需要明白,这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM,下面就来看看它们的影响方式。

CSS

<style> p { color: red; }</style>

<link rel="stylesheet" href="index.css">

这样的 link 标签(无论是否 inline)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。

渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。

最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。

<link href="index.css" rel="stylesheet">

<link href="print.css" rel="stylesheet"media="print">

<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">

第一个资源会加载并阻塞。
第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。
第三个资源提供了媒体查询,会在符合条件时阻塞渲染。

如何让页面尽可能早地渲染页面,页面更早可见,让白屏时间更短,尤其是无线环境下,一直是性能优化的话题。

那么 CSS 会阻塞页面吗 ?

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

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

图片 9

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

图片 10

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

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

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

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

阻塞渲染:CSS 与 JavaScript

谈论资源的阻塞时,我们要清楚,现代浏览器总是并行加载资源。例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。

同时,由于下面两点:

  1. 默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。
  2. JavaScript 不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。

存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。另外:

  1. 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
  2. JavaScript 可以查询和修改 DOM 与 CSSOM。
  3. CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。

所以,script 标签的位置很重要。实际使用时,可以遵循下面两个原则:

  1. CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。
  2. JavaScript 应尽量少影响 DOM 的构建。

浏览器的发展日益加快(目前的 Chrome 官方稳定版是 61),具体的渲染策略会不断进化,但了解这些原理后,就能想通它进化的逻辑。下面来看看 CSS 与 JavaScript 具体会怎样阻塞资源。

JavaScript

JavaScript 的情况比 CSS 要更复杂一些。观察下面的代码:

<p>Do not go gentle into that good night,</p>

<script>console.log("inline")</script>

<p>Old age should burn and rave at close of day;</p>

<script src="app.js"></script>

<p>Rage, rage against the dying of the light.</p>

<p>Do not go gentle into that good night,</p>

<script src="app.js"></script>

<p>Old age should burn and rave at close of day;</p>

<script>console.log("inline")</script>

<p>Rage, rage against the dying of the light.</p>

这样的 script 标签会阻塞 HTML 解析,无论是不是 inline-script。上面的 P 标签会从上到下解析,这个过程会被两段 JavaScript 分别打算一次(加载、执行)。

所以实际工程中,我们常常将资源放到文档底部。

页面可见时间

页面可见要经历以下过程:

  • 解析 HTML 为 DOM,解析 CSS 为 CSSOM(CSS Object Model)
  • 将 DOM 和 CSSOM 合成一棵渲染树(render tree)
  • 完成渲染树的布局(layout)
  • 将渲染树绘制到屏幕

图片 11

layout

图片 12

由于 JS 可能随时会改变 DOMCSSOM,当页面中有大量的 JS 想立刻执行时,浏览器下载并执行,直到完成 CSSOM 下载与构建,而在我们等待时,DOM 构建同样被阻塞。为了 JS 不阻塞 DOM 和 CSSDOM 的构建,不影响首屏可见的时间,测试几种 JS 加载策略对页面可见的影响:

回到未来 – 预解析(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">

1
2
3
4
5
  <script src="slider.js"></script>
  <script src="animate.js"></script>
  <script src="cookie.js"></script>
  <img src="slide1.png">
  <img src="slide2.png">

这个过程过去是这样的:

图片 13

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

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

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

图片 14

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

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

CSS

JavaScript

<style> p { color: red; }</style> <link rel="stylesheet" href="index.css">

1
2
<style> p { color: red; }</style>
<link rel="stylesheet" href="index.css">

这样的 link 标签(无论是否 inline)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。

渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。

最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。

JavaScript

<link href="index.css" rel="stylesheet"> <link href="print.css" rel="stylesheet" media="print"> <link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">

1
2
3
<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">

第一个资源会加载并阻塞。
第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。
第三个资源提供了媒体查询,会在符合条件时阻塞渲染。

改变阻塞模式:defer 与 async

为什么要将 script 加载的 defer 与 async 方式放到后面呢?因为这两种方式是的出现,全是由于前面讲的那些阻塞条件的存在。换句话说,defer 与 async 方式可以改变之前的那些阻塞情形。

首先,注意 async 与 defer 属性对于 inline-script 都是无效的,所以下面这个示例中三个 script 标签的代码会从上到下依次执行。

<!-- 按照从上到下的顺序输出 1 2 3 -->

<script async>

  console.log("1");

</script>

<script defer>

  console.log("2");

</script>

<script>

  console.log("3");

</script>

故,下面两节讨论的内容都是针对设置了 src 属性的 script 标签。

几种异步加载方式测试

  • A. head script: 即普通的将 JS 放在 head 中或放在 body 中间:DEMO 地址
  • B. bottom script: 即常规的优化策略,JS 放body的底部:DEMO 地址
  • C. document.write: 以前 PC 优化少用的一种异步加载 JS 的策略:DEMO 地址
JavaScript

function injectWrite(src){ document.write('&lt;script src="'   src  
'"&gt;&lt;/sc'   'ript&gt;'); }

<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<tbody>
<tr class="odd">
<td><div class="crayon-nums-content" style="font-size: 13px !important; line-height: 15px !important;">
<div class="crayon-num" data-line="crayon-5a721bbc827ff070447677-1">
1
</div>
<div class="crayon-num crayon-striped-num" data-line="crayon-5a721bbc827ff070447677-2">
2
</div>
<div class="crayon-num" data-line="crayon-5a721bbc827ff070447677-3">
3
</div>
</div></td>
<td><div class="crayon-pre" style="font-size: 13px !important; line-height: 15px !important; -moz-tab-size:4; -o-tab-size:4; -webkit-tab-size:4; tab-size:4;">
<div id="crayon-5a721bbc827ff070447677-1" class="crayon-line">
function injectWrite(src){
</div>
<div id="crayon-5a721bbc827ff070447677-2" class="crayon-line crayon-striped-line">
  document.write('&lt;script src=&quot;'   src   '&quot;&gt;&lt;/sc'   'ript&gt;');
</div>
<div id="crayon-5a721bbc827ff070447677-3" class="crayon-line">
}
</div>
</div></td>
</tr>
</tbody>
</table>
  • D. getScript: 形如以下,也是 KISSY 内部的getScript函数的简易实现:DEMO 地址
JavaScript

&lt;script&gt; var script = document.createElement('script');
script.src = "//g.tbcdn.com/xx.js";
document.getElementsByTagName('head')[0].appendChild(script);
&lt;/script&gt;

<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<tbody>
<tr class="odd">
<td><div class="crayon-nums-content" style="font-size: 13px !important; line-height: 15px !important;">
<div class="crayon-num" data-line="crayon-5a721bbc82807359027480-1">
1
</div>
<div class="crayon-num crayon-striped-num" data-line="crayon-5a721bbc82807359027480-2">
2
</div>
<div class="crayon-num" data-line="crayon-5a721bbc82807359027480-3">
3
</div>
<div class="crayon-num crayon-striped-num" data-line="crayon-5a721bbc82807359027480-4">
4
</div>
<div class="crayon-num" data-line="crayon-5a721bbc82807359027480-5">
5
</div>
</div></td>
<td><div class="crayon-pre" style="font-size: 13px !important; line-height: 15px !important; -moz-tab-size:4; -o-tab-size:4; -webkit-tab-size:4; tab-size:4;">
<div id="crayon-5a721bbc82807359027480-1" class="crayon-line">
&lt;script&gt;
</div>
<div id="crayon-5a721bbc82807359027480-2" class="crayon-line crayon-striped-line">
  var script = document.createElement('script');
</div>
<div id="crayon-5a721bbc82807359027480-3" class="crayon-line">
  script.src = &quot;//g.tbcdn.com/xx.js&quot;;
</div>
<div id="crayon-5a721bbc82807359027480-4" class="crayon-line crayon-striped-line">
  document.getElementsByTagName('head')[0].appendChild(script);
</div>
<div id="crayon-5a721bbc82807359027480-5" class="crayon-line">
&lt;/script&gt;
</div>
</div></td>
</tr>
</tbody>
</table>
  • E. 加 async 属性:DEMO 地址
  • F. 加 defer 属性:DEMO 地址
  • G. 同时加 async defer 属性:DEMO 地址

关于(预)加载

这种资源加载的方式带来了显著地性能提升,你不需要做任何事情就可以使用这种优势。然而,作为一个 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);

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

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

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

JavaScript

JavaScript 的情况比 CSS 要更复杂一些。观察下面的代码:

JavaScript

<p>Do not go gentle into that good night,</p> <script>console.log("inline")</script> <p>Old age should burn and rave at close of day;</p> <script src="app.js"></script> <p>Rage, rage against the dying of the light.</p> <p>Do not go gentle into that good night,</p> <script src="app.js"></script> <p>Old age should burn and rave at close of day;</p> <script>console.log("inline")</script> <p>Rage, rage against the dying of the light.</p>

1
2
3
4
5
6
7
8
9
10
11
<p>Do not go gentle into that good night,</p>
<script>console.log("inline")</script>
<p>Old age should burn and rave at close of day;</p>
<script src="app.js"></script>
<p>Rage, rage against the dying of the light.</p>
 
<p>Do not go gentle into that good night,</p>
<script src="app.js"></script>
<p>Old age should burn and rave at close of day;</p>
<script>console.log("inline")</script>
<p>Rage, rage against the dying of the light.</p>

这样的 script 标签会阻塞 HTML 解析,无论是不是 inline-script。上面的 P 标签会从上到下解析,这个过程会被两段 JavaScript 分别打算一次(加载、执行)。

所以实际工程中,我们常常将资源放到文档底部。

defer

<script src="app1.js" defer></script>

<script src="app2.js" defer></script>

<script src="app3.js" defer></script>

defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。

defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。

测试结果

以下提到的 domReadyDOMContentLoaded 事件。

A (head script) B (bottom script) C (document.write) D (getScript) E (async) F (defer) G (async defer)
1 PC Chrome 页面白屏长、domReady:5902.545、onLoad:5931.48 页面先显示、domReady:5805.21、onLoad:5838.255 页面先显示、domReady:5917.95、onLoad:5949.30 页面先显示、domReady:244.41、onLoad:5857.645 页面先显示、domReady:567.01、onLoad:5709.33 页面先显示、domReady:5812.12、onLoad:5845.6 页面先显示、domReady:576.12、onLoad:5743.79
2 iOS Safari 页面白屏长、domReady:6130、onLoad:6268.41 页面白屏长、domReady:5175.80、onLoad:5182.75 页面白屏长、domReady:5617.645、onLoad:5622.115 502s 白屏然后页面显示最后变更 load finish 时间、domReady:502.71、onLoad:6032.95 508s 白屏然后页面显示最后变更 load finish time domReady:508.95、onLoad:5538.135 页面白屏长、domReady:5178.98、onLoad:5193.58 556s 白屏然后页面显示最后变更 load finish 时间、domReady:556、onLoad:5171.95
3 iOS 手淘 WebView 页面白屏长、页面出现 loading 消失、domReady: 5291.29、onLoad:5292.78 页面白屏长、页面未跳转 loading 消失、domReady: 5123.46、onLoad:5127.85 页面白屏长、页面未跳转 loading 消失、domReady: 5074.86、onLoad:5079.875 页面可见快、loading 消失快在 domReady 稍后、domReady:14.06、load finish:5141.735 页面可见快、loading 消失快在 domReady 稍后、domReady:13.89、load finish:5157.15 页面白屏长、loading 先消失再出现页面、domReady: 5132.395、onLoad:5137.52 页面可见快、然后 loading 消失、domReady:13.49、load finish:5124.08
4 Android browser 页面白屏长、domReady: 5097.29、onLoad:5100.37 页面白屏长、domReady: 5177.48、onLoad:5193.66 页面白屏长、domReady: 5125.96、onLoad:5165.06 页面可见快、等 5s 后更新 load finish 时间 domReady:463.33、load finish:5092.90 页面可见快、等 5s 后更新 load finish 时间 domReady:39.34、load finish:5136.55 页面白屏长、domReady: 5092.45、onLoad:5119.81 页面可见快、等 5s 后更新 load finish 时间 domReady:50.49、load finish:5507.668
5 Android 手淘 WebView 白屏时间长、一直 loading 直接页面可见、domReady:5058.91、onLoad:5073.81 页面立即可见、loading 消失快、等 5s 后更新 domReady 时间和 load 时间 domReady:4176.34、onLoad:4209.50 页面立即可见、loading 消失快、domReady:6011.18、onLoad:6031.93 页面可见快、loading 之后消失、等 5s 后更新 load finish 时间 domReady:36.31、load finish:5081.76 页面可见快、loading 随后消失、等 5s 后更新 load finish 时间 domReady:25.11、load finish:5113.81 页面可见快、loading 随后消失、等 5s 后更新 domReady 时间和 load 时间 domReady:5213.11、load finish:5312.19 页面可见快、loading 随后消失、等 5s 后更新 load finish 时间 domReady:89.67、load finish:5589.95

从以上测试结果可以看出以下结论:

  • 横向看, iOS Safari 和 Android browser 的在页面可见、domReady、onLoad 的时间表现一致。
  • 纵向看,bottom script、document.write 和 defer 三列,可知 document.write 和 defer 无任何异步效果,可见时间、domReady、onLoad 的触发时间和 bottom script 的情况一致。
  • 纵向看,async defer 联合用和 async 的表现一致,故合并为 async。
  • 纵向看,script 放页头(head script)和 script 放 body 底部(bottom script)。iOS Safari 、Android browser 和 iOS WebView 表现一致,即使 script 放在 body 的底部也无济于事,页面白屏时间长,要等到 domReady 5s 多后结束才显示页面;唯独 Android WebView 的表现和 PC 的 Chrome 一致。
  • 单纯看手淘 WebView 容器中 loading 消失的时间,这个时间点 iOS 和 Android 的表现一致,即都是在 UIWebView 的 didFinishLoad 事件触发时消失。这个事件的触发可能在 domReady 之前(如:A3、B3),也可能在 domReady 之后(如:D3、E3);这个事件触发和 JS 中的 onLoad 触发时机也没有必然的联系,可能在 onLoad 之前(如:D3、E3)也可能在 onLoad 几乎同时(如:A5)。 didiFinishLoad 到底是什么时机触发的呢,详见下章。
  • 页面可见时间,getScript 方式和 async 方式页面可见都非常快,domReady 的时间触发得也非常快,客户端的 loading 在 domReady 稍后即消失。原因是因为最后耗时的 JS 请求异步化了,没有阻塞浏览器的 DOM CSSOM 构建,页面渲染完成就立刻可见了。整体看,如果 domReady 的时间快,则页面可见快;反之如果页面可见快,domReady 的时间不一定快,如 B5、B1、C1、C5、F1、F5。如果异步化耗时长的 JS,domReady 和 onLoad 的时间差距是很大的,不做任何处理 onLoad 的时间 domReady 的时间差 30ms 左右。所以在异步化的前提下,可以用 domReady 的时间作为页面可见的时间。

defer 和 async

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

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

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

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

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

图片 15

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

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

图片 16

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

改变阻塞模式:defer 与 async

为什么要将 script 加载的 defer 与 async 方式放到后面呢?因为这两种方式是的出现,全是由于前面讲的那些阻塞条件的存在。换句话说,defer 与 async 方式可以改变之前的那些阻塞情形。

首先,注意 async 与 defer 属性对于 inline-script 都是无效的,所以下面这个示例中三个 script 标签的代码会从上到下依次执行。

JavaScript

<!-- 按照从上到下的顺序输出 1 2 3 --> <script async> console.log("1"); </script> <script defer> console.log("2"); </script> <script> console.log("3"); </script>

1
2
3
4
5
6
7
8
9
10
<!-- 按照从上到下的顺序输出 1 2 3 -->
<script async>
  console.log("1");
</script>
<script defer>
  console.log("2");
</script>
<script>
  console.log("3");
</script>

故,下面两节讨论的内容都是针对设置了 src 属性的 script 标签。

async

<script src="app.js" async></script>

<script src="ad.js" async></script>

<script src="statistics.js" async></script>

async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

从上一段也能推出,多个 async-script 的执行顺序是不确定的。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true,下一节会继续这个话题。

didFinishLoad 到底什么时候触发

didFinishLoad 是 native 定义的事件,该事件触发时手淘 loading 菊花消失,并且 windvane 中的发出请求不再收集,也就是 native 统计出的 pageLoad 时间。在用户数据平台看到的瀑布流请求,就是在 didFinishLoad 触发前收集到的所有请求。

图片 17

经过上方测试,客户端的 didFinisheLoad 事件的触发和 JS 中的 domReady(DOMContentLoaded)和 onLoad 触发没有任何关联。可能在 domReady 之前或之后,也可能在 onLoad 之前或之后。

那它到底是什么时候触发呢? iOS 官方文档 是 Sent after a web view finishes loading a frame。 结合收集的用户请求和测试,didFinishLoad 是在连续发起的请求结束之后触发,监听一段时间内无请求则触发。

所以经常会看到 data_sufei 这个 JS 文件,在有些用户的瀑布流里面有,在有些用户的又没有。原因是这个 JS 是 aplus_wap.js 故意 setTimeout 1s 后发出的,如果页面在 1s 前所有的请求都发完了则触发 didFinishLoad,后面的 data_sufei.js 的时间就不算到 pageLoad 的时间;反之如果接近 1s 页面还有图片等请求还在发,则 data_sufei.js 的时间也会被算到里面。

因此在 JS 中用 setTimeout 来延迟发送请求也有可能会影响 didFinishLoad 的时间,建议 setTimeout 的时间设置得更长一点,如 3s。

preload

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

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

你只需要写上:

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

1
  <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>

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

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

defer

JavaScript

<script src="app1.js" defer></script> <script src="app2.js" defer></script> <script src="app3.js" defer></script>

1
2
3
<script src="app1.js" defer></script>
<script src="app2.js" defer></script>
<script src="app3.js" defer></script>

defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。

defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。

document.createElement

使用 document.createElement 创建的 script 默认是异步的,示例如下。

console.log(document.createElement("script").async); // true

所以,通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。

如果使用 document.createElement 创建 link 标签会怎样呢?

const style = document.createElement("link");

style.rel = "stylesheet";

style.href = "index.css";

document.head.appendChild(style); // 阻塞?

其实这只能通过试验确定,已知的是,Chrome 中已经不会阻塞渲染,Firefox、IE 在以前是阻塞的,现在会怎样我没有试验。

async 和 defer

script 标签上可以添加 defer 和 async 属性来优化此 script 的下载和执行。

结论

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

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

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

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

1 赞 1 收藏 评论

图片 18

async

JavaScript

<script src="app.js" async></script> <script src="ad.js" async></script> <script src="statistics.js" async></script>

1
2
3
<script src="app.js" async></script>
<script src="ad.js" async></script>
<script src="statistics.js" async></script>

async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

从上一段也能推出,多个 async-script 的执行顺序是不确定的。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true,下一节会继续这个话题。

document.write 与 innerHTML

通过 document.write 添加的 link 或 script 标签都相当于添加在 document 中的标签,因为它操作的是 document stream(所以对于 loaded 状态的页面使用 document.write 会自动调用 document.open,这会覆盖原有文档内容)。即正常情况下, link 会阻塞渲染,script 会同步执行。不过这是不推荐的方式,Chrome 已经会显示警告,提示未来有可能禁止这样引入。如果给这种方式引入的 script 添加 async 属性,Chrome 会检查是否同源,对于非同源的 async-script 是不允许这么引入的。

如果使用 innerHTML 引入 script 标签,其中的 JavaScript 不会执行。当然,可以通过 eval() 来手工处理,不过不推荐。如果引入 link 标签,我试验过在 Chrome 中是可以起作用的。另外,outerHTML、insertAdjacentHTML() 应该也是相同的行为,我并没有试验。这三者应该用于文本的操作,即只使用它们添加 text 或普通 HTML Element。

-

defer :延迟

HTML 4.0 规范,其作用是,告诉浏览器,等到 DOM CSSOM 渲染完成,再执行指定脚本。

JavaScript

<script defer src="xx.js"></script>

1
<script defer src="xx.js"></script>
  • 浏览器开始解析 HTML 网页
  • 解析过程中,发现带有 defer 属性的 script 标签
  • 浏览器继续往下解析 HTML 网页,解析完就渲染到页面上,同时并行下载 script 标签中的外部脚本
  • 浏览器完成解析 HTML 网页,此时再执行下载的脚本,完成后触发 DOMContentLoaded

下载的脚本文件在 DOMContentLoaded 事件触发前执行(即刚刚读取完标签),而且可以保证执行顺序就是它们在页面上出现的顺序。所以 添加 defer 属性后,domReady 的时间并没有提前,但它可以让页面更快显示出来。

将放在页面上方的 script 加 defer,在 PC Chrome 下其效果相当于 把这个 script 放在底部,页面会先显示。 但对 iOS Safari 和 iOS WebView 加 defer 和 script 放底部一样都是长时间白屏。

document.createElement

使用 document.createElement 创建的 script 默认是异步的,示例如下。

JavaScript

console.log(document.createElement("script").async); // true

1
console.log(document.createElement("script").async); // true

所以,通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。

如果使用 document.createElement 创建 link 标签会怎样呢?

JavaScript

const style = document.createElement("link"); style.rel = "stylesheet"; style.href = "index.css"; document.head.appendChild(style); // 阻塞?

1
2
3
4
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = "index.css";
document.head.appendChild(style); // 阻塞?

其实这只能通过试验确定,已知的是,Chrome 中已经不会阻塞渲染,Firefox、IE 在以前是阻塞的,现在会怎样我没有试验。

async: 异步

HTML 5 规范,其作用是,使用另一个进程下载脚本,下载时不会阻塞渲染,并且下载完成后立刻执行。

JavaScript

<script async src="yy.js"></script>

1
<script async src="yy.js"></script>
  • 浏览器开始解析 HTML 网页
  • 解析过程中,发现带有 async 属性的 script 标签
  • 浏览器继续往下解析 HTML 网页,解析完先显示页面并触发 DOMContentLoaded,同时并行下载 script 标签中的外部脚本
  • 脚本下载完成,浏览器暂停解析 HTML 网页,开始执行下载的脚本
  • 脚本执行完毕,浏览器恢复解析 HTML 网页

async 属性可以保证脚本下载的同时,浏览器继续渲染。但是 async 无法保证脚本的执行顺序。哪个脚本先下载结束,就先执行那个脚本。

document.write 与 innerHTML

通过 document.write 添加的 link 或 script 标签都相当于添加在 document 中的标签,因为它操作的是 document stream(所以对于 loaded 状态的页面使用 document.write 会自动调用 document.open,这会覆盖原有文档内容)。即正常情况下, link 会阻塞渲染,script 会同步执行。不过这是不推荐的方式,Chrome 已经会显示警告,提示未来有可能禁止这样引入。如果给这种方式引入的 script 添加 async 属性,Chrome 会检查是否同源,对于非同源的 async-script 是不允许这么引入的。

如果使用 innerHTML 引入 script 标签,其中的 JavaScript 不会执行。当然,可以通过 eval() 来手工处理,不过不推荐。如果引入 link 标签,我试验过在 Chrome 中是可以起作用的。另外,outerHTML、insertAdjacentHTML() 应该也是相同的行为,我并没有试验。这三者应该用于文本的操作,即只使用它们添加 text 或普通 HTML Element。

如何选择 async 和 defer

  • defer 可以保证执行顺序,async 不行【注:hack】
  • async 可以提前触发 domReadydefer 不行【注:Firefox 的 defer 也可以提前触发 domready
  • defer 在 iOS 和部分 Android 下依然阻塞渲染,白屏时间长。
  • 当 script 同时加 asyncdefer 属性时,后者不起作用,浏览器行为由 async 属性决定。
  • asyncdefer 的兼容性不一致,好在 asyncdefer 无线端基本都支持,async 不支持 IE 9-。
    附 async 兼容性 defer 兼容性

参考资料

Mobile Analysis in PageSpeed Insights

Web Fundamentals

MDN – HTML element reference

1 赞 4 收藏 2 评论

图片 19

script inject 和 async

JavaScript

<!-- BAD --> <script src="//g.alicdn.com/large.js"></script> <!-- GOOD --> <script> var script = document.createElement('script'); script.src = "//g.alicdn.com/large.js"; document.getElementsByTagName('head')[0].appendChild(script); </script>

1
2
3
4
5
6
7
8
9
  <!-- BAD -->
<script src="//g.alicdn.com/large.js"></script>
 
<!-- GOOD -->
<script>
  var script = document.createElement('script');
  script.src = "//g.alicdn.com/large.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>

我们通常用这种 inject script 的方式来异步加载文件,特别是以前 Sea.jsKISSY 的盛行时,出现大量使用$.use 来加载页面入口文件。这种方式和 async 的一样都能异步化 JS,不阻塞页面渲染。但真的是最快的吗?

一个常见的页面如下:一个 CSS,两个异步的 JS

JS 使用 script inject 的方式测试结果如下,DEMO:

图片 20

JS 使用 async 的方式测试结果如下, DEMO:

图片 21

对比结果发现,通过 `` 的方式的 JS 可以和 CSS 并发下载,这样整个页面 load 时间变得更短,JS 更快执行完,这样页面的交互或数据等可以更快更新。为什么呢?因为浏览器有类似 ‘preload scanner’ 的功能,在 HTML 解析时就可以提前并发去下载 JS 文件,如果把 JS 的文件隐藏在 JS 逻辑中,浏览器就没这么智能发现了。

也许大家会说,现在 CSS/JS 都预加载到客户端了,怎么加载不重要。但页面有可能分享出去也有可能运行在浏览器中,也有可能预加载失效。

综合上面 async 和 defer,推荐以下用法。

JavaScript

<!-- 现代浏览器用 'async', ie9-用 'defer' --> <script src="//g.alicdn.com/alilog/mlog/aplus_wap.js" async defer></script>

1
2
<!-- 现代浏览器用 'async', ie9-用 'defer' -->
<script src="//g.alicdn.com/alilog/mlog/aplus_wap.js" async defer></script>

其实现在无线站点 aplus.js 可以完全用这种方式引入,既不会阻塞 DOMCSSOM,也不会延长整个页面 onLoad 时间,而不是原来的 PC 上的 script inject方式。

如果 aplus.js 在 PC 上这么用,IE 8/IE 9 应用的是 defer 属性,不会阻塞页面渲染,但是这个 JS 需要执行完后才触发 domReady(DOMContentLoaded)事件,故在 IE 8/IE 9 下可能会影响 domReady 的时间。

最后建议

  • 业务 JS 尽量异步,放 body 底部的 JS 在 iOS 上和部分 Android 是无效的,依然会阻塞首屏渲染。
  • 异步的方式尽可能原生用async,容器(浏览器、webview 等)级别自带优化,不要通过 JS 去模拟实现,如 getScript/ajax/KISSY.use/$.use 等。
  • 有顺序依赖关系的 JS 可以加 defer,不改变执行顺序,相当于放到页面底部,如 TMS head 中一时无法挪动位置的类库等。

参考资料

  • 1 赞 2 收藏 评论

本文由pc28.am发布于前端技术,转载请注明出处:更快地构建,过程与原理

上一篇:实战教程 下一篇:没有了
猜你喜欢
热门排行
精彩图文