Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

加载第三方JS的各种姿势 #19

Open
zmmbreeze opened this issue Sep 9, 2016 · 4 comments
Open

加载第三方JS的各种姿势 #19

zmmbreeze opened this issue Sep 9, 2016 · 4 comments

Comments

@zmmbreeze
Copy link
Owner

zmmbreeze commented Sep 9, 2016

网页中加载JS文件是一个老问题了,已经被讨论了一遍又一遍,这里不会再赘述各种经典的解决方案。JS文件可以通过来源来分为两个纬度:第一方JS和第三方JS。第一方JS是网页开发者自己使用的JS代码(内容开发者可控)。而第三方JS则是其他服务提供商提供的(内容开发者不可控),他们将自己的服务包装成JS SDK供网页开发者使用。这篇文章关注的第三方JS文件的加载。

从网站开发者的角度来看,第三方JS相比第一方JS有如下几个不同之处:

  1. 下载速度不可控
  2. JS地址域名与网站域名不同
  3. 文件内容不可控
  4. 不一定有强缓存(Cache-Control/Expires)

如果你的网站上面有很多第三方JS代码,那么“下载速度的不可控”很有可能导致你的网站会被拖慢。因为JS在执行的时候会影响到页面的DOM和样式等情况。浏览器在解析渲染HTML的时候,如果解析到需要下载文件的script标签,那么会停止解析接下来的HTML,然后下载外链JS文件并执行。等JS执行完毕之后才会继续解析剩下的HTML。这就是所谓的『HTML解析被阻止』。浏览器解析渲染页面的抽象流程图如下:

第三方JS代码并不受网站开发者的控制,很有可能会出现加载时间长甚至加载失败的情况。这时候就会导致整个页面的加载速度变慢。第三方JS代码越多这种风险越大。按照互联网守则:

网站加载速度越慢,用户流失越多

所以要考虑下如何在有很多第三方JS的情况下,保证他们不影响到网站自己的加载速度。我们可以异步加载这些第三方JS代码。

异步加载

异步加载JS的方法很多,最常见的就是动态创建一个script标签,然后设置其srcasync属性,再插入到页面中。这里有个DEMO。实际操作的代码如下:

<script>
function loadScript(url) {
    var scrs = document.getElementsByTagName('script');
    var last = scrs[scrs.length - 1];
    var scr = document.createElement('script');
    scr.src = url;
    scr.async = true;
    last.parentNode.insertBefore(scr, last);
}
loadScript('test.js');
</script>

PS:为了避免IE8以前版本的bug,并且确保script能插入DOM树,所以这里没有直接document.body.append(src),而是调用了insertBefore方法。

改成异步加载第三方JS代码之后,在JS的下载过程中浏览器会继续解析渲染HTML。流程图就变成了如下:

因为loadScript的操作也是使用JS实现的,所以在JS下载之前会有一段执行JS代码的消耗。但是这段JS代码很简单,很快就会执行完毕。

除了动态创建script标签的方法,异步加载JS的方法还有很多其他奇技淫巧,这里也罗列了一下:

  1. 先下载再执行 - 通过XMLHttpReqeust对象或者JSONP方法下载可执行的JS文件,然后使用eval()或者script标签执行JS。第三方JS文件一般是不同域名的且JS内容不可控,所以此方法就不适用了
  2. iframe中加载JS – 将你的JS文件直接放到另一个页面的HTML中,然后将此页面URL地址作为iframe标签src属性。此方法需要增加一次页面请求,而且因为是在iframe内部执行了,第三方JS文件本身也需要修改,故并不是很适用
  3. 先缓存再执行 – 利用JS文件的强缓存,先使用new Image().src = 'http://url.com/sample.js'之类(或者Object对象)的方法下载JS文件。然后在真正需要解析执行JS的时候下载(有缓存,不必再次下载)和执行JS文件。此方法不仅仅适用于JS文件,同样也可以用于CSS文件。这样我们就可以将静态文件的下载和解析执行(使用)分开,批量并行下载,然后在合适的机会解析执行(使用)。但此方法需要强缓存的配合,第三方JS为了在版本发布时更早的更新JS代码一般都不会设置缓存,甚至有些第三方JS的代码是服务器端动态生成的。所以也不是适用于第三方JS。

浏览器预加载机制

动态创建script标签的方法可以异步加载第三方JS,但它也有缺陷。如果加载代码之前有外链JS文件或CSS文件需要下载,如下面的代码:

<script src="app1.js"></script>
<script src="app2.js"></script>
<script>
function loadScript(url) {
    var scrs = document.getElementsByTagName('script');
    var last = scrs[scrs.length - 1];
    var scr = document.createElement('script');
    scr.src = url;
    scr.async = true;
    last.parentNode.insertBefore(scr, last);
}
loadScript('test.js');
</script>

那么会先下载解析app1.jsapp2.js再执行我们的loadScript方法,所以第三方脚本的下载也会被暂停。流程图如下:

而如今我们页面中代码如此复杂,触发这种case的情况很多。上面的DEMO中实际下载过程也确实是这样,动态创建script标签方式下载的test.js需要等到其他CSS和JS文件下载执行完毕之后才开始下载。如下图:

虽然这对页面原有JS的执行不会有大的影响,但会影响到第三方JS代码本身的下载与执行。如何解决这个问题呢?

你可能已经发现上面的例子有个问题:HTML代码中g.js的位置在test.js之后却先下载了。其实这得益于浏览器的预解析机制,会先对HTML代码做静态分析找到外链的JS和CSS文件,然后并行下载下来(但是执行顺序不变)。IE>=8 及其他主流浏览器基本都实现了这个功能。所以在这些支持预先载的浏览器中流程图应该是这样的:

为了利用预加载这个特性,我们可以使用如下的写法:

<script src="app1.js"></script>
<script src="app2.js"></script>
<script src="./test.js" async></script>

使用标准的script标签写法,确保浏览器能够正确的识别这是一个外链JS文件。同时设置async标签,浏览器便会异步加载test.js文件,不会暂停掉浏览器的解析执行。流程图如下:

这里有一个DEMO

但它也并不完美,因为一些旧浏览器并不支持async属性。这会导致这个test.js文件在这些浏览器中不是异步的,并且会阻止掉页面渲染。有一个好消息是移动浏览器大多都支持async标签,如果你的用户大都是移动浏览器的,或者你的产品不支持旧浏览器,那么你可以使用这种方法。

当然如果你不介意第三方JS代码(本身也支持支持)被延后到页面解析完毕后执行,那么你可以再加上defer属性:

<script src="./test.js" async defer></script>

Firefox支持defer属性要比支持async早一点点。而且当浏览器同时使用了asyncdefer属性之后,浏览器会忽略defer属性。所以可以放心的同时使用asyncdefer属性。对于不支持async的浏览器,会自动fallback到defer。不过支持程度也就多了一点点,Firefox的旧版占比已经很低了,基本可以忽略不计。

页面onload事件

上面提到的两种方法还有一个缺点:会影响到页面的onload事件。这对第一方JS可能没有影响,因为第一方JS大都是页面主要逻辑,从业务逻辑上来说它们的加载影响到页面onload事件触发不会有问题。但第三方JS则不一样,曾经因为Google被墙GA(Google Analytics简称)的加载就会特别慢甚至失败。导致了很多使用了GA的页面加载特别"慢",页面一直处于loading状态。大家先通过fiddler代理来设置test.js的加载时间为10秒,然后打开之前的DEMO,查看页面的loading是否会被延长。下面是我打开第一个DEMO的结果:

loading

可以看到因为test.js的下载速度变慢,整个页面一直处于loading状态。页面的load事件要等到全部加载完成之后才会触发。如果页面中的主要逻辑是在页面load之后再执行,那么页面很可能会在很长一段时间内不可用。极大的影响了用户的使用体验。

Friendly IFrame方法

为了解决这个问题,meebo的工程师想了一个方案来解决这个问题:

(function(url){
    // 第一部分
    var dom,doc,where,iframe = document.createElement('iframe');
    iframe.src = "javascript:false";
    iframe.title = ""; iframe.role="presentation";
    (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();
})('test.js');

上述代码分为两个部分:

  1. 创建了一个隐藏的iframe标签,设置其src值为JS代码,然后插入到主页面中
  2. iframe标签load之后加载JS脚本

这样加载Javascript,就不会阻止浏览器的onload事件,提升普通用户的体验。还有另一个好处:第三方的Javascript代码在独立的iframe中运行,不会与主页面中的JS相互干扰。已经有了一些基于这个想法的开源实现,例如:lightning.js是一个专用于快速、安全、异步地加载第三方JS代码的库。

这个方法也不完美,它需要创建一个iframe标签导致了开销较大。同时还需要第三方JS本身的支持。第三方JS代码运行在iframe中,导致它无法获取到页面上的信息。虽然它并非跨域可以获得window.parent,但是第三方代码并不能知道自己是否在iframe中,需要在加载第三方JS代码的时候通知它。具体的通知方法千变万化,而第三方JS的内容又不受我们控制。

富媒体广告JS(用于展示交互广告的JS)一般都会运行在隔离环境里面,且不需要(不允许)访问外部的window对象。如果你需要加载的第三方JS全部是广告时,那么使用这个方案是OK的,否则并不是最为合适。幸运的是有一个叫iAB(The Interactive Advertising Bureau,简称iAB)的组织,建立了一套工业级标准。虽然标准已经比较旧了,但是里面提到了通过设置变量inDapIFtrue来通知第三方JS:你现在正运行在iframe中。因为iAB成员较多影响力大,所以遵循这个标准是有好处的,比起自己玩一套要好的多。

总结

方法 DEMO 异步 预下载 阻止onload事件 比较
动态创建script标签 dynamic_script.html 是(IE<=9除外) 兼容性最好、普适性最高的方案
<script async src="test.js"></script> async_script.html IE>=10及其他主流浏览器可以 如果你的用户没有IE<10(或者偏移动端),那么这是最合适的
<script async defer src="test.js"></script> async_defer.html 如果不介意IE<10中JS的执行会被延后到文档解析完毕,那么这是最合适的方案
Friendly Iframe friendly_iframe.html 投放代码太过复杂,且需要第三方JS的支持。比较适用于广告的加载,因为广告通常在隔离环境中即可,不需要访问外部window
@blackcater
Copy link

不错的文章,感谢分享

@pjcn
Copy link

pjcn commented Jan 24, 2017

配图太帅了

@design-study
Copy link

非常感谢分享这么精彩的文章,帮助很大。

@chenjunxyf
Copy link

很好的文章,想问一个问题,如果我代码这么写:

<script>
setTimeout(function() {
    // 动态加载资源
    loadScript('test.js')
}, 0)
</script>

这个会阻碍 onload 事件吗?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants
@zmmbreeze @pjcn @chenjunxyf @design-study @blackcater and others