模块化的演变

在没有模块化的概念之前,代码量也不大的时候,可以直接将JavaScript代码写在html<script>标签中,如下

1
<script type="text/javascript"></script>

当代码量变多了,也可以在html引入外部js脚本,如下

1
<script src="js/index.js"></script>

当业务变得复杂,人们开始根据页面来引入脚本,即一个页面就引入包含此逻辑的脚本,公用的脚本可以每个页面都引入。但是,当这个公用的脚本过于复杂,有多个处理逻辑,如下

1
2
3
4
function fn1() {}

function fn2() {}
// ... many other functions

有可能页面1只需要fn1,fn2等等很多函数,页面2却只需要fn1,却都要引入整个脚本,这就不太合理了。

那么,按照功能来分如何?我们在a.js, b.js, c.js分别处理a, b, c如下,然后引入该页面的逻辑index.js,简单起见这里index.js就简单的把a, b, c输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<meta charset="UTF-8" />
<!-- <link rel="stylesheet" type="text/css" href="styles.css"> -->
</head>

<body>
<h1>Hello there!</h1>
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>
<script src="index.js"></script>
</body>
</html>
1
2
// a.js
var a = [1, 2, 3].reverse();
1
2
// b.js
var b = a.concat([4, 5, 6]);
1
2
// c.js
var c = b.join("-");
1
2
3
4
// index.js
console.log(moduleA.a); // [3, 2, 1]
console.log(moduleB.b); // [3, 2, 1, 4, 5, 6]
console.log(moduleC.c); // 3-2-1-4-5-6

这种写法存在的问题:

  • 顺序问题:在html的引入脚本的时候,顺序必须要按照模块的逻辑,否则就会报错,这是因为解析html会造成阻塞,解析完一个脚本再进行下一个,假设我们把index.js先引入了,这个时候a,b,c都还没有定义。
  • 作用域问题:可以看到这些文件是公用一个作用域的,在单独脚本暴露出来的变量,实际上都存在于全局作用域。容易发生变量覆盖等问题。

这里就引出了模块化需要解决的问题:加载顺序,污染的全局变量。

为了避免全局变量的污染,可以通过闭包来尝试解决,将需要的变量通过返回值暴露出去即可。

下面通过立即执行函数把之前例子改写如下

1
2
3
4
5
6
7
8
// a.js
var moduleA = (function () {
var a = [1, 2, 3].reverse();
return {
a: a,
};
})();
// moduleA = {a : a};

该模块是暴露在全局的,可以a变量是模块局部的,在 b 中要使用可通过moduleA.a,下面通过注入的方式避免了在全局下寻找moduleA

这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

类似于jQuery;(function($){//do something}(jQuery))

1
2
3
4
5
6
7
8
9
// b.js
// 要使用 moduleA 中的变量,将 moduleA 注入

var moduleB = (function (moduleA) {
var b = moduleA.a.concat([4, 5, 6]);
return {
b: b,
};
})(moduleA);

c.js中同理,通过注入moduleB来使用moduleB的变量。

1
2
3
4
5
6
7
// c.js
var moduleC = (function (moduleB) {
var c = moduleB.b.join("-");
return {
c: c,
};
})(moduleB);

最后,index.js依赖于以上模块。

1
2
3
4
5
6
// index.js
(function (moduleA, moduleB, moduleC) {
console.log(moduleA.a);
console.log(moduleB.b);
console.log(moduleC.c);
})(moduleA, moduleB, moduleC);

但是该方法依然要求保证引入脚本的顺序。

开发者通过实践,形成了一种插件化模式,很多也是通过立即执行函数来实现的。插件能够给用户提供一些可配置项,让用户通过配置项实现不同的功能。

模块化解决方案

CommonJS

CommonJS 是来源于 NodeJS 的一种规范,需要node环境。

并且由于它是同步加载的,通过require引用,必须加载完成后才会执行下面的代码(阻塞),这样就很不适合浏览器环境。

这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于”假死”状态。

这也是之后引入异步模块定义(Asynchronous Module Definition, AMD)的原因。

使用 CommonJS 改写上面的例子,此时index.html只需要引入index.js即可。

1
2
3
4
5
6
7
8
// [common JS] a.js
var a = (function () {
return [1, 2, 3].reverse();
})();

module.exports = {
a,
};
1
2
3
4
5
6
7
8
9
10
11
// [common JS] b.js

var moduleA = require("./a");

var b = (function () {
return moduleA.a.concat([4, 5, 6]);
})();

module.exports = {
b,
};
1
2
3
4
5
6
7
8
9
10
// [common JS] c.js
var moduleB = require("./b");

var c = (function () {
return moduleB.b.join("-");
})();

module.exports = {
c,
};
1
2
3
4
5
6
7
8
// [common JS] index.js
var moduleA = require("./a");
var moduleB = require("./b");
var moduleC = require("./c");

console.log(moduleA.a);
console.log(moduleB.b);
console.log(moduleC.c);

需要注意的是,require会创建一个模块的实例,并且有缓存机制,

CommonJS一个模块对应一个脚本文件,require 命令每次加载一个模块就会执行整个脚本,然后生成一个对象。这个对象一旦生成,以后再次执行相同的 require 命令都会直接到缓存中取值。

引用模块脚本进来其实是一个立即执行函数,例如,对于下面的 JS 模块文件

1
2
3
4
var modulex = function () {
// do something;
};
module.exports = modulex;

引入时会被node.js包装成如下形式,这也是为什么我们能够使用exports, require, module的原因。

1
2
3
4
5
6
(function (exports, require, module, __filename, __dirname) {
var modulex = function () {
// do something;
};
module.exports = modulex;
})();

AMD

异步模块定义(Asynchronous Module Definition, AMD)是是在浏览器环境下出现的模块化解决方案,异步加载模块,并且是前置依赖,即依赖加载完毕后才会执行最后一个参数的回调函数。

1
2
3
4
5
//Calling define with module ID, dependency array, and factory function
define("myModule", ["dep1", "dep2"], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});

AMD基于require.js,主要解决的问题是:

  • 实现js文件的异步加载,避免网页失去响应;
  • 管理模块之间的依赖性,便于代码的编写和维护。

使用AMD改写上面的例子

1
2
3
4
5
6
7
// [AMD] a.js
define("moduleA", function () {
var a = [1, 2, 3].reverse();
return {
a: a,
};
});
1
2
3
4
5
6
7
// [AMD] b.js
define("moduleB", ["moduleA"], function (moduleA) {
var b = moduleA.a.concat([4, 5, 6]);
return {
b: b,
};
});
1
2
3
4
5
6
7
// [AMD] c.js
define("moduleC", ["moduleB"], function (moduleB) {
var c = moduleB.b.join("-");
return {
c: c,
};
});

需要在入口文件配置依赖的脚本的路径,在此例中是index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// [AMD] index.js

// 需要在入口文件配置依赖的路径
require.config({
path: {
moduleA: "./a",
moduleB: "./b",
moduleC: "./c",
},
});

require(["moduleA", "moduleB", "moduleC"], function (
moduleA,
moduleB,
moduleC
) {
console.log(moduleA.a);
console.log(moduleB.b);
console.log(moduleC.c);
});

AMD解决了模块依赖问题,规范化了输入输出。

CMD (SeaJS)

Common Module Definition,即通用模块定义。CMDSeaJS 在推广过程中对模块定义的规范化产出。

CMD规范和AMD类似,都主要运行于浏览器端,写法上看起来也很类似。主要是区别在于模块初始化时机

  • AMD 是依赖前置:只要模块作为依赖时,就会加载并初始化,加载完后执行回调
  • CMD 是依赖就近:模块作为依赖且被引用时才会初始化,否则只会加载。
  • AMDAPI 默认是一个当多个用,CMD 严格的区分推崇职责单一。例如,AMDrequire 分全局的和局部的。CMD 里面没有全局的 require,提供 seajs.use() 来实现模块系统的加载启动。CMD 里每个 API 都简单纯粹。

UMD 规范

集结了 CommonJs, CMD, AMD 的规范于一身,通过运行时或者编译时让同一个代码模块在使用 CommonJs, CMD, AMD的项目中运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// UMD 简单实现
((global, factory) => {
//如果 当前的上下文有 defin e函数,说明处于AMD 环境下
if (typeof define === "function" && define.amd) {
define(["moduleA"], factory);
} else if (typeof exports === "object") {
// CommonJS
let moduleA = require("moduleA");
modules.exports = factory(moduleA);
} else {
global.moduleA = factory(global.moduleA); //直接挂载成 windows 全局变量
}
})(this, (moduleA) => {
//本模块的定义
return {};
});

ES6 模块化

import命令用于输入其他模块提供的功能,export命令用于规定模块的对外接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//lib.js
//导出常量
export const sqrt = Math.sqrt;
//导出函数
export function square(x) {
return x * x;
}
//导出函数
export function diag(x, y) {
return sqrt(square(x) + square(y));
}

//main.js
import { square, diag } from "./lib";
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5

ES6 Module 和 CommonJS 对比

  • ES6 Module静态化的,在编译时确定模块的依赖关系。CommonJSAMD只能在运行时确定。
  • CommonJS模块输出的是一个值的浅拷贝ES6输出的是值的引用
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
// [CommonJS]

// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};

// main.js
var counter = require("./lib").counter;
var incCounter = require("./lib").incCounter;
console.log(counter); // 3
incCounter();
console.log(counter); // 3
// 上面代码说明,**counter** 输出以后,**lib.js** 模块内部的变化就影响不到 **counter** 了。

// -----------------------------------------------------------------------------//

//common_lib.js
function incCounter() {
obj.counter++;
}

var obj = {
counter: 3,
};

exports.obj = obj;
exports.incCounter = incCounter;

//common_main.js
let lib = require("./common_lib");

console.log(lib.obj); //3
lib.incCounter();
console.log(lib.obj); //4
// 可以理解为对 lib 是浅拷贝,所以能够更改引用类型的 obj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// [ES6 Module]

// lib.js
export let counter = 3;

export function incCounter() {
counter++;
}

//main.js
import { counter, incCounter } from "./lib";

console.log(counter); //3
incCounter();
console.log(counter); //4
// **incCounter** 方法调用,能够修改**counter**, 说明 **ES6 module** 导出的是**变量的引用**,而不是值拷贝。

参考资料

  1. JavaScript 模块化全面解析
  2. Javascript 模块化编程
  3. JS 模块化——CommonJS AMD CMD UMD ES6 Module 比较