从 Google Analytics 的统计代码说起 —— 谈谈 script 标签的 async 和 defer 属性
之前我在「天下武功,唯快不破 —— 我是这样优化博客的」一文中提到「对于大部分浏览器来说,确保 JS 异步加载和执行的做法其实是在操作 DOM 动态插入 <script async>
」,但是并没有给出详细原因。这一次我以 Google Analytics 的统计代码为引子,详细讲讲 <script>
的 async
和 defer
属性、以及它们背后的故事。
拆开 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 函数,变量 i
、s
、o
、g
和 r
分别指的是 window
、document
、'script'
、https://www.google-analytics.com/analytics.js
和 'ga'
(变量 a
和 m
尚未被赋值)。让我们替换这些变量、把 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 实现
绝大部分从事前端开发的程序员应该都非常了解 defer
和 async
以及他们的区别:
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 行为:
- 2001 年发布的 IE 6 开始对
defer
属性提供支持,但是直到 11 年后 IE 10 发布之前,IE 都不能保证带有defer
属性的<script>
能够按顺序执行:如果第一个defer
的<script>
使用 DOM API 修改了 DOM 结构、那么第二个<script defer>
会在第一个<script>
执行完毕之前就会开始执行。 - 2009 年发布的 Firefox 3.5 开始对
defer
属性提供支持,但是带有defer
的<script>
可能会在DOMContentLoaded
之后加载和执行,而且 Firefox 甚至会异步执行带有defer
属性的内联脚本。这一行为直到五年后在 Firefox 31 中才被修复。 - 2010 年的 Chrome 8 开始实现
defer
属性。但如果页面的 MIME Type 为 XHTML(application/xhtml+xml
),那么 Chrome 会无视<script>
的defer
属性,因为 在 XHTML 中defer
属性只是一个可选实现。
终于在 HTML5 中,不仅 defer
应有的行为被正式确定下来,而且还介绍了一个新的属性 async
,提供了「真正异步地」加载和执行外部脚本的方式。也正是因为 HTML5 正式发布,下面这张介绍 defer
和 async
区别的图也开始深入人心:
这张出现在绝大部分文章中的图,正是来自 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),大致处理流程如下所示:
本图由 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。