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

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 均受支持,因为不支持 async 属性的版本会以动态添加的脚本添加到文档中的顺序执行这些脚本。

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

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

我们可以将以下代码添加到文档的标头中,以便重新添加可被发现性:

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

然后,在文档的标头中,我们使用 JavaScript 和 async=false 内嵌加载脚本,并回退到 IE 基于 readystate 的脚本加载,再回退到延迟加载。

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);
});

规范规定:一并下载,下载完毕后按顺序执行。Firefox 低于 3.6、Opera 说:我不知道这个“异步”是什么,但恰巧的是,我会按添加顺序执行通过 JS 添加的脚本。Safari 5.0 显示:我理解“async”,但不理解如何使用 JS 将其设为“false”。您的脚本一到达,我就会按任意顺序执行。 IE 10 以下版本的说法:不了解“异步”,但可以使用“onreadystatechange”方法来解决问题。 其他红色标记的浏览器的说法:我不了解“异步”这个概念,我会在脚本到达后立即执行它们,不论顺序如何。 其他所有信息都表明:我是您的好友,我们会按照规定办事。