加载第三方 JS 的各种姿势

dwms5841 8年前
   <p>网页中加载JS文件是一个老问题了,已经被讨论了一遍又一遍,这里不会再赘述各种经典的解决方案。JS文件可以通过来源来分为两个纬度:第一方JS和第三方JS。第一方JS是网页开发者自己使用的JS代码(内容开发者可控)。而第三方JS则是其他服务提供商提供的(内容开发者不可控),他们将自己的服务包装成JS SDK供网页开发者使用。这篇文章关注的第三方JS文件的加载。</p>    <p>从网站开发者的角度来看,第三方JS相比第一方JS有如下几个不同之处:</p>    <ol>     <li> <p>下载速度不可控</p> </li>     <li> <p>JS地址域名与网站域名不同</p> </li>     <li> <p>文件内容不可控</p> </li>     <li> <p>不一定有强缓存(Cache-Control/Expires)</p> </li>    </ol>    <p>如果你的网站上面有很多第三方JS代码,那么“下载速度的不可控”很有可能导致你的网站会被拖慢。因为JS在执行的时候会影响到页面的DOM和样式等情况。浏览器在解析渲染HTML的时候,如果解析到需要下载文件的 script 标签,那么会停止解析接下来的HTML,然后下载外链JS文件并执行。等JS执行完毕之后才会继续解析剩下的HTML。这就是所谓的『HTML解析被阻止』。浏览器解析渲染页面的抽象流程图如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/9a8769f9d721743bf23f0f6357234c24.png"></p>    <p>第三方JS代码并不受网站开发者的控制,很有可能会出现加载时间长甚至加载失败的情况。这时候就会导致整个页面的加载速度变慢。第三方JS代码越多这种风险越大。按照互联网守则:</p>    <p>网站加载速度越慢,用户流失越多</p>    <p>所以要考虑下如何在有很多第三方JS的情况下,保证他们不影响到网站自己的加载速度。我们可以异步加载这些第三方JS代码。</p>    <h2><strong>异步加载</strong></h2>    <p>异步加载JS的方法很多,最常见的就是动态创建一个 script 标签,然后设置其 src 和 async 属性,再插入到页面中。这里有个 DEMO 。实际操作的代码如下:</p>    <pre>  <code class="language-javascript"><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></code></pre>    <p>PS:为了避免 IE8以前版本的bug ,并且确保script能插入DOM树,所以这里没有直接 document.body.append(src) ,而是调用了 insertBefore 方法。</p>    <p>改成异步加载第三方JS代码之后,在JS的下载过程中浏览器会继续解析渲染HTML。流程图就变成了如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fd7d5b09521fba5f3c8c4bba86310760.png"></p>    <p>因为 loadScript 的操作也是使用JS实现的,所以在JS下载之前会有一段执行JS代码的消耗。但是这段JS代码很简单,很快就会执行完毕。</p>    <p>除了动态创建 script 标签的方法,异步加载JS的方法还有很多其他奇技淫巧,这里也罗列了一下:</p>    <ol>     <li><strong>先下载再执行</strong> - 通过 XMLHttpReqeust 对象或者 JSONP 方法下载可执行的JS文件,然后使用 eval() 或者 script 标签执行JS。第三方JS文件一般是不同域名的且JS内容不可控,所以此方法就不适用了</li>     <li><strong>iframe 中加载JS </strong> – 将你的JS文件直接放到另一个页面的HTML中,然后将此页面URL地址作为 iframe 标签 src 属性。此方法需要增加一次页面请求,而且因为是在 iframe 内部执行了,第三方JS文件本身也需要修改,故并不是很适用</li>     <li><strong>先缓存再执行</strong> – 利用JS文件的强缓存,先使用 new Image().src = 'http://url.com/sample.js' 之类(或者 Object 对象)的 方法 下载JS文件。然后在真正需要解析执行JS的时候下载(有缓存,不必再次下载)和执行JS文件。此方法不仅仅适用于JS文件,同样也可以用于CSS文件。这样我们就可以将静态文件的下载和解析执行(使用)分开,批量并行下载,然后在合适的机会解析执行(使用)。但此方法需要强缓存的配合,第三方JS为了在版本发布时更早的更新JS代码一般都不会设置缓存,甚至有些第三方JS的代码是服务器端动态生成的。所以也不是适用于第三方JS。</li>    </ol>    <h3><strong>浏览器预加载机制</strong></h3>    <p>动态创建 script 标签的方法可以异步加载第三方JS,但它也有缺陷。如果加载代码之前有外链JS文件或CSS文件需要下载,如下面的代码:</p>    <pre>  <code class="language-javascript"><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></code></pre>    <p>那么会先下载解析 app1.js 和 app2.js 再执行我们的 loadScript 方法,所以第三方脚本的下载也会被暂停。流程图如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a53e1ecc8f96ecaa6ab692f01321e649.png"></p>    <p>而如今我们页面中代码如此复杂,触发这种case的情况很多。上面的 <a href="/misc/goto?guid=4959721681700404914" rel="nofollow,noindex">DEMO</a> 中实际下载过程也确实是这样,动态创建 script 标签方式下载的test.js需要等到其他CSS和JS文件下载执行完毕之后才开始下载。如下图:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/2861898513de58319a324310f18e30f4.png"></p>    <p>虽然这对页面原有JS的执行不会有大的影响,但会影响到第三方JS代码本身的下载与执行。如何解决这个问题呢?</p>    <p>你可能已经发现上面的例子有个问题:HTML代码中 g.js 的位置在 test.js 之后却先下载了。其实这得益于浏览器的预解析机制,会先对HTML代码做静态分析找到外链的JS和CSS文件,然后并行下载下来(但是执行顺序不变)。IE>=8 及其他主流浏览器基本都实现了这个功能。所以在这些支持预先载的浏览器中流程图应该是这样的:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/f81833f637b2ab5c314d6f3e8c5e667f.png"></p>    <p>为了利用预加载这个特性,我们可以使用如下的写法:</p>    <pre>  <code class="language-javascript"><script src="app1.js"></script>  <script src="app2.js"></script>  <script src="./test.js" async></script></code></pre>    <p>使用标准的 script 标签写法,确保浏览器能够正确的识别这是一个外链JS文件。同时设置 async 标签,浏览器便会异步加载 test.js 文件,不会暂停掉浏览器的解析执行。流程图如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/3d377449f46f3b2af23ef254999aafa7.png"></p>    <p>但它也并不完美,因为一些 旧浏览器 并不支持 async 属性。这会导致这个 test.js 文件在这些浏览器中不是异步的,并且会阻止掉页面渲染。有一个好消息是移动浏览器大多都支持 async 标签,如果你的用户大都是移动浏览器的,或者你的产品不支持旧浏览器,那么你可以使用这种方法。</p>    <p>当然如果你不介意第三方JS代码(本身也支持支持)被延后到页面解析完毕后执行,那么你可以再加上 defer 属性:</p>    <pre>  <code class="language-javascript"><script src="./test.js" async defer></script></code></pre>    <p>Firefox支持 defer 属性要比支持 async 早一点点。而且当浏览器同时使用了 async 和 defer 属性之后, 浏览器会忽略 defer 属性 。所以可以放心的同时使用 async 和 defer 属性。对于不支持 async 的浏览器,会自动fallback到 defer 。不过支持程度也就多了一点点,Firefox的旧版占比已经很低了,基本可以忽略不计。</p>    <h2>页面 onload 事件</h2>    <p>上面提到的两种方法还有一个缺点:会影响到页面的 onload 事件。这对第一方JS可能没有影响,因为第一方JS大都是页面主要逻辑,从业务逻辑上来说它们的加载影响到页面 onload 事件触发不会有问题。但第三方JS则不一样,曾经因为Google被墙GA(Google Analytics简称)的加载就会特别慢甚至失败。导致了很多使用了GA的页面加载特别"慢",页面一直处于loading状态。大家先通过fiddler代理来设置 test.js 的加载时间为10秒,然后打开之前的DEMO,查看页面的loading是否会被延长。下面是我打开第一个 DEMO 的结果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7ad050132e3d2df5a2bc29338c62e2bb.gif"></p>    <p>可以看到因为 test.js 的下载速度变慢,整个页面一直处于loading状态。页面的 load 事件要等到全部加载完成之后才会触发。如果页面中的主要逻辑是在页面 load 之后再执行,那么页面很可能会在很长一段时间内不可用。极大的影响了用户的使用体验。</p>    <h3><strong>Friendly IFrame方法</strong></h3>    <p>为了解决这个问题,meebo的工程师想了一个方案来解决这个问题:</p>    <pre>  <code class="language-javascript">(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');</code></pre>    <p>上述代码分为两个部分:</p>    <ol>     <li> <p>创建了一个隐藏的 iframe 标签,设置其 src 值为JS代码,然后插入到主页面中</p> </li>     <li> <p>在 iframe 标签load之后加载JS脚本</p> </li>    </ol>    <p>这样加载Javascript,就不会阻止浏览器的 onload 事件,提升普通用户的体验。还有另一个好处:第三方的Javascript代码在独立的iframe中运行,不会与主页面中的JS相互干扰。已经有了一些基于这个想法的开源实现,例如:lightning.js是一个专用于快速、安全、异步地加载第三方JS代码的库。</p>    <p>这个方法也不完美,它需要创建一个 iframe 标签导致了开销较大。同时还需要第三方JS本身的支持。第三方JS代码运行在iframe中,导致它无法获取到页面上的信息。虽然它并非跨域可以获得 window.parent ,但是第三方代码并不能知道自己是否在iframe中,需要在加载第三方JS代码的时候通知它。具体的通知方法千变万化,而第三方JS的内容又不受我们控制。</p>    <p>富媒体广告JS(用于展示交互广告的JS)一般都会运行在隔离环境里面,且不需要(不允许)访问外部的window对象。如果你需要加载的第三方JS全部是广告时,那么使用这个方案是OK的,否则并不是最为合适。幸运的是有一个叫 iAB(The Interactive Advertising Bureau,简称iAB) 的组织,建立了一套 工业级标准 。虽然标准已经比较旧了,但是里面提到了通过设置变量 inDapIF 为 true 来通知第三方JS:你现在正运行在iframe中。因为iAB成员较多影响力大,所以遵循这个标准是有好处的,比起自己玩一套要好的多。</p>    <h2><strong>总结</strong></h2>    <table>     <thead>      <tr>       <th>方法</th>       <th>DEMO</th>       <th>异步</th>       <th>预下载</th>       <th>阻止 onload 事件</th>       <th>比较</th>      </tr>     </thead>     <tbody>      <tr>       <td>动态创建 script 标签</td>       <td><a href="/misc/goto?guid=4959721681794347073" rel="nofollow,noindex">dynamic_script.html</a></td>       <td>是</td>       <td>否</td>       <td>是(IE<=9除外)</td>       <td>兼容性最好、普适性最高的方案</td>      </tr>      <tr>       <td><script async src="test.js"></script></td>       <td><a href="/misc/goto?guid=4959721681891285209" rel="nofollow,noindex">async_script.html</a></td>       <td>IE>=10及 <a href="/misc/goto?guid=4959658520269369754" rel="nofollow,noindex">其他主流浏览器</a> 可以</td>       <td>是</td>       <td>是</td>       <td>如果你的用户没有IE<10(或者偏移动端),那么这是最合适的</td>      </tr>      <tr>       <td><script async defer src="test.js"></script></td>       <td><a href="/misc/goto?guid=4959721681996891287" rel="nofollow,noindex">async_defer.html</a></td>       <td>是</td>       <td>是</td>       <td>是</td>       <td>如果不介意IE<10中JS的执行会被延后到文档解析完毕,那么这是最合适的方案</td>      </tr>      <tr>       <td>Friendly Iframe</td>       <td><a href="/misc/goto?guid=4959721682087064921" rel="nofollow,noindex">friendly_iframe.html</a></td>       <td>是</td>       <td>否</td>       <td>否</td>       <td>投放代码太过复杂,且需要第三方JS的支持。比较适用于广告的加载,因为广告通常在隔离环境中即可,不需要访问外部window</td>      </tr>     </tbody>    </table>    <p> </p>    <p>来自:https://github.com/zmmbreeze/blog/issues/19</p>    <p> </p>