ltaoo's web

模仿 sea.js 实现 CMD 模块加载器(一)

模块化是现在编写 JavaScript 的必然选择,而模块规范和我们如何写模块化的代码有很大关系,比如AMD规范与CMD规范,而这些规范具体是指什么呢,下面以仿照sea.js的源码自己实现一个简单的模块加载器来具体了解CMD规范。

还是先来实现一个最简单的模块加载器。

现代模块机制

下面以一个简单的例子来介绍模块机制。
html文件内加载了三个js文件,fakeSea为核心库,say.js声明了一个方法say()可以打印hellomain.js引入say.js并使用该方法。

所以下面的代码可以在浏览器中打印出hello

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>实现简单的 CMD 模块加载器</title>
</head>
<body>
<script src="./fakeSea.js"></script>
<script src="./say.js"></script>
<script src="./main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fakeSea.js
var module = {}
;(function (global) {
// 内部保存模块的对象
var providedMods = {}
// 使用模块
function load (ids, callback) {
var deps = []
for(var i = 0, len = ids.length; i < len; i++) {
// 获取保存在 module 内的模块,放入 deps 数组中
deps[i] = providedMods[ids[i]]
}

callback && callback.apply(null, deps)
}
// 声明模块
function declare (name, mod) {
providedMods[name] = mod
}

module.load = load
module.declare = declare
})(this)
1
2
3
4
// say.js
module.declare('say', function () {
alert('hello')
})
1
2
3
4
// main.js
module.load(['say'], function (say) {
say()
})

为什么使用模块化

当然在一个js文件中声明方法,另一个文件中使用,不用这种所谓的模块化也可以实现,但是存在一个问题就是存在太多全局变量。而使用模块化,全局只存在module一个变量。

而且,现在虽然需要手动在html内引入say.jsmain.js,但完善后的fakeSea.js,仅仅只需要引入fakeSea.js一个文件。

同时,需要注意到先引入了say.js再引入main.js,因为main.js需要用到say.js内的函数,即main.js依赖say.js,使用模块化后就不需要担心先后顺序,都在内部进行了处理。

原理

很明显能够看出这是利用了“闭包”,providedMods变量存在于自执行函数的作用域内,按理说外部无法访问到,但是同时存在于该函数作用域内还有load以及declare函数,这两个函数能够访问到,所以declare用来修改providedMods变量,load用来获取providedMods变量。

最后将这两个函数暴露至全局,外部就能够借助着两个函数访问作用域内的变量了。

开始

sea.js最初版本代码量虽然不多只有七百余行,但为了降低难度还是从”简陋版“过渡到”完善版“,一个一个功能点进行实现。

自动处理依赖

只需要指定入口文件,不需要关心先引入哪个模块,我们现在来实现该功能点。

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>实现简单的 CMD 模块加载器</title>
</head>
<body>
<script src="./fakeSea.js"></script>
<script src="./main.js"></script>
</body>
</html>
1
2
3
4
5
6
// say.js
module.declare(function (exports) {
exports.say = function () {
alert('hello')
}
})
1
2
3
4
// main.js
module.load(['./say.js'], function (module) {
module.say()
})

可以看到main.js中指定了say模块的地址为./say.js,声明模块的方式也改变为declare方法接收函数作为参数,在函数内使用exports导出模块。

生成 script 标签

load模块时,如果模块存在,就可以像上面一样直接使用,不存在则需要借助script标签加载该文件。

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
function load (ids, callback) {
// 筛选出未加载的模块
var urls = getUnMemoized(ids)
// 如果未加载的模块数量为 0 ,表示都是已经加载好的模块,就可以直接调用回调函数了
if (urls.length === 0) {
var deps = []
for(var i = 0, len = ids.length; i < len; i++) {
// 获取保存在 module 内的模块,放入 deps 数组中
deps[i] = providedMods[ids[i]]
}

callback && callback.apply(null, deps)
} else {
// 不然的话就要加载该模块
for(var i = 0, len = urls.length; i < len; i++) {
// 使用 provide 函数来加载新模块
provide(urls[i])
}
}
}

// 筛选未加载的模块
function getUnMemoized (ids) {
var unLoadMods = []
for(var i = 0, len = ids.length; i < len; i++) {
if (!providedMods[ids[i]]) unLoadMods.push(ids[i])
}

return unLoadMods
}

重点在于provide函数,

1
2
3
4
5
6
7
8
// 加载新模块
function provide (url) {
var script = document.createElement('script')
script.src = url
script.async = true
var head = document.getElementsByTagName('head')[0]
head.appendChild(script)
}

可想而知,当provide('./say.js')的时候,会生成一个script标签插入head内,当文件下载成功,就会立即执行其中的代码:

1
2
3
4
5
module.declare(function (exports) {
exports = function () {
alert('hello')
}
})

我们”假设“该文件加载成功后,模块就被定义好了可以使用了,那就应该在文件加载成功的时候,通知load函数可以执行回调函数了是吧,所以需要用到多个回调函数:

回调流程

增加好回调函数后,刷新浏览器如果报错TypeError: say in not a function表示成功,能够执行我们想要执行的函数。

保存模块

由于load时会从providedMods对象中获取需要的模块,所以肯定要在script标签下载文件成功后,将该模块加入到该全局对象中。

为什么不在declare函数内完成该功能?因为declare只接收一个函数,没有name字段,不知道该怎么保存。

sea.js的实现是,在declare内将函数加入到一个数组中,再在能够拿到name的地方取出来。

比如provide函数内,因为此时有url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 加载新模块
function provide (url, callback) {
var script = document.createElement('script')
script.addEventListener('load', function () {
for(var i = 0, len = pendingMods.length; i < len; i++) {
var mod = pendingMods[i]
// 加入全局对象
mod && memoize(url, mod)
}
callback()
})
script.src = url
script.async = true
var head = document.getElementsByTagName('head')[0]
head.appendChild(script)
}

获取模块

OK,我们现在假定已经完成了模块加载,现在是要调用load的回调函数了,我们需要将模块作为参数传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 使用模块
function load (ids, callback) {
// 筛选出未加载的模块
var urls = getUnMemoized(ids)
// 如果未加载的模块数量为 0 ,表示都是已经加载好的模块,就可以直接调用回调函数了
if (urls.length === 0) {
var deps = []
for(var i = 0, len = ids.length; i < len; i++) {
// 获取保存在 module 内的模块,放入 deps 数组中
deps[i] = providedMods[ids[i]]
}

callback && callback.apply(null, deps)
} else {
// 不然的话就要加载该模块
for(var i = 0, len = urls.length; i < len; i++) {
// 使用 provide 函数来加载新模块
provide(urls[i], function () {
callback()
})
}
}
}

这部分代码肯定是没有用了,因为providedMods['./say.js']对应的值是:

1
2
3
4
5
function (exports) {
exports.say = function () {
alert('hello')
}
}

我们需要的是exports这个变量,而不是整个函数。我们声明一个require函数用来获取依赖。

1
2
3
4
5
6
7
8
9
10
// 获取依赖
function require(id) {
var factory = providedMods[id]
var exports = {}
if (typeof factory === 'function') {
factory(exports)
}

return exports
}

通过声明一个空对象,作为参数传入后,经过factory的”加工“后,就变成了say函数。

不过发现之前load代码存在很大的问题,如果

1
2
3
load(['a.js', 'b.js'], function (a, b){
// ...
})

a.jsb.js都不存在需要使用script加载,而provide函数在for循环内,每加载成功一个js文件就要调用一次callback很明显不对。

1
2
3
4
5
6
7
for(var i = 0, len = urls.length; i < len; i++) {
// 使用 provide 函数来加载新模块
provide(urls[i], function () {
// 会调用 urls.length 次数
callback()
})
}

所以要重写。。。。怎么写呢,将load函数名改为provideprovide改为fetch,再新增load函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 使用模块
function load (ids, callback) {
provide.call(null, ids, function () {
var args = []
for(var i = 0, len = ids.length; i < len; i++) {
var mod = require(ids[i])
if (mod) {
args.push(mod)
}
}
callback && callback.apply(null, args)
})
}

完整代码

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
// fakeSea.js
var module = {}
;(function (global) {
// 内部保存模块的对象
var providedMods = {}
// 临时保存声明的模块
var pendingMods = []
// 使用模块
function load (ids, callback) {
provide.call(null, ids, function () {
var args = []
for(var i = 0, len = ids.length; i < len; i++) {
var mod = require(ids[i])
if (mod) {
args.push(mod)
}
}
callback && callback.apply(null, args)
})
}
//
function provide (ids, callback) {
// 筛选出未加载的模块
var urls = getUnMemoized(ids)
// 如果未加载的模块数量为 0 ,表示都是已经加载好的模块,就可以直接调用回调函数了
if (urls.length === 0) {
return callback && callback.apply(null, deps)
} else {
// 不然的话就要加载该模块
for(var i = 0, len = urls.length, count = len; i < len; i++) {
// 使用 fetch 函数来加载新模块
fetch(urls[i], function () {
// 当模块都加载成功了,才调用回调函数
--count === 0 && callback()
})
}
}
}
// 声明模块
function declare (factory) {
pendingMods.push(factory)
}
// 将模块加入到全局对象中
function memoize (url, mod) {
providedMods[url] = mod
}
// 筛选未加载的模块
function getUnMemoized (ids) {
var unLoadMods = []
for(var i = 0, len = ids.length; i < len; i++) {
if (!providedMods[ids[i]]) unLoadMods.push(ids[i])
}

return unLoadMods
}
// 加载新模块
function fetch (url, callback) {
var script = document.createElement('script')
script.addEventListener('load', function () {
for(var i = 0, len = pendingMods.length; i < len; i++) {
var mod = pendingMods[i]
// 加入全局对象
mod && memoize(url, mod)
}
callback()
})
script.src = url
script.async = true
var head = document.getElementsByTagName('head')[0]
head.appendChild(script)
}
// 获取依赖
function require(id) {
var factory = providedMods[id]
var exports = {}
if (typeof factory === 'function') {
factory(exports)
}
return exports
}

module.load = load
module.declare = declare
})(this)
1
2
3
4
// main.js
module.load(['./say.js'], function (module) {
module.say()
})
1
2
3
4
5
6
// say.js
module.declare(function (exports) {
exports.say = function () {
alert('hello')
}
})

总结

虽然仅仅是一个简单的模块加载器,但是也能够大概了解如何获取模块、如何保存模块。