目录
一、什么是模块化?
模块化是一种将复杂系统分解为更好的可管理模块的方式(引自百度百科)。
在软件开发领域,当一个系统的规模变得十分庞大,功能变得非常复杂时,开发和维护该系统就会变得相当困难。这个问题在软件开发早期就已经初见端倪。随着软件行业的成熟,它更是成为软件开发的头号敌人,并在20世纪60年代末期造成了一场著名的“软件危机”。这次软件危机还催生了软件工程这个研究领域,旨在研究如何对大型软件项目进行科学化地管理。
其中模块化开发就是该领域最重要的成果之一。它的基本思想是将整个系统划分成若干个模块,每个模块负责一个相对独立的功能,然后单独开发和维护这些模块,最后再将这些模块进行整合,以支撑整个系统的运行。
互联网的发展也是类似的。
互联网刚刚诞生时,各个网站的功能相对简单,编写和维护都没有太大的难度。但进入21世纪后,web的发展更加成熟,数以百万计的web站点被开发出来,取代了许多复杂的桌面应用。伴随着这一浪潮,网站的开发和维护工作又变得相当困难。由于有之前“软件危机”的应对经验,模块化开发迅速被应用于web领域。不过这里指的主要是后端开发,因为彼时的前端开发还处于萌芽期。
但是现在,你可能很难想象如何在没有模块化规范的情况下,开发出像React、Vue这般复杂的前端框架。前端的发展自然而然地催生了前端的模块化方案。
目前前端的模块化方案主要有四个:CommonJS、AMD、CMD,以及ES6规范所定义的import/export规范。
其中CommonJS主要被应用于Nodejs、Browerify等;AMD规范的主要实现方案是RequireJS,主要用于浏览器端;CMD规范的主要实现是淘宝的模块化框架Sea.js;ES6的模块化方案则是未来的模块化标准。
下面我们具体来看一下这四个规范。
二、前端模块化规范详解
1. CommonJS
(1)基本用法
CommonJS是最早诞生的面向JavaScript的模块化规范,由Mozilla工程师Kevin Dangoor于2009年1月启动,最初名为ServerJS,同年8月更名为CommonJS。目前使用CommonJS规范的最重要的项目包括:Nodejs、Browerify。
在CommonJS规范中,每个文件就是一个模块(module),有自己的作用域。它内部定义的所有变量、方法、类都是私有的,对其他文件不可见。如:
moduleA.js
var a = 1;
function getA(){
return a;
}
在CommonJS规范下,这里的变量a
和函数getA
对外都是不可见的。
CommonJS规范的全局对象是global
(类似于浏览器环境下的window
对象),因此如果将变量或方法添加到global
对象上,它对其他文件是可见的,如:
global.a = 1;
此时变量a
被添加到了CommonJS的全局对象上,在任意文件内都可以访问。当然,这种做法在模块化开发中是极不推荐的。
CommonJS推荐的做法是通过module.exports
向外暴露接口。如:
var a = 1;
function getA(){
return a;
}
module.exports.getA = getA;
在CommonJS规范下,每个文件用来定义一个模块,对应一个module
对象。它的exports
属性是当前模块向外暴露的接口对象,其他文件在加载当前模块时,其实就是在加载该模块对象的exports
属性。我们将函数getA
添加为exports
的一个属性,它就可以被外部文件所访问了。而没有被添加到exports
属性上的变量a对外仍然是不可见的,此时它只能作为当前模块的私有变量使用。
为了方便,CommonJS还在每个模块内专门定义了一个exports
变量,指向当前模块的exports
属性,因此,上面的代码可以简写为:
exports.getA = getA;
也就是说,CommonJS相当于进行了下面这样一个赋值过程:
var exports = module.exports;
因此,如果你直接为变量exports
赋值,你将切断该变量与Module.exports
属性的联系,此时它将无法再向外提供接口:
exports = 'Hello World';
外部无法得到这里暴露出去的值,因为exports
变量此时不再指向module.exports
。不过module.exports
是可以直接赋值的,此时当前模块将只对外暴露这一个值(一般来说会是一个函数或一个类),如:
var a = 1;
module.exports = function(){
return a;
}
CommonJS通过require
命令来导入其他模块。前面说了,导入的实际上就是该模块对象module
的exports
属性。比如对于上面的例子:
var fun = require('moduleA.js');
// 相当于var fun = moduleA.exports;
fun(); // 1
(2)运行机制
假如有以下两个文件:
moduleA.js
var a = 1;
var getA = function(){
return a;
}
module.exports.getA = getA;
moduleB.js
// ES6的解构赋值,从接口对象中提取属性getA
var { getA } = require('moduleA.js');
getA(); // 1
此时moduleB.js
从moduleA.js
中导入了一个函数使用。以Nodejs环境为例,当我们输入以下命令运行moduleB.js
时:
node moduleB.js
Nodejs引擎会按照CommonJS规范做下面几件事:
- 读取
moduleB.js
,开始执行 - 执行到第一行
require
语句时,同步加载依赖的模块moduleA.js
- 执行
moduleA.js
内的代码,执行完后得到下面这个模块对象:
{ id: '.',
exports: { 'getA': [Function] },
parent: (moduleB.js对应的模块对象),
filename: '(文件路径)/moduleA.js',
loaded: false,
children:[],
paths:[......]
}
- 将该对象的
exports
属性作为require
的结果进行解构赋值,即var { getA } = require('moduleA.js');
,变量getA
即可从模块A中提取接口函数getA
。 - 运行
getA
,得到结果1
。
通过以上的介绍我们看到,CommonJS规范是实时同步加载依赖的。也就是说,只有每次执行到一个require
语句时,它才会去加载和执行该模块。得到执行结果后,引擎会进行赋值,然后继续执行后面的代码。
在Nodejs环境下,模块文件都是存储在本地硬盘中的,加载速度相对较快,所以这种实时加载,同步执行并不会带来什么性能问题。
但是在浏览器环境下则大不一样。因为浏览器加载模块文件需要通过http请求,从服务器下载,而这个过程是非常耗时的。假如浏览器在执行某个模块时,发现依赖了另一个模块,那么它必须去下载该模块,并且在模块下载完之前,浏览器一直处于等待状态(同步执行所导致的),这会带来非常严重的性能问题。因此,CommonJS并不是一个适合浏览器端的模块加载方案,而是多用于服务端(这也是它最初为什么叫ServerJS)。
2. AMD
AMD规范就是为了解决CommonJS实时加载,同步执行所带来的的弊端而出现的,主要用于浏览器端。它兼容CommonJS规范,符合CommonJS规范的模块可以被AMD规范正确导入。
它的全称是async module define
,即异步模块定义。顾名思义,这是一个异步模块加载方案。它的主要思想是,在模块的顶部声明所有的依赖模块(也可以使用require导入,但不同于CommonJS,它们会被提前预加载),并提供一个回调函数。浏览器预先加载好这些依赖模块后,会将这些模块对象作为参数传给回调函数并执行。
AMD规范使用统一的define
方法来定义和导入模块。它最多接收三个参数:
- id(可选),即模块名,字符串类型
- 依赖列表,数组类型,数组每项的值是依赖的模块路径或模块名(此时需要在配置文件中配置该模块的路径)
- 回调函数或当前模块定义的模块对象
一个完整的AMD模块看起来大致是这样的:
moduleA.js
define('moduleA', ['jquery', 'Base64'], function($, Base64){
...
})
第一个参数是可选的,它定义当前模块的名字,当它被其他模块引入时将使用该名字。在省略该参数的情况下,将自动使用文件名(即moduleA
,这里是不带文件后缀的)作为该模块的名字。
第二个参数是个依赖列表,指定了当前模块所依赖的模块。该参数也可以省略,此时有两种可能:一是当前模块没有依赖的模块,二是你将在回调函数中通过require引入模块。在使用具体的框架(如require.js)时,你需要提供一个配置文件,来指定这些模块的路径(以及声明依赖关系)。如:
index.html
<script src="/js/requirejs/require.js"></script>
<script src="/js/require/require.config.js"></script>
require.config.js
require.config({
baseUrl: "/",
waitSeconds: 0,
paths: {
jquery: "/js/jquery-3.2.1.min",
Base64:"/js/Base64",
bootstrap: '/js/bootstrap.min',
},
shim: {
// 指定模块的依赖关系
'bootstrap': {
deps: ["jquery"]
}
}
});
require.js将根据这个配置文件,预先加载好所有的模块。当模块加载完毕,引擎会自动调用传入的回调函数,并将依赖的模块对象按照声明的顺序依次传入回调函数使用(如上面声明了对jquery
和Base64
的依赖,它们就会分别作为参数传入回调函数,我们通过形参$
和Base64
来接收它们)。
该参数也是可以省略的,但这并不表示当前模块一定没有依赖外部模块,你可以通过require语句来导入依赖的模块。如:
define(function(){
var $ = require('/js/jquery.min');
...
})
乍看上去,这里的require语法似乎又会导致实时加载、同步执行的性能问题,但其实并不是这样的。
AMD规范并不会在执行该模块时才去下载和执行依赖模块,而是先调用回调函数的toString
方法,将其转化为字符串。然后用正则表达式匹配其中的require(...)
语句,提取依赖模块,并依次下载这些模块。
注意,Opera下函数的toString方法存在一定问题,可以通过构建工具解决该问题。
回调函数最终的返回值即是当前模块对外的输出,如:
define(function(){
var a = 1;
return function(){
return a;
}
})
这里只对外输出了一个接口函数。你也可以通过return一个对象,向外输出一个接口对象:
...
return {
a: a,
getA: function(){
return a;
}
}
你可以像下面一样输出一个符合CommonJS规范的对象:
define(function (require, exports, module) {
var a = require('a'),
b = require('b');
exports.action = function () {};
});
AMD规范会帮你自动注入这三个参数。
当然,你可以仅仅向define传入一个对象,此时该对象就是模块对象:
define({
add: function(x, y){
return x + y;
}
});
如果当前文件不是为了定义一个模块,而是需要像普通的js一样执行,可以直接使用require函数:
require(['jquery', 'Base64'], function($, Base64){
...
})
这个文件将不会作为一个模块使用,但是它同样可以从AMD规范中受益。
3. CMD
CMD规范与AMD规范非常相似,但是相对来说更严格。它的基本语法有点类似于CommonJS,如:
define(function(require, exports, module) {
var $ = require('jquery');
module.exports = {...}
exports.doSomething = function(){}
})
在AMD规范中我们也举了一个类似的例子,这就是说,其实CMD规范定义的模块在AMD中也是合法的。只是CMD严格要求使用这种语法来定义模块。这使得CMD规范看上去更直观和简洁。
AMD规范所推崇的是依赖前置,即提前声明好需要依赖的模块。而CMD则推崇依赖就近,也就是在用到的地方去声明依赖模块(尽管AMD模块也允许依赖就近,但只是允许,而不是推荐)。
CMD的define
函数接收的三个参数与CommonJS模块系统的概念是一致的:
require
是导入其他模块的方法exports
是指向module.exports
的变量module
即模块对象,同CommonJS
因此CMD规范保持了很好的对CommonJS和AMD规范的兼容性,同时它又比AMD规范的api定义得更加严格和简洁。
注意,与AMD一样,尽管CMD也使用require导入模块,但它也是异步加载的。CMD规范也会提前扫描回调函数,预下载依赖的模块,然后才会执行回调函数,以防止代码在执行过程中被阻塞。
4. ES6的模块规范
(1)基本用法
ES6的模块规范是JavaScript语言层面的模块规范,目标是取代上述所有规范,成为前端领域的标准模块规范。未来的浏览器将原生支持该模块规范。
ES6模块规范的一大特点是静态化,也就是可以在预编译的时候就尽可能地确定模块的依赖关系,以及需要输入和输出的变量。它使用import命令导入其他模块提供的接口,使用export命令导出接口。基本语法如下:
moduleA.js
import {a, getA} from 'moduleB';
getA();
moduleB.js
var a = 1;
function getA(){
return a;
}
export { a, getA };
实现了ES6模块规范的引擎可以在编译的时候就分析出moduleA依赖了moduleB向外提供的接口{ a, getA }
。同样的,它也可以在编译阶段检测出moduleB向外提供了两个接口:a
和getA
。这种静态化的导入导出可以极大提高引擎的执行效率,另外可以在编译阶段检测出异常接口。
(2)export命令
export命令用于导出模块的接口,其实现逻辑与闭包很类似。
在ES6规范中,模块不是对象,因此这些接口也就不像CommonJS规范一样是模块对象的属性。你可以理解为,每个ES6模块独自占有一块内存区(如同由闭包形成的一个封闭空间一样),通过export命令指定外部可访问的接口(这些接口就像是闭包的返回值一样,可以直接访问该模块所在的内存区)。
你可以在任何位置输出一个或多个接口:
export var a = 1;
function getA(){
return a;
}
export { getA };
上面的代码与下面的闭包有着几乎一致的逻辑:
(function(){
var a = 1;
function getA(){
return a;
}
return {a, getA};
})()
正是由于这种机制,下面的写法不能暴露出变量a(注意,export var a = 1
与下面的写法不等价):
var a = 1;
export a;
可以参考下面的写法:
(function(){
var a = 1;
return a;
})()
显然这是几乎没有意义的,因为接口规范要求暴露出来的是接口,而不是一个常量值。
另外,由于采用了这种导出机制,ES6的模块系统可以实时拿到接口最新的值。比如下面的例子:
moduleA.js
import {a} from 'moduleB'
console.log(a); // 1
setTimeout(function(){
console.log(a); // 2
}, 100)
moduleB.js
var a = 1;
setTimeout(function(){
return a + 1;
})
export {a};
上面的过程大致如下:
- 引擎在解析到
import {a} from 'moduleB'
语句时,会去执行模块B,并为其开辟一块内存区(就像闭包一样)。 - 将模块B暴露出的接口
{a}
导出,并在模块A中导入。由于模块B有自己的内存区,因此它保留着变量a。 - 当同步逻辑执行完毕后,引擎将去任务队列取出模块B中的异步任务,将变量a的值加一。
- 100毫秒后,模块A中的异步任务被执行,它会实时从模块B所在的内存区去取变量a的值,因此会输出新的值2。
在CommonJS模块内,使用exports.a = a
导出变量就等价于export a
,也就是只导出了一个值,因此这样无法获取变量a实时的值,但这并不意味着CommonJS总是无法得到变量a最新的值:
// moduleB
module.exports.tempA = {a}
//moduleA
var {tempA} = require('moduleB');
// temp现在持有moduleB中的对象{a}的内存地址,
// 这样它就可以间接操作变量a
看到了吗?我们没有将变量直接作为exports的属性返回,而是将其封装成了一个对象返回,这样它就会持有模块B中变量a的内存地址,因此也就可以同步跟踪变量a的值变化。
从这个角度来说,ES6的模块规范和CommonJS规范其实都是基于闭包原理实现的。下面我们给出一个例子:
(function(){
var a = 1,
b = 2;
return {a}; // 普通的闭包
export {a}; // ES6的模块规范
export {b}; // 你可以export任意多的接口,而不像return,只能返回一次
})()
// CommonJS规范
// moduleB
var moduleB = {};
(function(){
var a = 1;
// CommonJS下是在操作闭包外的一个对象
moduleB.exports.a = a;
})()
// moduleA
var { a } = moduleB.exports; // 解析出变量a
所以CommonJS并不是无法跟踪变量的值,而是你本来就只是把变量的值1
保存在了模块对象moduleB.exports.a
中了而已。
(3)import命令
在理解了export的实现之后,import命令已经极其简单,就是从ES6的模块注册区导入其他模块向外暴露出的接口(其实就是变量地址)。
import {a} from 'moduleB'
引擎在执行模块B的代码时会开辟一块内存,这里存在一个变量a,模块B通过export命令将其注册到ES6的模块系统,然后模块A从这个模块系统中拿到变量a的地址,仅此而已。由于模块A和模块B拿到的都是变量a的地址,因此它们的值总是同步的。
注意,每个模块都可以指定一个默认的对外接口:
// moduleB
export default function(){...}
// moduleA
import getName from 'moduleB'
这时导入的时候不需要匹配原模块的接口名,可以任意定义一个变量来接收这个接口,并且不需要使用大括号包裹。
三、其他规范
这里涉及的规范严格来讲并不是规范,而是基于基本规范的解决方案或执行环境。我们主要介绍UMD规范和webpack模块规范。
1. UMD规范
全称是universal module define,通用模块定义。它是一种适配CommonJS、AMD和CMD的模块定义标准。一个标准的UMD规范模块如下:
!( function( root, name, factory) {
// 是否在AMD规范下
if ( typeof define === 'function' && define.amd) {
define( name, factory);
// 是否在CommonJS或CMD规范下
} else if ( typeof exports === 'object') {
module.exports = factory();
// 没有使用模块化
} else {
root[name] = factory();
}
})( this, 'run', function () {
function run () {
return 'this is run function!';
}
return run;
});
可以看到,UMD规范在主函数中会通过检查define、exports这两个变量,来判断当前处于哪种模块化规范。判断依据如下:
typeof define === 'function' && define.amd
,这是判断是否在AMD规范的条件typeof exports === 'object'
,这个条件可以判断是否处于CommonJS或CMD规范下。CommonJS自然不用说。我们上面说到,CMD规范是兼容CommonJS的,它也通过一个变量exports来暴露接口对象,因此语句module.exports = factory();
可以同时用于CommonJS和CMD规范来定义模块。- 不符合上述条件,则认为没有处于模块化环境下。这时当前文件会把输出结果直接作为
this
的属性输出出去,如在浏览器环境下时,返回值会被添加到window
对象上(在浏览器环境下,this
就指向window
)。
现在无论是在CommonJS、AMD还是CMD规范下运行该代码,都可以输出符合所用模块规范的模块,从而实现了规范的兼容。
注意,UMD没有对ES6的模块规范进行处理,因为ES6模块规范是语言层面的规范,未来会得到浏览器的原生支持。一旦ES6规范得到原生支持,UMD规范以及另外三个规范都可能被取代。
2. webpack模块规范
webpack本身并没有自己的模块规范,但在webpack环境下,你可以使用CommonJS和ES6标准模块规范。
对于CommonJS规范,webpack本身就运行于Nodejs环境下,它的编译运行全部都依赖Nodejs。而Nodejs默认的模块规范就是CommonJS,所以你当然可以用CommonJS规范来导入导出模块。
对于ES6的模块规范,由于高版本的Nodejs提供了对ES6规范的原生支持,所以webpack自然也支持ES6模块规范。
另外,require方法在webpack环境下功能更加强大。除了导入js模块,它还可以用于导入任何其他类型的资源,如css、图片、PDF等,webpack将借助对应的loader来处理这些资源。
总结
本文侧重于讲解四种模块规范的实现原理,而没有深究它们的各种语法细节。我相信只要理解了这些原理,它们的语法很快就可以掌握了。
关于ES6的模块规范,如果有想了解更多细节的同学,可以参考阮一峰 Module 的语法。