从 Google Analytics 的统计代码说起 —— 谈谈 script 标签的 async 和 defer 属性

从 Google Analytics 的统计代码说起 —— 谈谈 script 标签的 async 和 defer 属性

技术向约 2.7 千字

之前我在「天下武功,唯快不破 —— 我是这样优化博客的」一文中提到「对于大部分浏览器来说,确保 JS 异步加载和执行的做法其实是在操作 DOM 动态插入 <script async>」,但是并没有给出详细原因。这一次我以 Google Analytics 的统计代码为引子,详细讲讲 <script>asyncdefer 属性、以及它们背后的故事。

拆开 Google Analytic 的统计代码

现在 Google 推出了 Google Tag Manager,通过 window.gtag 可以轻易加载包括 analytics.js、Google AD、Google OAuth 等 Google 插件:

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-122669675-1"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-XXXXX-Y');
</script>

在 Google Tag Manager 出现之前,大部分人更熟悉传统的 analytics.js 统计代码:

<!-- Google Analytics -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<!-- End Google Analytics -->

Google 提供的代码是经过压缩后的版本。不过这段代码很短,完全不使用 debugger 等方法也可以还原出原始的代码,让我们拆拆看吧。

首先不难看出这是一个 IIFE 函数,变量 isogr分别指的是 windowdocument'script'https://www.google-analytics.com/analytics.js'ga'(变量 am 尚未被赋值)。让我们替换这些变量、把 IIFE 拆开:

window['GoogleAnalyticsObject'] = 'ga';
window['ga'] = window['ga'] || function () {
  window['ga'].q = (window['ga'].q || []).push(arguments)
};
window['ga'].l = +new Date();
var a = document.createElement('script');
a.async = 1;
a.src = 'https://www.google-analytics.com/analytics.js';
var m = document.getElementsByTagName('script')[0];
m.parentNode.insertBefore(a, m);

这下就清楚多了。抛开 Google Analytics 的队列初始化、函数初始化的操作,analytics.js 本质上是通过 JS 动态创建一个 <script async> 标签、并插入到 DOM 中所有 <script> 标签之前。

Google 关于 analytics.js 的文档 是这么介绍的:

While the Google Analytics tag described above ensures the script will be loaded and executed asynchronously on all browsers, it has the disadvantage of not allowing modern browsers to preload the script.

虽然上述代码保证了脚本在所有浏览器上都会异步地加载和执行,但是它也有一个缺陷、它不能够让浏览器 preload 外部的脚本文件。

Google 在文档中也给出了「现代浏览器」专用的代码,看起来和今天的 Google Tag Manager 载入代码非常接近:

<!-- Google Analytics -->
<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<script async src='https://www.google-analytics.com/analytics.js'></script>
<!-- End Google Analytics -->

Google 文档也对第二种代码进行了介绍:

The alternative async tag below adds support for preloading, which will provide a small performance boost on modern browsers, but can degrade to synchronous loading and execution on IE 9 and older mobile browsers that do not recognize the async script attribute.

第二种方法在现代浏览器上会略微提升性能,因为现代浏览器支持 preloading。但是在不认识 async 属性的 IE9 和一些老旧的移动端浏览器上,这种方法会降级为同步加载和执行。

所以,结合上述代码和 Google 文档中的描述,我们不难得出结论:

  • 第一种做法(由 JS 生成 <script async> 标签、动态插入 DOM 中)可以确保 analytics.js 被异步加载和执行,但是在现代浏览器上由于不能提前发现 analytics.js 、不能 preload。
  • 第二种做法在 HTML 中直接声明了 <script async>、可以被现代浏览器在 Parse HTML 阶段发现 analytics.js 并 preload。但是对于「并不现代的」浏览器中,这种做法不能保证 analytics.js 被异步加载和执行。

那么,为什么 Google 要这么说呢?

混乱的 defer 和 async 实现

绝大部分从事前端开发的程序员应该都非常了解 deferasync 以及他们的区别:

  • async 的加载不会阻碍 DOM 的解析,但是当加载完后就会立即执行,执行时会阻碍 DOM 的解析
  • defer 的加载也不会阻碍 DOM 的解析,并且会在 DOM 解析完后、DOMContentLoaded 触发之前执行

鲜少有人知道的是,在 HTML5 之前,异步加载和执行外部脚本有一段混乱而荒唐的历史。

defer 属性的历史可以追溯到 1999 年 12 月 24 日制定的 HTML4.01 规范。在 规范的第 18.2 节中的 18.2.1「SCRIPT 元素」 中,有对 defer 属性的描述:

When set, this boolean attribute provides a hint to the user agent that the script is not going to generate any document content (e.g., no "document.write" in javascript) and thus, the user agent can continue parsing and rendering.

该 Boolean Attribute 用于提示 User Agent 这份脚本不会生成 document 内容(如,不使用 document.write),因此 User Agent 在遇到该脚本时不应该暂停解析和渲染 document。

值得注意的是,在 HTML4.01 规范中没有强制浏览器用什么行为面对 defer 属性,各个浏览器的实现导致了千奇百怪的行为,这里仅简单列举几个 Quirk 行为:

终于在 HTML5 中,不仅 defer 应有的行为被正式确定下来,而且还介绍了一个新的属性 async,提供了「真正异步地」加载和执行外部脚本的方式。也正是因为 HTML5 正式发布,下面这张介绍 deferasync 区别的图也开始深入人心:

这张出现在绝大部分文章中的图,正是来自 HTML5 规范:https://html.spec.whatwg.org/multipage/scripting.html#attr-script-async

IE 10、Chrome 8、Firefox 3.6 都对 async 属性提供了支持。相对于 defer 混乱的实现,浏览器对 async 的实现可谓是乖巧了许多:除 Safari 5.0 会无视 async 的取值外(async=false 时仍然会异步地加载和执行该外部脚本。这一 Bug 在 Safari 5.1 即被修复),绝大部分浏览器的实现都很正常。

等等,动态插入的 script 标签呢

如果你还记得 Google 给出的第一种 analytics.js 的加载方法的话(什么?你不记得了?快回到第一节再看一遍),你会注意到 Google 为了「保证了脚本在所有浏览器上都会异步地加载和执行」,使用了 document.createElement('script')el.parentNode.insertBefore 动态插入 <script> 标签。这就引出了这一节的内容:动态插入的 <script> 应该按照什么顺序加载和执行呢?

这个问题在 HTML4.01 的规范中完全没有说明,因此 HTML5 发布之前,当时的浏览器也依然采用了自己的实现:

  • 对于 WebKit、Blink 和 IE,动态插入的脚本将会被默认视为需要被异步加载和执行的脚本。如果要同步执行,需要显式声明 async = false
  • 对于 Presto(早期 Opera 使用的自研内核)和 Firefox <= 4,动态插入的脚本会默认同步加载和执行,除非显示声明 async = true。不过从 Firefox 4.0 开始,动态插入的脚本将会异步加载和执行,以和 IE、WebKit 行为保持一致(因此同步执行也一样需要显式声明 async = false );当 Opera 15 开始更换为基于 Chormium 开发(当时 Chromium 版本为 28)后,行为也变得和 IE、WebKit 一致。

在 HTML5 规范中,这一问题终于被一劳永逸的解决了。在「HTML Standard - 4.12.1.1 Processing Model」中明确规定了浏览器应该如何处理动态插入的 <script>。对于传统的脚本(非 Module),大致处理流程如下所示:

是否包含 src 属性?
是否包含 src 属性?
是否包含 defer 属性?
是否包含 defer 属性?
是否包含 async 属性?
是否包含 async 属性?
将脚本添加到「文档解析完成后执行」的队列的最后面
将脚本添加到「文档解析完成后执行」的队列的最后面
是否包含 async 属性
是否包含 async 属性
动态插入 script 标签
动态插入 script 标签
当「插入该 script」的 script 执行完后立刻执行
当「插入该 script」的 script 执行完后立刻执行
将脚本添加到「立即加载和执行」的队列的最后面
将脚本添加到「立即加载和执行」的队列的最后面

本图由 Sukka 绘制,按照 CC BY-NC-SA 4.0 协议共享。使用时请遵守许可协议,注明出处、不得被用于商业用途(除非获得书面许可)。

尾声

Google Analytics 在 2013 年开始逐渐用 analytics.js 代替 ga.js 时,市面上同时存在有现代的支持 HTML5 和 async 属性的浏览器、和不支持 async 属性的「前 HTML5 时代的」古董浏览器。如果要保证在所有的浏览器上 Google Analytics 都能被异步地加载和执行,动态地将 <script> 标签插入 DOM 中、并显式声明 async = true 无疑是唯一的方法。随着古董浏览器逐渐退出人们的视线、实现 async 属性的浏览器在全球的份额已经达到了 98.08%,Google 终于开始直接用 <script async> 标签引入 Google Tag Manager。

从 Google Analytics 的统计代码说起 —— 谈谈 script 标签的 async 和 defer 属性
本文作者
Sukka
发布于
2020-10-13
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...