加载 JavaScript 的各种姿势

标签:JavaScript

前天我给自己的博客做了些优化,让大部分的页面对所有人都输出相同的内容,然后再针对不同的用户,用 JavaScript 动态地修改一些细微的部分。
这样的好处是 HTTP 缓存可以设为 public 了,甚至可以用 nginx 的 proxy cache 或者完全静态化。
不过按照最近的习惯,这篇文章也不是介绍缓存的经验,而是另一个问题:我在给不同用户增加不同功能时,需要动态地加载 JavaScript 文件,那么正确的加载方式是怎样的呢?

先抛开这个问题,看看静态的加载方式:
<!DOCTYPE html>
<html>
<head>
	<script src="1.js"></script>
	<script src="2.js" async></script>
	<script src="3.js" defer></script>
	<script src="4.js" async></script>
	<script src="5.js" defer></script>
	<script src="6.js" async defer></script>
</head>
<body>
	<script src="7.js"></script>
</body>
</html>
这里共有 7 处 script 标签。其中,1.js 无疑是最先加载的,之后则有点让人迷惑了。

根据 MDNW3C 的介绍:async 属性是指当这个 script 可用时,就异步执行它;而 defer 属性是指当页面被解析完毕后,才能执行;如果两个属性都没有,则立刻下载并执行,同时阻止页面的解析,直到执行完毕。
而在 HTML 5.1 的 W3C Working Draft 08 October 2015 中,async 属性是指当这个 script 可用时,就执行它,并且不阻止页面的解析,顺便还做了一张图来解释这个过程:

奇怪的是,这张图在执行 async 的 script 时,阻止了页面的解析。
另外,当前(写这篇文章时)的 Editor’s Draft 的描述则更明确了这个规则:async 将是完全并行的,执行的时候也不阻止页面的解析。

根据上述解释,7.js 会在页面解析完成前执行,而 3.js 和 5.js 会在页面解析完成后执行,它们之间的先后顺序是可以确定的。
而那个「异步执行」的描述则比较让我困惑,因为目前的 JavaScript 运行时是单线程执行的,不会同时有 2 个 script 在执行。所以我猜测它应该是指当前没有 script 在执行时,就可以执行它了。
于是按我的猜测,这个测试页面很小,7.js 会在 2.js ~ 6.js 完成下载前就已经被解析到了,然后开始独占执行。
再是 2.js、4.js 和 6.js,它们下载完就可以立刻执行了,但由于页面很小,还没下完就已经解析完毕了,而那时 3.js 和 5.js 也是可以执行的。

为了验证我的猜想,我拿 Chrome 48 和 Firefox 44 做了下测试。
Chrome 的顺序是 1.js -> 7.js,之后是乱序;Firefox 的顺序是 1.js -> 7.js -> 2.js -> 4.js -> 6.js -> 3.js -> 5.js。
接着,我在 7.js 前插入了大量文本,并且把 Chrome 的 network throttling 设为 GPRS,使得 1.js ~ 6.js 能在页面解析到 7.js 前就下载完。这次观测到的顺序是 1.js -> 2.js -> 4.js -> 6.js -> 7.js -> 3.js -> 5.js。
Firefox 则没找到内置的限速工具,只好自己改了下服务器的代码,在输出 HTML 时,先输出 1000 个字节,然后每输出 100 个字节就 sleep 1 秒。出乎我意料的是,执行顺序没有变化,scripts 虽然会在解析出来就立刻下载,并很快就下载完毕,但直到页面加载完,async 和 defer 的 scripts 才开始执行的。
如此看来,Chrome 应该是符合 W3C 规范的,而 Firefox 则把 async 当成了优先级稍高的 defer 而已。

接着我又做了个测试(代码就不贴了,懒得整理),在页面很小的情况下,比较 async、defer、window.onload(等效于 body 的 onload 属性或 jQuery(window).on('load')) 和 jQuery(document).ready 的执行时序。
Chrome 下是:defer -> jQuery(document).ready -> async -> window.onload。
Firefox 下是:async -> defer -> jQuery(document).ready -> window.onload。
我又把 JavaScript 的下载速度变慢,发现结果仍然没有变化。
也就是说,aysnc 的 script 不能保证在 jQuery(document).ready 的时候是可用的,而 defer 可以。

此外,script 还有 load 和 error 这 2 个事件,分别会在 script 执行完和下载出错时调用。
IE 11 之前的版本则特立独行地使用 readystatechange 这个事件取代 load 事件,会有 uninitialized(未初始化)、loading(开始下载)、loaded(下载完)、interactive(执行中)和 complete(执行完毕)这五种状态。理论上来说,uninitialized 不会被触发,因为没这个时机;interactive 更不会被触发,因为这时候 JavaScript 运行时正在执行 script,不可能同时处理事件。(这段话我并没验证,因为我没有也不关心 IE 浏览器。)
顺带一提,Editor’s Draft 里还提到了 beforescriptexecute 和 afterscriptexecute 这 2 个事件,分别在 script 执行前后被调用,前者能取消掉 script 的执行,后者在 load 事件之前。测试发现,目前 Chrome 还不支持这 2 个事件,而 Firefox 可用。

接下来回归正题,看看如何动态地加载 JavaScript。
标准的方式就是创建一个 script 元素,然后放到文档里即可,代码大致如下:
function on_js_load(e) {
	var script = e.currentTarget || e.srcElement; // Chrome 里是一样的,很老的浏览器可能要用 srcElement
	script.onload = script.onreadystatechange = null;
}
function on_js_ready_state_change(e) {
	var script = e.currentTarget || e.srcElement;
	var readyState = script.readyState;
	if (readyState === 'complete' || readyState === 'loaded') {
		script.onload = script.onreadystatechange = null;
	}
}
var script = document.createElement('script');
script.async = true; // 这个根据自己需求设置
script.src = '1.js';
script.onload = on_js_load; // 有多个事件的话,就用 addEventListener 和 attachEvent 吧
script.onreadystatechange = on_js_ready_state_change;
document.body.appendChild(script);
其中,两个事件的处理逻辑其实差不多,所以可以合并一下:
function on_js_load(e) {
	var script = e.currentTarget || e.srcElement;
	if (e.type === 'load' || (/^(complete|loaded)$/.test(script.readyState))) {
		script.onload = script.onreadystatechange = null;
	}
}

其他还有一些不严谨或可以改进的地方,例如:
  • 如果有 base 标签,appendChild 在 IE 6 上可能会出错,可以改成:
    var baseElement = document.getElementsByTagName('base')[0];
    if (baseElement) {
    	var head = baseElement.parentNode;
    	head.insertBefore(script, baseElement);
    } else {
    	head = document.getElementsByTagName('head')[0];
    	head.appendChild(script);
    }
  • head 和 body 标签可能不存在。
    Google Analytics 的做法是插到第一个 script 元素前面:
    var firstScript = document.getElementsByTagName('script')[0];
    firstScript.parentNode.insertBefore(script, firstScript);
    还有个办法是插到当前正在运行的 script 元素前面:
    var currentScript = document.currentScript; // IE 不支持
    currentScript.parentNode.insertBefore(script, currentScript);
    然而文档中可能并没有 script 标签,所以仍然存在隐患。考虑到前面 base 的问题,还得确保插到 base 之前。
    jQuery 老版本里的做法则是直接插在第一个元素前,一次性解决了两个问题:
    var head = document.getElementsByTagName("head")[0] || document.documentElement;
    head.insertBefore(script, head.firstChild);
  • jQuery 老版本的源码里提到,动态插入的 script 会造成 IE 内存泄露,在 load 之后需要删除掉:
    script.parentNode.removeChild(script);
  • RequireJS 的源码里提到,PlayStation 3 需要等待 complete 状态。
这里我就不处理了,还是用 jQuery 这种第三方库,让别人去操这些心吧。

于是尝试用 jQuery(使用的版本是 2.2.0)写了如下的代码:
var scripts = [];
for (var i = 0; i < 10; ++i) {
	scripts.push('<script src="test.js?' + i + '" async><\/script>');
}
$('body').append(scripts.join(''));

结果发现了几个问题:
  1. 实际的请求带了一堆时间时间戳,导致没法利用缓存。
    解决办法是针对 script 请求,允许缓存即可:
    $.ajaxPrefilter('script', function(options) {
    	options.cache = true;
    });
    或者允许所有的缓存:
    $.ajaxSetup({
    	cache: true
    });
  2. 请求变成了同步的,每个 script 的下载和执行都会阻塞后续所有的 scripts。
  3. 在 Chrome 下,HTTP/2 的请求会降级成 HTTP/1.x。(Firefox 没这个问题。)
  4. 下载请求变成了 ajax 请求,会带上 X-Requested-With: XMLHttpRequest 的请求头。(貌似算不上什么坏处,也就多用了几个字节。)

第 2 和第 3 个问题可以用 jQuery.getScript 方法来解决:
for (var i = 0; i < 10; ++i) {
	$.getScript('test.js?' + i);
}
顺带还获得了一个好处,可以很容易地写 onload 和 onerror 了。

由此看来,如果不依赖第三方库的话,最简单的办法是把 script 设为 defer,然后等到 window.onload 时处理,这时依赖的 scripts 也都加载好了。
而如果用 jQuery 的话,则可以使用 jQuery.getScript 方法,当依赖的 scripts 都加载完了,再做需要做的事。代码类似于:
var requests = [];
for (var i = 0; i < 10; ++i) {
	requests.push($.getScript('test.js?' + i));
}
$.when.apply($, requests).done(function() {
	// 都可用了
})

1条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?