异步加载思考

Avatar of Chris Coyier
Chris Coyier 发表

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200美元免费额度!

关键在于:当您从第三方加载 JavaScript 时,应该异步加载。 您可能也希望异步加载自己的脚本,但本文我们将重点关注第三方脚本。

这样做的原因有两个:

  1. 如果第三方服务宕机或速度缓慢,您的页面不会因等待加载该资源而被阻塞。
  2. 它可以加快页面加载速度。

在 Wufoo,我们 刚刚切换到 异步嵌入代码片段。现在建议使用 Wufoo 构建表单并将其嵌入到网站的用户使用此方法。我们正是出于上述原因才这样做的。对于一个要求用户链接到该服务网站上的资源的网络服务来说,这是负责任的做法。

让我们来探索一下这个异步加载。

嗯,什么?

这里涉及一些术语,可以帮助我们理解“异步”这个总称。

“解析器阻塞” – 浏览器读取您的 HTML,当遇到 <script> 标签时,它会先下载整个资源,然后再继续解析。这肯定会减慢页面加载速度,尤其是在脚本位于头部或任何其他可视元素的上方时。这在旧版浏览器以及现代浏览器中都是如此,前提是您没有使用 async 属性(稍后详细介绍)。来自 MDN 文档“在不支持 async 属性的旧版浏览器中,解析器插入的脚本会阻塞解析器……”

为了防止出现有问题的解析器阻塞,可以“脚本插入”(即使用 JavaScript 插入另一个脚本),这将强制它们异步执行(Opera 或 4.0 之前的 Firefox 除外)。

“资源阻塞” – 在下载脚本期间,它可能会阻止其他资源同时下载。IE 6 和 7 会这样做,一次只允许下载一个脚本,并且不允许下载任何其他资源。IE 8 和 Safari 4 允许并行下载多个脚本,但会阻塞任何其他资源(参考)。

理想情况下,我们要解决这两个问题,并加快页面加载速度(实际和感知速度)。

HTML5 方法

HTML5 中的 script 标签有一个 async 属性(规范)。示例:

<script async src="https://third-party.com/resource.js"></script>

它的 浏览器支持情况 是 Firefox 3.6+、IE 10+、Chrome 2+、Safari 5+、iOS 5+、Android 3+。Opera 尚未支持。

如果您要直接加载脚本,使用此属性可能是一个好主意。它可以防止解析器阻塞。这些较新的浏览器在资源阻塞方面没有太大问题,但解析器问题是一个大问题。我们在 Wufoo 中没有使用它,因为我们需要比这更广泛的浏览器支持。

经典的异步方法

这是一个基本的异步脚本加载模式,它将为您提供所需的广泛浏览器支持。

<script>
  var resource = document.createElement('script'); 
  resource.src = "https://third-party.com/resource.js";
  var script = document.getElementsByTagName('script')[0];
  script.parentNode.insertBefore(resource, script);
</script>

这是一个更高效的版本,它使用一个包装器来封装这些变量(鸣谢 Mathias Bynens)

(function(d, t) {
    var g = d.createElement(t),
        s = d.getElementsByTagName(t)[0];
    g.src = 'https://third-party.com/resource.js';
    s.parentNode.insertBefore(g, s);
}(document, 'script'));
更新:请注意,这 不再推荐! 导致这种模式变得有用的时代已经过去,您现在应该主要使用 async 属性。

广告网络

BuySellAds 是一个广告网络,它是 最早 异步投放广告的网络之一。这是它们的模式:

<script type="text/javascript">
(function(){
  var bsa = document.createElement('script');
     bsa.type = 'text/javascript';
     bsa.async = true;
     bsa.src = 'https://s3.buysellads.com/ac/bsa.js';
  (document.getElementsByTagName('head')[0]||document.getElementsByTagName('body')[0]).appendChild(bsa);
})();
</script>

这里需要注意两点:

  • 他们在附加脚本之前将脚本的 async 属性设置为 true。这仅对 Firefox 3.6 有用,因为它是唯一一个默认情况下不执行此操作的浏览器。在大多数情况下,这可能可以省略。设置脚本 type 绝对没有必要。
  • 与上面的简单模式一样,src 使用 协议相对 URL 设置。这是一种非常有用的方法,可以根据请求页面的不同,从 HTTP 或 HTTPS 加载脚本。在 Wufoo,我们绝对需要这样做,但不幸的是,我们发现它在 IE 6 中使用默认安全设置时会引发错误。如果您不需要 IE 6 支持,请随时使用它。
  • 他们将脚本附加到头部或主体,以先找到哪个为准。这是一种完全可以接受的方法,但查找脚本元素也同样安全,因为代码本身就是一个脚本元素。

The Deck 使用不同的模式:

<script type="text/javascript">
//<![CDATA[
(function(id) {
 document.write('<script type="text/javascript" src="' +
   'http://connect.decknetwork.net/deck' + id + '_js.php?' +
   (new Date().getTime()) + '"></' + 'script>');
})("DF");
//]]>
</script>

我非常确定这仍然算作异步加载,因为他们加载的资源是脚本插入的脚本,这意味着它不会阻塞解析器。

如果需要回调函数怎么办?

有时您需要加载第三方脚本,然后在加载该脚本后,触发一些自定义代码。该自定义代码可能会调用第三方脚本中定义的一些函数,并使用特定于某个页面的数据。

<script src="https://third-party.com/resource.js"></script>
<script>
  doSomethingFancy('chriscoyier');
</script>

Typekit 就处于这种情况。您需要加载 Typekit JavaScript,然后启动它。Typekit 实际上是利用了脚本阻塞解析器的事实。如果您的页面被阻塞,直到他们的脚本加载完毕,您将看不到“FOUT”(未设置样式文本的闪烁),这通常只在 Firefox 中会出现问题,但在 Typekit 中也存在问题,因为 @font-face 资源是通过 JavaScript 加载的。

<script type="text/javascript" src="https://use.typekit.com/abc1def.js"></script>
<script type="text/javascript">try{Typekit.load();}catch(e){}</script>

这很聪明,但也略微危险。如果 Typekit 出现故障或速度缓慢:“曾经为了隐藏 FOUT 而延迟渲染变成了一个严重的问题,当脚本加载时间超过几秒钟时。”参考)。

以下是如何以异步方式加载 Typekit 的方法:

<script type="text/javascript">
  TypekitConfig = {
    kitId: 'abc1def'
  };
  (function() {
    var tk = document.createElement('script');
    tk.src = 'https://use.typekit.com/' + TypekitConfig.kitId + '.js';
    tk.type = 'text/javascript';
    tk.async = 'true';
    tk.onload = tk.onreadystatechange = function() {
      var rs = this.readyState;
      if (rs && rs != 'complete' && rs != 'loaded') return;
      try { Typekit.load(TypekitConfig); } catch (e) {}
    };
    var s = document.getElementsByTagName('script')[0];
    s.parentNode.insertBefore(tk, s);
  })();
</script>

处理 onload 回调函数需要大量复杂的代码。不幸的是,这就是使用广泛浏览器支持的回调函数的方法。请注意,使用此模式实际上会带来 FOUT 问题。如果您想使用 Typekit 的异步加载,并获得与平时一样好的体验,请 阅读他们的文章,其中介绍了一些巧妙的类名操作和字体事件。

jQuery 和其他脚本加载器

如果您已经在使用 jQuery,加载第三方脚本并在其准备好后获取回调非常容易:

$.ajax({
  url: 'https://third-party.com/resource.js',
  dataType: 'script',
  cache: true, // otherwise will get fresh copy every page load
  success: function() {
    // script loaded, do stuff!
  }
}

我相信其他库也有类似的功能。这是 JavaScript 库擅长帮助解决的经典问题。另请参阅 getScript,它可能更简洁一些。

如果您没有使用库,并且担心文件大小,YepNope 是一个超小的脚本加载器,也可以提供帮助。它的理想用途是执行测试以查看是否需要加载脚本,但它也具有直接方法:

yepnope.injectJs("https://third-party.com/resource.js", function () {
  // script loaded, do stuff!
});

相关:Max Wheeler 的文章 使用 YepNope 异步加载 Typekit

您真的需要回调函数吗?

在 Wufoo,我们认为是的,我们需要回调函数。我们需要加载表单嵌入 JavaScript 资源,然后使用用户表单的所有详细信息调用一个函数。这就是我们过去使用的方法:

<script type="text/javascript">var host = (("https:" == document.location.protocol) ? "https://secure." : "http://");document.write(unescape("%3Cscript src='" + host + "wufoo.com/scripts/embed/form.js' type='text/javascript'%3E%3C/script%3E"));</script><br />
<script type="text/javascript">
var z7p9p1 = new WufooForm();
z7p9p1.initialize({
'userName':'chriscoyier', 
'formHash':'z7p9p1', 
'autoResize':true,
'height':'546', 
'ssl':true});
z7p9p1.display();
</script>

这组键值对对用户很有用,他们可以查看、更改和添加内容。我们尝试了一些方法来保留它,但将这些数据作为 URL 的一部分传递,以便在调用脚本时使用。我们这边的脚本实际上是 PHP,并且能够 $_GET 这些值。这样就可以避免在异步模式下处理所有难看的回调代码。可能类似于:

script.src = 'https://wufoo.com/form.js?data=' + JSON.stringify(options);

但最终,我们放弃了这种方法。回调代码并不糟糕,JSON 字符串化没有得到很广泛的浏览器支持(在复制粘贴的代码中包含 polyfill 也不切实际),并且我们的 form.js 缓存效果很好。

社交媒体

社交媒体按钮是一个经典的需要在页面上使用第三方 JavaScript 的案例。有趣的是,三大巨头已经以异步模式提供了他们的代码。

Facebook

<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "https://#/en_US/all.js#xfbml=1&appId=200103733347528";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>

Twitter

<a href="https://twitter.com/share" class="twitter-share-button">Tweet</a>
<script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src="https://platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);}}(document,"script","twitter-wjs");</script>

Google Plus

<g:plusone annotation="inline"></g:plusone>
<script type="text/javascript">
  (function() {
    var po = document.createElement('script'); po.type = 'text/javascript'; po.async = true;
    po.src = 'https://apis.google.com/js/plusone.js';
    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(po, s);
  })();
</script>

清理代码

以上所有代码都非常相似,但也略有不同。如果直接将它们全部放在页面上,可能会让像我们这样的代码洁癖者感到崩溃。Nicholas Gallagher 有一个非常简洁高效的方法将它们整合在一起。

(function(doc, script) {
    var js, 
        fjs = doc.getElementsByTagName(script)[0],
        add = function(url, id) {
            if (doc.getElementById(id)) {return;}
            js = doc.createElement(script);
            js.src = url;
            id && (js.id = id);
            fjs.parentNode.insertBefore(js, fjs);
        };

    // Google Analytics
    add('https:://#/ga.js', 'ga');
    // Google+ button
    add('https://apis.google.com/js/plusone.js');
    // Facebook SDK
    add('https://#/en_US/all.js', 'facebook-jssdk');
    // Twitter SDK
    add('https://platform.twitter.com/widgets.js', 'twitter-wjs');
}(document, 'script'));

处理 CMS

WordPress 非常庞大。其他主要的 CMS 也是如此。当您作为第三方提供复制粘贴的 JavaScript 代码时,不能忽略它们。关键当然在于测试。最重要的是不要在代码中包含双换行符。例如

<script type="text/javascript">
var s = d.createElement(t), options = {

   foo: bar

}
// The line break space above is bad!
</script>

这看起来可能很简洁,但 WordPress 的“自动”行为会在其各个部分插入段落标签,这当然会阻止脚本按预期执行。

最终的 Wufoo 代码片段

这就是我们最终得到的代码。

<div id="wufoo-z7w3m7">
Fill out my <a href="http://examples.wufoo.com/forms/z7w3m7">online form</a>.
</div>
<script type="text/javascript">var z7w3m7;(function(d, t) {
var s = d.createElement(t), options = {
'userName':'examples', 
'formHash':'z7w3m7', 
'autoResize':true,
'height':'260',
'async':true,
'header':'show', 
'ssl':true};
s.src = 'https://wufoo.com/scripts/embed/form.js';
s.onload = s.onreadystatechange = function() {
var rs = this.readyState; if (rs) if (rs != 'complete') if (rs != 'loaded') return;
try { z7w3m7 = new WufooForm();z7w3m7.initialize(options);z7w3m7.display(); } catch (e) {}};
var scr = d.getElementsByTagName(t)[0], par = scr.parentNode; par.insertBefore(s, scr);
})(document, 'script');</script>

老实说,代码片段的“大小”是一个问题。50 行对于这样的代码来说实在太多了。现在是 19 行,比我们之前多,但可以接受。其中很多行是 options 对象,我们可以将其压缩成更少的行,但将其分别放在单独的行中以方便阅读和修改会更好。

我们仍然需要支持 IE 6,因此不幸的是我们不能使用协议相对 URL。我们正在使用 location.protocol 测试。

它比您的“普通”异步代码片段(如果存在“普通”代码片段的话)要大一些,但这没关系。它需要完成很多工作,并且做得很好。

我们在公告博文中讨论了其中的一些优势。我最喜欢的一点是,您现在可以将脚本移动到任何您想要的位置,它不必像以前那样必须完全位于您希望表单出现的位置。

瀑布图

如果您有兴趣对资源加载进行一些测试,查看资源瀑布图特别有用。现代的 Web 开发工具内置了此功能,例如 Web 检查器中的“网络”选项卡或 Firebug 的“网络”选项卡。但是 IE 6-8 中的旧版开发工具不提供这些信息。幸运的是,网站Web Page Test提供了这些信息(它有点丑,但非常酷)。

在为 IE 6 中的 Wufoo 代码片段进行一些测试时,我可以证明我们的新方法是非阻塞的,而旧方法是阻塞的。

好了,这就是我所要说的。写下所有这些东西让我感觉有点奇怪,因为这些东西对我来说都是相当新的,而且我觉得自己离专家还差得远。所以,请随时纠正我的任何错误或分享您自己的异步体验。