动手实现一个简单的浏览器端js模块加载器

在es6之前,js不像其他语言自带成熟的模块化功能,页面只能靠插入一个个script标签来引入自己的或第三方的脚本,并且容易带来命名冲突的问题。js社区做了很多努力,在当时的运行环境中,实现”模块”的效果。
通用的js模块化标准有CommonJS与AMD,前者运用于node环境,后者在浏览器环境中由Require.js等实现。此外还有国内的开源项目Sea.js,遵循CMD规范。(目前随着es6的普及已经停止维护,不论是AMD还是CMD,都将是一段历史了)

浏览器端js加载器

实现一个简单的js加载器并不复杂,主要可以分为 解析路径、下载模块、解析模块依赖、解析模块 四个步骤。
首先定义一下模块。在各种规范中,通常一个js文件即表示一个模块。那么,我们可以在模块文件中,构造一个闭包,并传出一个对象,作为模块的导出:

1
2
3
4
5
6
define(factory() {
var x = {
a: 1
};
return x;
});

define函数接收一个工厂函数参数,浏览器执行该脚本时,define函数执行factory,并把它的return值存储在加载器的模块对象modules里。
如何 标识 一个模块呢?可以用文件的 uri,它是唯一标识,是天然的id。
文件路径path有几种形式:

绝对路径:http://xxx, file://xxx

相对路径:./xxx , ../xxx , xxx(相对当前页面的文件路径)

虚拟绝对路径:/xxx /表示网站根目录
因此,需要一个resolvePath函数来将不同形式的path解析成uri,参照当前页面的文件路径来解析。

接着,假设我们需要引用a.js与b.js两个模块,并设置了需要a与b才能执行的回调函数f。我们希望加载器去拉取a与b,当a与b都加载完成后,从modules里取出a与b作为参数传给f,执行下一步操作。这里可以用观察者模式(即订阅/发布模式)实现,创建一个eventProxy,订阅加载a与加载b事件;define函数执行到最后,已经把导出挂载modules里之后,emit一个本模块加载完成的事件,eventProxy收到后检查a与b是否都加载完成,如果完成,就传参给f执行回调。

同理,eventProxy也可以实现模块依赖加载

1
2
3
4
5
// a.js
define([ 'c.js', 'd.js' ], factory (c, d) {
var x = c + d;
return x;
});

define函数的第一个参数可以传入一个依赖数组,表示a模块依赖c与d。define执行时,告诉eventProxy订阅c与d加载事件,加载好了就执行回调函数f存储a的导出,并emit事件a已加载。

浏览器端加载脚本的原始方法是插入一个 script 标签,指定src之后,浏览器开始下载该脚本。
那么加载器中的模块加载可以用dom操作实现,插入一个script标签并指定src,此时该模块为下载中状态。
PS:浏览器中,动态插入script标签与初次加载页面dom时的script加载方式不同:

初次加载页面,浏览器会从上到下顺序解析dom,碰到script标签时,下载脚本并阻塞dom解析,等到该脚本下载、执行完毕后再继续解析之后的dom(现代浏览器做了preload优化,会预先下载好多个脚本,但执行顺序与它们在dom中顺序一致,执行时阻塞其他dom解析)

动态插入script,var a = document.createElement(‘script’); a.src=’xxx’; document.body.appendChild(a);浏览器会在该脚本下载完成后执行,过程是异步的。

下载完成后执行上述的操作,解析依赖->加载依赖->解析本模块->加载完成->执行回调。

模块下载完成后,如何在解析它时知道它的uri呢?有两种发发,一种是用 srcipt.onload 获取 this 对象的src属性;一种是在define函数中采用 document.currentScript.src

实现基本的功能比较简单,代码不到200行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
var zmm = {
_modules: {},
_configs: {
// 用于拼接相对路径
basePath: (function (path) {
if (path.charAt(path.length - 1) === '/') {
path = path.substr(0, path.length - 1);
}
return path.substr(path.indexOf(location.host) + location.host.length + 1);
})(location.href),
// 用于拼接相对根路径
host: location.protocol + '//' + location.host + '/'
}
};
zmm.hasModule = function (_uri) {
// 判断是否已有该模块,不论加载中或已加载好
return this._modules.hasOwnProperty(_uri);
};
zmm.isModuleLoaded = function (_uri) {
// 判断该模块是否已加载好
return !!this._modules[_uri];
};
zmm.pushModule = function (_uri) {
// 新模块占坑,但此时还未加载完成,表示加载中;防止重复加载
if (!this._modules.hasOwnProperty(_uri)) {
this._modules[_uri] = null;
}
};
zmm.installModule = function (_uri, mod) {
this._modules[_uri] = mod;
};
zmm.load = function (uris) {
var i, nsc;
for (i = 0; i < uris.length; i++) {
if (!this.hasModule(uris[i])) {
this.pushModule(uris[i]);
// 开始加载
var nsc = document.createElement('script');
nsc.src = uri;
document.body.appendChild(nsc);
}
}
};
zmm.resolvePath = function (path) {
// 返回绝对路径
var res = '', paths = [], resPaths;
if (path.match(/.*:\/\/.*/)) {
// 绝对路径
res = path.match(/.*:\/\/.*?\//)[0]; // 协议+域名
path = path.substr(res.length);
} else if (path.charAt(0) === '/') {
// 相对根路径 /开头
res = this._configs.host;
path = path.substr(1);
} else {
// 相对路径 ./或../开头或直接文件名
res = this._configs.host;
resPaths = this._configs.basePath.split('/');
}
resPaths = resPaths || [];
paths = path.split('/');
for (var i = 0; i < paths.length; i++) {
if (paths[i] === '..') {
resPaths.pop();
} else if (paths[i] === '.') {
// do nothing
} else {
resPaths.push(paths[i]);
}
}
res += resPaths.join('/');
return res;
};
var define = zmm.define = function (dependPaths, fac) {
var _uri = document.currentScript.src;
if (zmm.isModuleLoaded(_uri)) {
return;
}
var factory, depPaths, uris = [];
if (arguments.length === 1) {
factory = arguments[0];
// 挂载到模块组中
zmm.installModule(_uri, factory());
// 告诉proxy该模块已装载好
zmm.proxy.emit(_uri);
} else {
// 有依赖的情况
factory = arguments[1];
// 装载完成的回调函数
zmm.use(arguments[0], function () {
zmm.installModule(_uri, factory.apply(null, arguments));
zmm.proxy.emit(_uri);
});
}
};
zmm.use = function (paths, callback) {
if (!Array.isArray(paths)) {
paths = [paths];
}
var uris = [], i;
for (i = 0; i < paths.length; i++) {
uris.push(this.resolvePath(paths[i]));
}
// 先注册事件,再加载
this.proxy.watch(uris, callback);
this.load(uris);
};
zmm.proxy = function () {
var proxy = {};
var taskId = 0;
var taskList = {};
var execute = function (task) {
var uris = task.uris,
callback = task.callback;
for (var i = 0, arr = []; i < uris.length; i++) {
arr.push(zmm._modules[uris[i]]);
}
callback.apply(null, arr);
};
var deal_loaded = function (_uri) {
var i, k, task, sum;
// 当一个模块加载完成时,遍历当前任务栈
for (k in taskList) {
if (!taskList.hasOwnProperty(k)) {
continue;
}
task = taskList[k];
if (task.uris.indexOf(_uri) > -1) {
// 查看这个任务中的模块是否都已加载好
for (i = 0, sum = 0; i < task.uris.length; i++) {
if (zmm.isModuleLoaded(task.uris[i])) {
sum ++;
}
}
if (sum === task.uris.length) {
// 都加载完成 删除任务
delete(taskList[k]);
execute(task);
}
}
}
};
proxy.watch = function (uris, callback) {
// 先检查一遍是否都加载好了
for (var i = 0, sum = 0; i < uris.length; i++) {
if (zmm.isModuleLoaded(uris[i])) {
sum ++;
}
}
if (sum === uris.length) {
execute({
uris: uris,
callback: callback
});
} else {
// 订阅新加载任务
var task = {
uris: uris,
callback: callback
};
taskList['' + taskId] = task;
taskId ++;
}
};
proxy.emit = function (_uri) {
console.log(_uri + ' is loaded!');
deal_loaded(_uri);
};
return proxy;
}();

循环依赖问题

“循环加载”指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。这是一种应该尽量避免的设计。

浏览器端

用上面的zmm工具加载模块a:

1
2
3
4
5
6
7
8
9
10
11
12
13
// main.html
zmm.use('/a.js', function(){...});
// a.js
define('/b.js', function(b) {
var a = 1;
a = b + 1;
return a;
});
// b.js
define('/a.js', function(a) {
var b = a + 1;
return b;
});

就会陷入a等待b加载完成、b等待a加载完成的死锁状态。sea.js碰到这种情况也是死锁,也许是默认这种行为不应该出现。
seajs里可以通过require.async来缓解循环依赖的问题,但必须改写a.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// a.js
define('./js/a', function (require, exports, module) {
var a = 1;
require.async('./b', function (b) {
a = b + 1;
module.exports = a; //a= 3
});
module.exports = a; // a= 1
});
// b.js
define('./js/b', function (require, exports, module) {
var a = require('./a');
var b = a + 1;
module.exports = b;
});
// main.html
seajs.use('./js/a', function (a) {
console.log(a); // 1
});

但这么做a就必须先知道b会依赖自己,且use中输出的是b还没加载时a的值,use并不知道a的值之后还会改变。
在浏览器端,似乎没有很好的解决方案。node模块加载碰到的循环依赖问题则小得多。

node/CommonJS

CommonJS模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。CommonJS的做法是,一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.js
var a = 1;
module.exports = a;
var b = require('./b');
a = b + 1;
module.exports = a;
// b.js
var a = require('./a');
var b = a + 1;
module.exports = b;
// main.js
var a = require('./a');
console.log(a); //3

上面main.js的代码中,先加载模块a,执行require函数,此时内存中已经挂了一个模块a,它的exports为一个空对象a.exports={};接着执行a.js中的代码;执行var b = require(‘./b’);之前,a.exports=1,接着执行require(b);b.js被执行时,拿到的是a.exports=1,b加载完成后,执行权回到a.js;最后a模块的输出为3。

CommonJS与浏览器端的加载器有着实现上的差异。node加载的模块都是在本地,执行的是同步的加载过程,即按依赖关系依次加载,执行到加载语句就去加载另一个模块,加载完了再回到函数调用点继续执行;浏览器端加载scripts由于天生限制,只能采取异步加载,执行回调来实现。

ES6

ES6模块的运行机制与CommonJS不一样,它遇到模块加载命令import时,不会去执行模块,而是只生成一个引用。等到真的需要用到时,再到模块里面去取值。因此,ES6模块是动态引用,不存在缓存值的问题,而且模块里面的变量,绑定其所在的模块。

这导致ES6处理”循环加载”与CommonJS有本质的不同。ES6根本不会关心是否发生了”循环加载”,只是生成一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

来看一个例子:

1
2
3
4
5
6
7
8
9
10
// even.js
import { odd } from './odd';
export var counter = 0;
export function even(n) { counter++; return n == 0 || odd(n - 1);}
// odd.js
import { even } from './even';
export function odd(n) { return n != 0 && even(n - 1);}
// main.js
import * as m from './even.js';
m.even(10); // true; m.counter = 6

上面代码中,even.js里面的函数even有一个参数n,只要不等于0,就会减去1,传入加载的odd()。odd.js也会做类似作。

上面代码中,参数n从10变为0的过程中,foo()一共会执行6次,所以变量counter等于6。第二次调用even()时,参数n从20变为0,foo()一共会执行11次,加上前面的6次,所以变量counter等于17。

而这个例子要是改写成CommonJS,就根本无法执行,会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function(n) {
counter++;
return n == 0 || odd(n - 1);
}
// odd.js
var even = require('./even').even;
module.exports = function(n) {
return n != 0 && even(n - 1);
}
// main.js
var m = require('./even');
m.even(10); // TypeError: even is not a function

上面代码中,even.js加载odd.js,而odd.js又去加载even.js,形成”循环加载”。这时,执行引擎就会输出even.js已经执行的部分(不存在任何结果),所以在odd.js之中,变量even等于null,等到后面调用even(n-1)就会报错。