前言
随着技术的发展,各种前端库层出不穷,前端代码日益膨胀。如果不对前端代码加以模块化规范去管理,维护将变得异常困难。本文的主要内容是理解什么是模块化、为什么要模块化、模块化的优缺点以及当下最流行的AMD、CMD、CommonJS、ES6规范。本文旨在用通俗易懂的语言介绍抽象的概念,希望对大家有所帮助。
什么是模块化?
什么是模块?
对于一个复杂的程序,将其按照一定的规范封装成几个文件块,每一块向外暴露一些接口,块的内部数据是私有的,块与块之间通过接口通信。这个过程称为模块化,而文件块称为模块。
模块化的前世今生
- 模仿命名空间进行简单对象封装
这种方法减少了全局变量的污染,减少了命名冲突。但与此同时,这种方法的问题是,由于是使用简单对象封装,对象外部能直接修改对象的属性,数据安全性不好。
let namespace={
data:'123456',
foo(){
console.log(`ns foo ${data}`);
},
bar(){
console.log(`ns bar ${data}`);
}
}
namespace.data='789456';
namespace.foo(); //ns foo 789456
namespace.bar(); //ns bar 789456
- 通过闭包进行封装
这种方法在减少全局变量冲突的同时还保证了私有数据的安全性,闭包外部只能通过暴露的接口操作私有数据。
那么如果当前模块依赖另一个模块怎么办?答案是将被依赖的模块作为参数传入。这种方法是实现现代模块化的基础。
// module.js文件
(function(window, $) {
let data = '123456';
function foo() {
console.log(`mo foo ${data}`);
$('body').css('background', 'black');
}
function bar() {
console.log(`mo bar ${data}`);
other(); //内部调用
}
function other() {
console.log(`mo other ${data}`);
}
//暴露行为
window.mo = { foo, bar };
})(window, jQuery);
// index.html文件
<script type='text/javascript' src='jquery'></script>
<script type='text/javascript' src='module.js'></script>
<!-- 注意这里引入依赖必须严格注意顺序,因为module是依赖于jquery的,所以jquery必须先于module引入 -->
<script type='text/javascript'>
mo.foo();
</script>
模块化的好处
- 减少全局变量污染
- 提高了可复用性
- 代码更易维护
- 模块分离可以实现按需加载
- 一定程度上减少了http请求的数量
模块化有着如此独特的好处,那么我们没有理由不对我们的代码进行模块化管理,下面我们来看一下四种主流的模块化规范,分别是CommonJS、AMD、CMD和ES6规范。
四种主流的模块化规范
CommonJS
1.概述
采用CommonJS模块规范的应用,每个文件就是一个模块,具有自己的私有作用域,不会污染全局作用域。模块的加载是同步的而且可以加载多次,但在第一次加载后就会被缓存,后面再次被加载时会直接从缓存中读取。CommonJS主要用于服务器Node.js编程。
2.module对象
每个模块内部都有一个module对象代表当前模块。module模块具有以下几个属性:
module.id
:模块的标识符,通常是绝对路径的模块文件名。module.filename
:模块的文件名。module.loaded
:一个布尔值,表示模块是否已经完成加载。module.parent
:一个数组,表示依赖该模块的模块。module.children
:一个数组,表示该模块依赖的模块。module.exports
:一个对象,表示模块向外暴露的内容。
3.module.exports属性
我们已经知道,module.exports属性是模块暴露的接口,加载某个模块时就是加载该模块的module.exports属性。所以module.exports属性是CommonJS模块化规范的核心。
4.exports变量
每个module都拥有一个exports变量,它指向module.exports属性,是module.exports的引用,设置这个变量是为了能够给module.exports添加属性,使用该变量添加的属性对调用该模块的模块可见。
const exports=module.exports; // CommonJS规范在每个module前隐式做了这个赋值
5.基本语法
- 暴露模块
module.exports=value或者module.xxx=value
- 引入模块
require(xxx)
,如果引入的模块是第三方模块,那么xxx为模块名。如果引入的模块是自定义模块,那么xxx为文件路径。
6.实战
// module_exports_example.js
let x = 5;
let func = function () {
console.log(func);
};
module.exports.x = x;
module.exports.func = func;
// 等价于module.exports={x,func};
// require_example.js
let example=require(./module_exports_example);// 后缀名默认为.js
console.log(example.x);// 5
example.func();// func
上面的例子给出了最简单CommonJS规范的例子,通过module.exports向外暴露模块,通过require在一个模块中引入以来的模块。下面我们来看一下exports变量的具体应用场景。
// module_exports_example.js
let x = 5;
let y = 5;
let func = function () {
console.log(func);
};
module.exports={x,func};
// 此时仍然想要向输出的对向添加属性可以这样做
exports.y = y;// 其实就是简单的为exports对象添加新的属性
// require_example.js
let example=require(./module_exports_example);// 后缀名默认为.js
console.log(example.x);// 5
console.log(example.y);// 5
example.func();// func
上述例子使用了exports变量给module.exports对象添加属性,其效果等价于:
// module_exports_example.js
let x = 5;
let y = 5;
let func = function () {
console.log(func);
};
module.exports={x,y,func};// 直接使用module.exports
// 也等价于module.exports.x=x; module.exports.y=y; module.exports.func=func;
突然一个鬼魅的操作涌上心头,如果两个模块循环引用会出现什么情况呢?如果模块的加载是简单的同步加载,那循环引用就会引起死循环,我们来试一下。
// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';
// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';
// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
执行main.js后输出结果为:
b.js a1
a.js b2
main.js a2
main.js b2
上述代码中a加载了b,b加载了a,但我们发现,并没有发生我们担心的循环引用的问题,这是因为CommonJS会在发生循环的位置剪断循环。具体的执行过程是这样的:
执行第一句输出,但由于require是同步加载,会先转去加载a.js,在加载完成前不会输出。在加载a.js的过程中转去加载b.js,这时发现b,js加载了a.js,发生了循环引用,CommonJS在发生循环的点,也就是a.js的第二句话处切断循环,也就是说b.js加载a.js的时候不会执行exports.x='a2'
。此时b.js加载a.js完毕,加载的a.x值是a1,所以先输出b.js a1
,b.js文件继续向下执行,将其暴露的属性b.x修改为b2,b.js文件执行完毕,显然此时a.js加载的b.x为b2,所以输出第二行a.js b2
,最后输出得到main.js a2
和main.js b2
。
我们将上面的main.js文件稍微改一下:
// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
执行一下,结果如下:
b.js a1
a.js b2
main.js a2
main.js b2
main.js a2
main.js b2
似乎跟我们的预期不太一样,为什么第二次加载的时候不会执行a.js和b.js文件里的输出了呢?我们刚刚说过,模块可以多次加载,在第一次加载以后再加载模块时,会直接从缓存中取值而不会再次加载文件,所以第二次加载的时候会直接从缓存中取出exports属性,所以a.js和b.js文件的console.log语句不会执行了。
另外还需要注意的一点是,模块内部的变化不会影响加载后的值,也就是说模块内部的属性和输出的属性不是响应式变化的,我们看一个例子:
// example.js
let num=0;
function inc(){
num++;
}
module.exports={num,inc};
let num=require(./example).num;
let inc=require(./example).inc;
console.log(num);// 0
inc();
console.log(num);// 0
例子很简单就不做过多解释了。
AMD
1.概述
AMD全称为Asynchronous Module Definition,是异步加载模块的,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不需要异步加载,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用异步模式,因此浏览器端一般采用AMD规范。
2.基本语法
- 定义模块
AMD规范使用define来定义模块,define函数的定义是define(id?,dependencies?,factory)
。id为字符串类型唯一用来标识模块(可以省略),dependencies是一个数组字面量,用来表示当前定义的模块所依赖的模块(默认后缀名是.js),当没有dependencies时,表示该模块是一个独立模块,不依赖任何模块。factory是一个需要实例化的函数,函数的参数与依赖的模块一一对应,函数需要有返回值,函数的返回值表示当前模块暴露的内容。 - 调用模块
AMD规范使用require来调用模块,require函数的定义是require(dependencies,factory)
。dependencies是一个数组字面量,表示调用的模块,factory需要传入一个回调函数,用来说明模块异步加载完成后执行的操作。
3.配置require对象
require函数本身也是一个对象,它带有一个config函数用来配置require函数的运行参数,config函数接受一个对象作为参数。config的参数对象有以下几个属性:
- baseUrl
baseUrl参数指定本地模块位置的基准目录,即本地模块的路径是相对于哪个目录的。 - paths
paths参数指定各个模块的位置。这个位置可以是服务器上的相对位置,也可以是外部源。可以为每个模块定义多个位置,如果第一个位置加载失败,则加载第二个位置,后面我们将看到具体例子。 - shim
有些库不是AMD兼容的,这时就需要指定shim属性的值。shim是用来帮助require.js加载非AMD规范的库。
4.实战
跟前面一样,还是先给出一个最基本的符合AMD规范模块化的例子。
// module1.js
// 定义一个没有依赖的模块
define(function() {
let msg = '123456';
function get() {
return msg;
}
return { get }; // 暴露模块
})
// module2.js
// 定义一个依赖module1的模块
define(['module1'],function(module1) {
let _msg_ = '7890';
function con() {
return module1.get()+_msg_;// 将两个字符串连接起来
}
return { con }; // 暴露模块
})
// main.js
function(){
require.config({
baseUrl:'./modules/',
paths:{
module1:'module1',
module2:'module2'
},
});
require([module2],function(module2){
console.log(module2.con());
});
}();// 1234567890
// index.html
<!DOCTYPE html>
<html>
<head>
<title>AMD</title>
</head>
<body>
<!-- 引入require.js并指定js主文件的入口 -->
<script data-main="js/main" src="js/libs/require.js"></script>
</body>
</html>
此外,如何在项目中引入第三方库呢?只需要在上述代码中作少许修改:
// module2.js
// 定义一个依赖module1的模块
define(['module1','jquery'],function(module1,$) {
let _msg_ = '7890';
function con() {
return module1.get()+_msg_;// 将两个字符串连接起来
}
$('body');// 使用第三方库
return { con }; // 暴露模块
})
// main.js
function(){
require.config({
baseUrl:'./modules/',
paths:{
module1:'module1',
module2:'module2',
jquery:'xxxx'
},
});
require([module2],function(module2){
console.log(module2.con());
});
}();// 1234567890
在module.js文件中引入第三方库,main.js文件中也要有相应的路径配置。此外,我们还可以给一个库指定多个地址以作备用地址,在前面的地址失效的时候使用备用地址,例:
require.config({
paths: {
jquery: [
// 路径定义为数组,数组的每一项为一个地址定义
'//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
'lib/jquery'
]
}
});
CMD
1.概述
CMD全称是Common Module Definition,它整合了CommonJS和AMD规范的特点,专门用于浏览器端,异步加载模块。该规范明确了模块的书写格式和基本交互规则
2.基本语法
CMD规范通过define关键字来定义模块,基本语法为define(factory)
,factory的取值可以是函数或者任何合法的值(对象、数组、字符串等)。当factory是函数时,表示是该模块的构造函数,这个函数具有三个参数————“require、exports、module”。require
参数是一个方法,它接受模块唯一标识作为参数,用来引入依赖。exports
参数用来暴露模块,module
参数指向当前模块。
3.实战
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CMD Demo</title>
<!--引入Sea.js库-->
<script type="text/javascript" src="../../sea-modules/seajs/seajs/2.2.0/sea.js"></script>
<script type="text/javascript">
seajs.use("../../static/cmd/main");// 使用模块
</script>
</head>
</html>
// module1.js
// factory为对象
define({foo:foo});
// module2.js
// factory为字符串
define(foo:"123456");
// module3.js
// factory为数字
define(foo:666);
上面是factory为普通值时候的例子,下面我们来看一个当factory为函数的时,怎样使用:
// main.js
define(function(require) {
//通过riquire引用模块
var module1=require('./module1');
var module2=require('./module2');
var module3=require('./module3');
console.log(module1.foo);// foo
console.log(module2.foo);// 123456
console.log(module3.foo);// 666
});
// module4.js
// 定义一个没有依赖的模块
define(function(require,exports,module){
function CmdDefine(){
console.log("CMD Demo");
}
CmdDefine.prototype.say=function(){
console.log("CMD Demo!");
}
module.exports=CmdDefine;// 对外发布接口
});
// main.js
define(function(require) {
var CmdDefine=require('./cmdDefine');
var tmp = new CmdDefine();// 创建CmdDefine实例
tmp.say();// 调用say方法
});
例子同样很容易理解,不做过多解释。
4.判断当前页面是否有CMD模块加载器
if(tepyof define === "function" && define.cmd){
// 有Sea.js等CMD模块加载器存在
}
ES6
1.概述
ES6模块规范的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系。而CommonJS和CMD,都只能在运行时确定依赖。
2.基本语法
- 暴露接口
export命令用于规定模块的对外接口,基本语法为export xxx
,暴露的接口可以是对象、函数、基本类型变量。另外可以使用export default xxx
为模块指定默认输出,因为很多时候用户不知道要加载的模块的属性名。 - 调用模块
import命令用于输入其他模块提供的功能,基本语法为import xxx from xxx
,其中第一个xxx为引入的模块的属性名,第二个xxx为模块的位置。如果不理解没关系,后面我们将看到具体的案例。
3.实战
// module1.js
let s='';
function proc(){
s='123456';
return s;
}
export {num,inc};// 暴露接口
// main.js
import {s,proc} from './module1';// 引入依赖
console.log(s);//
console.log(proc);// 123456
需要注意的一点是,在使用CommonJS规范时,输出的是值的拷贝,也就是说输出之后,模块内部的变化不会影响输出。但在ES6中是恰好相反的,ES6规范中输出的是值的引用,也就是说模块内部变化会影响输出。从上例可以看到,第一次输出的s为空字符串,调用proc后s被修改,再次输出后s变为处理后的字符串。下面我们再来看一个例子:
//module2.js
export let num=0;
export function inc(){
num++;
}
//main.js
import {num,inc} from './module2';
console.log(num);// 0
inc();
console.log(num);// 1
我们可以看到,在输出之后内部的变化仍然会反映到输出上。这是因为CommonJS加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而ES6模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成,所以ES6模块是动态引用,不会缓存值,模块里面的变量绑定其所在的模块。
从上面两个例子可以看到,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default
命令,为模块指定默认输出。
// module3.js
export default function(){
console.log('foo');
}
// main.js
import iWant from './module3';
iWant();// foo
在引入具有默认输出的模块时,如果该输出是一个匿名函数,import可以给匿名函数重命名。那么我们如何引入第三方的模块呢?
// 在引入之前要先安装第三方库
// main.js
import $ from 'jquery';// 引入第三方库
$('body').css(xxx);
总结
- CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD、CMD解决方案。
- AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
- CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。但是依赖SPM打包,模块的加载逻辑偏重。
- ES6在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代CommonJS和 CMD规范,成为浏览器和服务器通用的模块化解决方案。