加速异步代码段

如果你做过一段时间的网页开发,你很可能已经遇到过异步代码片段。它最简单的形式看起来像这样:

1
2
3
4
5
<script>
var script = document.createElement('script');
script.src = 'https://third-party.io/bundle.min.js';
document.head.appendChild(script);
</script>
  • 创建了一个 <script> 元素……
  • 它的 src 属性是 https://third-party.io/bundle.min.js……
  • 并将它添加到 <head> 中。

但是大多数开发者都不知道这是如何工作的,它做了什么,或者我们为什么要这样做。让我们从这里开始。

什么是异步代码片段?

这些代码片段通常由第三方提供,供你复制/粘贴到你的 HTML 中——通常放在 <head> 中。他们给我们这个繁琐的代码片段,而不是一个简洁得多的 <script src="">,纯粹是出于历史原因:异步代码片段是一种遗留的性能优化。

当从 DOM 请求 JavaScript 文件时,它们可以是阻塞的或非阻塞的。一般来说,阻塞文件对性能更差,特别是当它们托管在其他人的源上时。异步代码片段动态注入文件,使其成为异步的或非阻塞的,从而更快。

但是什么使这个片段实际上使文件变成异步的呢?这里没有看到 async 属性,代码本身也没有做什么特别的事情:它只是注入了一个脚本,解析为 DOM 中的一个常规的、阻塞的 <script> 标签:

1
2
3
...
<script src="https://third-party.io/bundle.min.js"></script>
</head>

这与正常加载文件有什么不同呢?我们做了什么使它变成异步的呢?

实际上答案是没有。我们什么也没做。这是规范规定任何动态注入的脚本应被视为异步的。仅仅通过用脚本插入脚本,我们就自动选择了标准的浏览器行为。这就是整个技术的全部内容。

但这引出了一个问题……我们不能直接使用 async 属性吗?

这意味着添加 script.async='async' 是多余的——不需要这样做。有趣的是,添加 script.defer='defer' 确实有效,但同样,你不需要异步代码片段来实现这一结果——只需使用一个常规的 <script src="" defer>

遗留的异步支持

直到 2015 年所有浏览器才支持 async 属性。对于所有主要浏览器,这个日期是 2011 年——超过十年前。所以,为了应对这一情况,第三方供应商采用了异步代码片段。异步代码片段在其最基本形式上,是一种 polyfill(填补功能空白的代码)。

如今,我们应该直接使用 <script src="" async>

Polyfill 有什么问题?

如果polyfill可以正常工作,那么使用 async 属性有什么好处呢?当然,使用更现代的东西感觉更好,但如果它们在功能上是相同的,那会更好吗?

不幸的是,这种性能polyfill实际上对性能不利。

虽然生成的脚本是异步的,但创建它的 <script> 块是完全同步的,这意味着脚本的发现受到它之前发生的所有同步工作的控制,无论是其他同步的 JSHTML 还是 CSS。实际上,我们将文件隐藏到了最后一刻,这意味着我们完全未能利用浏览器最优雅的内部机制之一——预加载扫描器。

预加载扫描器

所有主流浏览器都包含一个惰性的次要解析器,称为预加载扫描器。预加载扫描器的任务是提前于主解析器异步下载它可能找到的任何子资源:图像、样式表、脚本等。它与主解析器解析和构建 DOM 的工作并行进行。

由于预加载扫描器是惰性的,它不会运行任何 JavaScript。事实上,大多数情况下,它只会真正关注在 HTML 中定义的可标记的 srchref 属性。由于它不运行任何 JavaScript,预加载扫描器无法发现我们异步片段中包含的脚本引用。这使得脚本完全隐藏,从而无法与其他资源并行获取。请看下面的瀑布图:

在这里我们可以清楚地看到,浏览器直到处理完 CSS (2) 后才发现脚本的引用 (3)。这是因为同步的 CSS 会阻止任何后续同步 JS 的执行,并且记住,我们的异步片段本身是完全同步的。

垂直的紫色线是一个 performance.mark(),标记脚本实际执行的时间点。因此,我们看到完全缺乏并行化,并且执行时间戳为 3127ms。

新语法

现在有几种不同的方法可以重写你的异步片段。对于最简单的情况,例如:

1
2
3
4
5
<script>
var script = document.createElement('script');
script.src = 'https://third-party.io/bundle.min.js';
document.head.appendChild(script);
</script>

我们可以将其直接替换为以下内容,放在相同位置或 HTML 中的稍后位置:

1
<script src="https://third-party.io/bundle.min.js" async></script>

这些在功能上是相同的。

如果你对完全替换异步片段感到紧张,或者异步片段包含配置变量,那么你可以将以下内容:

1
2
3
4
5
6
7
8
<script>
var user_id = 'USR-135-6911-7';
var experiments = true;
var prod = true;
var script = document.createElement('script');
script.src = 'https://third-party.io/bundle.min.js?user=' + user_id;
document.head.appendChild(script);
</script>

替换为:

1
2
3
4
5
6
<script>
var user_id = 'USR-135-6911-7';
var experiments = true;
var prod = true;
</script>
<script src="https://third-party.io/bundle.min.js?user=USR-135-6911-7" async></script>

这是可行的,因为即使 <script src="" async> 是异步的,之前的 <script> 块是同步的,因此保证会先运行,正确初始化配置变量。

异步不意味着“准备好就运行”,而是“在声明之后准备好就运行”。任何在异步脚本之前定义的同步工作将始终首先执行。

现在我们可以看到预加载扫描器在工作:请求的完全并行化,JS 执行时间戳为 2340ms。

有趣的是,使用这种新语法,脚本本身下载时间长了 297ms,但仍然早了 787ms 执行!这就是预加载扫描器的力量。

无法避免异步片段的情况

有几种情况下我们无法避免异步片段,因此无法真正加快它们的速度。

动态脚本位置

最值得注意的是,当脚本的 URL 需要是动态的,例如,我们需要将当前页面的 URL 传递到文件路径中:

1
2
3
4
5
6
<script>
var script = document.createElement('script');
var url = document.URL;
script.src = 'https://third-party.io/bundle.min.js&URL=' + url;
document.head.appendChild(script);
</script>

在这种情况下,异步片段更多的是关于处理动态问题,而不是性能问题。如果第三方足够重要,我唯一推荐的优化是为相关源添加 preconnect:

1
2
3
4
5
6
7
<link rel="preconnect" href="https://third-party.io">
<script>
var script = document.createElement('script');
var url = document.URL;
script.src = 'https://third-party.io/bundle.min.js&URL=' + url;
document.head.appendChild(script);
</script>
注入不可控制的页面

第二种最可能需要异步片段的情况是,如果你是第三方,将第四方注入到别人的 DOM 中。在这种情况下,异步片段更多的是关于访问问题,而不是性能问题。我在这里不会推荐任何性能增强。永远不要为第四方、第五方、第六方预连接。