JavaScript 模块化

模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系统。

为什么需要模块化?

早期前端只是为了实现简单的页面交互逻辑,随着Ajax技术的广泛应用,前端库的层出不穷,前端代码日益膨胀,JavaScript却没有为组织代码提供任何明显帮助,甚至没有类的概念,更不用说模块(module)了,这时候JavaScript极其简单的代码组织规范不足以驾驭如此庞大规模的代码.
模块化可以使你的代码低耦合,功能模块直接不相互影响。

  • 可维护性:根据定义,每个模块都是独立的。良好设计的模块会尽量与外部的代码撇清关系,以便于独立对其进行改进和维护。维护一个独立的模块比起一团凌乱的代码来说要轻松很多。
  • 命名空间:在JavaScript中,最高级别的函数外定义的变量都是全局变量(这意味着所有人都可以访问到它们)。也正因如此,当一些无关的代码碰巧使用到同名变量的时候,我们就会遇到“命名空间污染”的问题。
  • 可复用性:现实来讲,在日常工作中我们经常会复制自己之前写过的代码到新项目中, 有了模块, 想复用的时候直接引用进来就行。

刀耕火种时代

早期的JS为了实现模块化,用了一些“奇技淫巧”来达到模块的效果

立即执行函数(IIFE)写法
使用立即执行函数(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var Module = (function(){
var _private = "safe now";
var foo = function(){
console.log(_private)
}

return {
foo: foo
}
})()

Module.foo();
Module._private; // undefined

这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量, 在模块外部无法修改我们没有暴露出来的变量、函数.

引入依赖
将全局变量当成一个参数传入到匿名函数然后使用

1
2
3
4
5
6
7
8
9
10
11
12
13
var Module = (function($){
var _$body = $("body"); // we can use jQuery now!
var foo = function(){
console.log(_$body); // 特权方法
}

// Revelation Pattern
return {
foo: foo
}
})(jQuery)

Module.foo();

jQuery的封装风格曾经被很多框架模仿,通过匿名函数包装代码,所依赖的外部变量传给这个函数,在函数内部可以使用这些依赖,然后在函数的最后把模块自身暴漏给window。如果需要添加扩展,则可以作为jQuery的插件,把它挂载到$上。
这种风格虽然灵活了些,但并未解决根本问题:所需依赖(jQuery)还是得外部提前提供、而且增加了全局变量Module。

从以上的尝试中,可以归纳出js模块化需要解决的问题:

  • 如何安全的包装一个模块的代码?(不污染模块外的任何代码)
  • 如何唯一标识一个模块?
  • 如何优雅的把模块的API暴漏出去?(不能增加全局变量)
  • 如何方便的使用所依赖的模块?

CommonJS

2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。
这标志Javascript模块化编程正式诞生。因为老实说,在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。
node.js的模块系统,就是参照CommonJS规范实现的。

在CommonJS中,一个单独的文件就是一个模块。每一个模块都是一个单独的作用域,也就是说,在该模块内部定义的变量,无法被其他模块读取,除非定义为global对象的属性。模块输出的内容在module.exports对象内。 加载模块使用require方法,该方法读取一个文件并执行,返回文件内部的module.exports对象。

1
2
3
4
5
6
7
8
9
10
11
// math.js
function add(a, b) {
return a + b;
}
module.exports = {
add: add,
}

// main.js
var math = require('math')
console.log(math.add(1, 2));

这种实现模式有两点好处:
避免全局命名空间污染、明确代码之间的依赖关系。

但是, 由于一个重大的局限,使得CommonJS规范不适用于浏览器环境。
CommonJS用同步的方式加载模块
也就是说,在加载完math.js之前,console.log语句及下面的代码都不会执行,如果加载的模块较大,数量较多,整个应用就会一直卡着直到加载完毕。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。
因此,浏览器端的模块,不能采用”同步加载”(synchronous),只能采用”异步加载”(asynchronous)。这就是AMD规范诞生的背景。

AMD

AMD(Asynchronous Module Definition)规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
这里介绍用require.js实现AMD规范的模块化:用require.config()指定引用路径等,用define()定义模块,用require()加载模块。

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
//a.js
define(function(){
console.log('a.js执行');
return {
hello: function(){
console.log('hello, a.js');
}
}
});

//b.js
define(function(){
console.log('b.js执行');
return {
hello: function(){
console.log('hello, b.js');
}
}
});

//main.js
require.config({
paths: {
"jquery": "../js/jquery.min"
},
});
require(['jquery','a', 'b'], function($, a, b){
console.log('main.js执行');
a.hello();
$('#btn').click(function(){
b.hello();
});
});

上面的main.js被执行的时候,会有如下的输出:
a.js执行
b.js执行
main.js执行
hello, a.js
在点击按钮后,会输出:
hello, b.js

但是如果细细来看,b.js被预先加载并且预先执行了,(第二行输出),b.hello这个方法是在点击了按钮之后才会执行,如果用户压根就没点,那么b.js中的代码应不应该执行呢?
这其实也是AMD/RequireJs被吐槽的一点,由于浏览器的环境特点,被依赖的模块肯定要预先下载的。问题在于,是否需要预先执行?如果一个模块依赖了十个其他模块,那么在本模块的代码执行之前,要先把其他十个模块的代码都执行一遍,不管这些模块是不是马上会被用到。这个性能消耗是不容忽视的。

另一点被吐槽的是,在定义模块的时候,要把所有依赖模块都罗列一遍,而且还要在factory中作为形参传进去,要写两遍很大一串模块名称,像这样:

1
define(['a', 'b', 'c', 'd', 'e', 'f', 'g'], function(a, b, c, d, e, f, g){  ..... })

CMD

CMD(Common Module Definition)是另一种js模块化方案,它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

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
//a.js
define(function(require, exports, module){
console.log('a.js执行');
return {
hello: function(){
console.log('hello, a.js');
}
}
});
//b.js
define(function(require, exports, module){
console.log('b.js执行');
return {
hello: function(){
console.log('hello, b.js');
}
}
});

//main.js
define(function(require, exports, module){
console.log('main.js执行');
var a = require('a');
a.hello();
$('#b').click(function(){
var b = require('b'); //在需要时才声明
b.hello();
});

});

Sea.js加载依赖的方式
加载期:即在执行一个模块之前,将其直接或间接依赖的模块从服务器端同步到浏览器端;
执行期:在确认该模块直接或间接依赖的模块都加载完毕之后,执行该模块。

AMD vs CMD
AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块,
CMD推崇就近依赖,只有在用到某个模块的时候再去require,
AMD和CMD最大的区别是对依赖模块的执行时机处理不同

同样都是异步加载模块,AMD在加载模块完成后就会执行改模块,所有模块都加载执行完后会进入require的回调函数,执行主逻辑.
CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。

总的来说,CommonJS是用在服务器端的,同步的,如nodejs。AMD, CMD是用在浏览器端的,异步的,如requirejs和seajs ,AMD依赖前置,CMD就近依赖。

ES6 Module

上述的这几种方法都不是JS原生支持的, 在ECMAScript 6 (ES6)中,引入了模块功能, ES6 的模块功能汲取了CommonJS 和 AMD 的优点,拥有简洁的语法并支持异步加载,并且还有其他诸多更好的支持。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:exportimport。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

一个要注意的点是:ES6 的模块自动采用严格模式,不管你有没有在模块头部加上”use strict”。

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。

1
2
3
4
5
6
7
8
9
10
11
12
/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b) {
return a + b;
};
export { basicNum, add };

/** 引用模块 **/
import { basicNum, add } from './math';
function test(ele) {
ele.textContent = add(99 + basicNum);
}

需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新.

注意import命令是编译阶段执行的,在代码运行之前。

1
2
foo();
import { foo } from 'my_module';

上面的代码不会报错,因为import的执行早于foo的调用。

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

1
2
3
4
5
6
7
8
9
10
11
12
// circle.js
export function area(radius) {
return Math.PI * radius * radius;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
// main.js
import * as circle from './circle';

console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));

从前面的例子可以看出,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

1
2
3
4
5
6
7
8
9
10
11
// 第一组
export default function crc32() { // 输出
}

import crc32 from 'crc32'; // 输入

// 第二组
export function crc32() { // 输出
};

import {crc32} from 'crc32'; // 输入

与CommonJS比较
CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。

CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。