深入探究脚本加载的模糊背景

Jake Archibald
Jake Archibald

简介

在本文中,我将向您介绍如何在浏览器中加载一些 JavaScript 并执行它。

不,等等,请回来!我知道这听起来很平凡且简单,但请注意,这发生在浏览器中,在浏览器中,理论上简单的操作会因旧版而变得奇怪。了解这些怪癖后,您可以选择最快、干扰最少的脚本加载方式。如果您时间紧迫,请跳至快速参考部分。

首先,下面是规范定义脚本可采用的各种下载和执行方式:

WHATWG 关于脚本加载的规范
关于脚本加载的 WHATWG

与所有 WHATWG 规范一样,它最初看起来就像拼图工厂遭到集束炸弹袭击后的场景,但当您第 5 次阅读它并擦去眼睛上的血迹后,就会发现它其实非常有趣:

我的第一个脚本包含

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

啊,多么幸福的简单。在这里,浏览器会并行下载两个脚本,并尽快执行它们,保持顺序不变。“2.js” 在“1.js”执行(或未执行)之前不会执行,“1.js”在之前的脚本或样式表执行之前不会执行,等等。

遗憾的是,在所有这些操作发生期间,浏览器会阻止进一步呈现网页。这是因为“网络的第一个时代”的 DOM API 允许将字符串附加到解析器正在处理的内容(例如 document.write)上。较新的浏览器会继续在后台扫描或解析文档,并触发下载可能需要的外部内容(js、图片、css 等),但仍会阻止呈现。

因此,性能领域的专家建议将脚本元素放在文档的末尾,以尽可能少地阻塞内容。遗憾的是,这意味着浏览器在下载完您的所有 HTML 之前不会看到您的脚本,而到那时,它已经开始下载其他内容,例如 CSS、图片和 iframe。现代浏览器足够智能,会优先处理 JavaScript 代码,而不是图片,但我们可以做得更好。

谢谢 IE!(我不是在讽刺)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft 认识到这些性能问题,并在 Internet Explorer 4 中引入了“延迟”功能。这句话的意思是“我承诺不会使用 document.write 等内容向解析器注入内容。如果我违反承诺,你随时可以以任何你认为合适的方式惩罚我”。此属性已融入 HTML4 中,并且出现在其他浏览器中。

在上面的示例中,浏览器将并行下载这两个脚本,并在 DOMContentLoaded 触发之前执行它们,从而保持脚本的顺序。

就像羊毛厂里的集束炸弹一样,“延迟”变得一团糟。在“src”和“defer”属性以及脚本标记与动态添加的脚本之间,我们有 6 种添加脚本的模式。当然,浏览器对执行顺序存在分歧。Mozilla 曾撰写了一篇关于该问题的精彩文章,介绍了 2009 年的情况。

WHATWG 明确说明了此行为,声明“defer”对动态添加的脚本或缺少“src”的脚本没有影响。否则,延迟脚本应在文档解析后按添加顺序运行。

谢谢 IE!(好了,现在我说的是讽刺话了)

它既能给予,也能夺走。遗憾的是,IE4-9 中存在一个严重的 bug,可能会导致脚本以意外的顺序执行。整个过程如下:

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js

console.log('3');

假设页面上有一个段落,日志的预期顺序为 [1, 2, 3],但在 IE9 及更低版本中,您会得到 [1, 3, 2]。特定 DOM 操作会导致 IE 暂停当前脚本执行,并执行其他待处理的脚本,然后再继续。

不过,即使在没有 bug 的实现(例如 IE10 和其他浏览器)中,脚本执行也会延迟到整个文档下载并解析完毕为止。如果您无论如何都要等待 DOMContentLoaded,这种做法会很方便,但如果您想大幅提升性能,可以更早地开始添加监听器并进行引导…

使用 HTML5 解决问题

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

HTML5 为我们提供了一个新属性“async”,该属性假定您不会使用 document.write,但不会等到文档解析完毕才执行。浏览器将并行下载这两个脚本,并尽快执行它们。

遗憾的是,由于它们会尽快执行,因此“2.js”可能会在“1.js”之前执行。如果它们是独立的,则没问题,也许“1.js”是与“2.js”无关的跟踪脚本。但如果“1.js”是“2.js”依赖的 jQuery 的 CDN 副本,则您的网页将会被错误所覆盖,就像在… 我不知道… 我对这个问题一无所知。

我知道我们需要什么,一个 JavaScript 库!

理想情况下,一组脚本应立即下载,且不会阻塞渲染,并应按照添加顺序尽快执行。很遗憾,HTML 不允许这样做。

这个问题可以通过 JavaScript 以几种方式解决。有些库需要您更改 JavaScript,将其封装在库以正确顺序调用的回调中(例如 RequireJS)。其他人会使用 XHR 并行下载,然后按正确的顺序 eval(),但这对其他网域上的脚本不起作用,除非它们包含 CORS 标头且浏览器支持该标头。有些人甚至使用了超级魔法黑客攻击,例如 LabJS

这些黑客攻击涉及诱骗浏览器以某种方式下载资源,以便在下载完成时触发事件,但避免执行该事件。在 LabJS 中,脚本会使用错误的 MIME 类型(例如 <script type="script/cache" src="...">)添加。下载完所有脚本后,系统会再次以正确的类型添加这些脚本,希望浏览器能够直接从缓存中获取这些脚本,并按顺序立即执行它们。这依赖于方便但未指定的行为,当 HTML5 声明浏览器不应下载类型无法识别的脚本时,此行为会失效。值得注意的是,LabJS 适应了这些变化,现在结合使用本文中的方法。

但是,脚本加载器也有其自己的性能问题,您需要等待库的 JavaScript 下载并解析,然后才能开始下载其管理的任何脚本。此外,我们将如何加载脚本加载器?我们将如何加载脚本,以告知脚本加载器要加载什么?谁在监视守望者?为什么我裸露?这些都是很难的问题。

基本上,如果您必须先下载额外的脚本文件,然后才能考虑下载其他脚本,那么您就已经输掉了性能之战。

DOM 来助你脱困

答案实际上在 HTML5 规范中,但隐藏在脚本加载部分的底部。

我们将其翻译为“地球人”:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

动态创建并添加到文档中的脚本默认是异步的,它们不会阻塞渲染,也不会在下载后立即执行,这意味着它们可能会以错误的顺序显示。不过,我们可以明确将它们标记为非异步:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

这样一来,我们的脚本就可以实现普通 HTML 无法实现的混合行为。通过明确设置为非异步,脚本会添加到执行队列中,也就是第一个纯 HTML 示例中添加到的同一个队列。不过,由于这些脚本是动态创建的,它们会在文档解析之外执行,因此在下载这些脚本时不会阻止呈现(不要将非异步脚本加载与同步 XHR 混淆,后者绝不是好事)。

上述脚本应内嵌在网页的标头中,以便尽快将脚本下载到队列中,同时不会中断渐进式呈现,并按照您指定的顺序尽快执行。您可以在“1.js”之前免费下载“2.js”,但只有在“1.js”成功下载并执行或下载和执行都失败后,系统才会执行“2.js”。太棒了!异步下载,但有序执行!

所有支持 async 属性的浏览器均支持以这种方式加载脚本,但 Safari 5.0 除外(5.1 版本可以)。此外,由于不支持异步属性,因此所有版本的 Firefox 和 Opera 都受支持,因此无论如何都是按照动态添加的脚本添加到文档中的顺序执行动态添加的脚本,非常方便。

这是加载脚本的最快方式,对吗?对吧?

如果您要动态决定要加载哪些脚本,则需要,否则可能不需要。在上述示例中,浏览器必须解析和执行脚本,才能发现要下载哪些脚本。这样一来,预加载扫描器便无法得知您的脚本。浏览器会使用这些扫描仪来发现您接下来可能会访问的网页上的资源,或者在解析器被其他资源阻止时发现网页资源。

我们可以通过将以下内容置于文档的标头,使之重新被用户发现:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

这会告知浏览器该网页需要 1.js 和 2.js。link[rel=subresource]link[rel=prefetch] 类似,但具有不同的语义。遗憾的是,目前只有 Chrome 支持此功能,并且您必须声明要加载哪些脚本两次,一次通过链接元素,另一次在脚本中。

更正:我最初说这些内容是由预加载扫描器提取的,但事实并非如此,它们是由常规解析器提取的。不过,预加载扫描器可以检测到这些内容,但目前还不能,而可执行代码中包含的脚本永远无法预加载。感谢 Yoav Weiss 在评论中更正我。

我发现这篇文章令人沮丧

这种情况令人沮丧,您也应该感到沮丧。没有非重复且声明式的方法可以快速异步下载脚本,同时控制执行顺序。借助 HTTP2/SPDY,您可以将请求开销降到最低,以便以多个可单独缓存的小文件形式提交脚本,这是最快的方式。不妨想象一下:

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

每个增强脚本都处理特定的页面组件,但都需要 dependencies.js 中的实用程序函数。理想情况下,我们希望异步下载所有内容,然后尽快执行增强脚本(可以按任何顺序,但必须在 dependencies.js 之后)。它是一种渐进增强的方式! 遗憾的是,除非修改脚本本身以跟踪 dependencies.js 的加载状态,否则无法以声明方式实现此目的。即使将 async 设为 false 也无法解决此问题,因为 enhancement-10.js 的执行将在 1-9 上阻塞。事实上,只有一种浏览器能够在不使用黑客手段的情况下实现这一点…

IE 有个想法!

IE 加载脚本的方式与其他浏览器不同。

var script = document.createElement('script');
script.src = 'whatever.js';

IE 现在会开始下载“whatever.js”,而其他浏览器则会等到脚本添加到文档中后才开始下载。IE 还有一个事件“readystatechange”和一个属性“readystate”,可用于告知我们加载进度。这实际上非常有用,因为它让我们可以独立控制脚本的加载和执行。

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

我们可以通过选择何时将脚本添加到文档中来构建复杂的依赖关系模型。IE 从版本 6 开始支持此模型。这很有趣,但它仍然存在与 async=false 相同的预加载器可检测性问题。

够了!应如何加载脚本?

好的好的。如果您希望以不会阻塞渲染、不涉及重复且具有出色浏览器支持的方式加载脚本,建议您采用以下方法:

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

那个。在 body 元素的末尾。是的,成为 Web 开发者就像是西西弗斯国王(boom! 100 个时髦人士积分,因为您提到了希腊神话!由于 HTML 和浏览器的限制,我们无法取得更理想的效果。

我希望 JavaScript 模块能够为我们提供一种声明性非阻塞方法来加载脚本并控制执行顺序,不过这需要将脚本编写为模块。

哎呀,现在肯定有更好的方法。

当然,如果您想在性能方面取得突破,并且不介意复杂性和重复性,可以结合使用上述几种技巧。

首先,我们为预加载器添加子资源声明:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

然后,内嵌在文档标头中,使用 async=false 通过 JavaScript 加载脚本,然后回退到 IE 基于 readystate 的脚本加载,再后退至 defer。

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

经过一些技巧和缩减后,它将变为 362 字节 + 您的脚本网址:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

与简单的脚本包含相比,是否值得增加额外的字节?如果您已经在使用 JavaScript 有条件地加载脚本(如 BBC 所做的那样),那么提前触发这些下载也可能会有益。否则,也许不能,坚持使用简单的正文结束方法。

哎,现在我知道为什么 WHATWG 脚本加载部分如此之大。我需要喝一杯。

快速参考

纯脚本元素

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

规范中规定:一并下载,在所有待处理的 CSS 之后按顺序执行,阻塞渲染,直到完成。 浏览器说:是的!

推迟

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

规范规定:一并下载,在 DOMContentLoaded 之前按顺序执行。忽略没有“src”的脚本的“defer”。IE < 10 显示:我可能会在执行 1.js 的中途执行 2.js。这不是很有趣吗? 红色的浏览器表示:我不知道这个“延迟”是什么,我会像没有这个脚本一样加载脚本。 其他浏览器会说:好的,但我可能不会忽略没有“src”的脚本的“defer”。

异步

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

规范规定:一并下载,按下载的顺序执行。 红色浏览器显示:什么是“async”?我会像没有它一样加载脚本。 其他浏览器的说法:好的。

异步 false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

规范规定:一并下载,下载完毕后按顺序执行。Opera 3.6 之前的 Firefox 版本:我不知道什么是“异步”的,但我正好能够按添加顺序执行通过 JS 添加的脚本。 Safari 5.0 表示:我能理解“异步”,但却不明白使用 JS 将其设为“false”。您的脚本一到达,我就会按任意顺序执行。 IE 10 以下版本的说法:不了解“异步”,但可以使用“onreadystatechange”方法来解决问题。 其他红色标记的浏览器的说法:我不了解这个“异步”功能,我会在脚本到达后立即执行它们,不论顺序如何。 其他所有内容都说:我是你的朋友,我们会按照图书帮你完成这项工作。