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

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 种添加脚本的模式。当然,浏览器不同意它们的执行顺序。2009 年,Mozilla 就此问题撰写了一篇精彩文章

WHATWG 明确声明了“defer”行为,以避免对动态添加或缺少“src”的脚本产生任何影响。否则,延迟脚本应该在文档解析完成后按照添加顺序运行。

感谢 IE!(好吧,我现在在讽刺呢)

给予,给予取舍。遗憾的是,IE4-9 中有一个严重错误,可能会导致脚本以意外顺序执行。整个过程如下:

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 暂停当前脚本执行,并在继续执行操作前执行其他待处理的脚本。

但是,即使在没有漏洞的实施方案(如 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”是独立的,这没关系,也许“1.js”是一个跟踪脚本,与“2.js”没有任何关系。但是,如果您的“1.js”是 jQuery 的 CDN 副本,而“2.js”完全依赖于此,那么...集群...

我知道我们需要的东西,一个 JavaScript 库!

高标准是让一组脚本立即下载,而不会阻止渲染,并且会按照添加顺序尽快执行。很遗憾,HTML 讨厌您,它不允许您这样做。

此问题已通过几种方式是通过 JavaScript 解决的。有些代码要求您对 JavaScript 进行更改,将其封装在库按正确顺序调用的回调中(例如 RequireJS)。其他人会使用 XHR 按正确的顺序使用 XHR 并行下载 eval(),这对其他网域中的脚本不起作用,除非它们具有 CORS 标头,并且浏览器也支持该标头。有些甚至还使用了超神奇的技巧,比如 LabJS

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

不过,脚本加载器本身也存在性能问题,您必须等到库的 JavaScript 下载并解析后,它管理的任何脚本才能开始下载。另外,我们如何加载脚本加载器?如何加载脚本,以告知脚本加载器要加载哪些内容?谁在关注《守卫者》?为什么我是裸体?这些都是难题。

基本上,如果您甚至在考虑下载其他脚本之前必须下载额外的脚本文件,就会错失性能。

这种情况下就轮到 DOM 大显身手了

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

让我们将其翻译成“Earthling”语言:

[
  '//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”。哎呀!虽然异步下载,但有序执行!

所有支持异步属性的设备均支持以这种方式加载脚本,但 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>

每个增强功能脚本都会处理特定的网页组件,但需要依赖项.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 开发者就好比是西西弗斯国王(太棒了!希腊神话中 100 点潮流人士积分!)。HTML 和浏览器方面的限制妨碍了我们的改进工作。

虽然这要求将脚本作为模块编写,但我希望 JavaScript 模块能够为我们节省费用,因为它提供了一种声明式非阻塞方式来加载脚本并能够控制执行顺序。

呃,现在肯定有更棒的东西可以用吗?

很好,如果您想大幅提高广告效果,并且不介意设计复杂程度和重复性,可以结合使用上述几种技巧。

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

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

然后,通过内嵌到文档标头中,我们使用 async=false 通过 JavaScript 加载脚本,回退到 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 版本低于 3.6,Opera 表示:我不了解这个“异步”功能是什么,不过,恰恰相反,我会按照脚本的添加顺序执行通过 JS 添加的脚本。 Safari 5.0 说:我了解“async”,但不了解使用 JS 将其设置为“false”。我会在脚本到达后立即执行这些脚本,顺序不限。IE 10 以下版本:不了解“async”,但有一种使用“onreadystatechange”的权宜解决方法。 其他红色浏览器显示:我不了解这些“异步”内容,我会在脚本加载后立即执行您的脚本,顺序不限。 其他都说:我是您的朋友,我们将根据书来做到这一点。