什么是模块
将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起
块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信
模块化的进化过程
全局function模式 : 将不同的功能封装成不同的全局函数
编码: 将不同的功能封装成不同的全局函数。
问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系。
function m1(){
//...
}
function m2(){
//...
}
namespace模式 : 简单对象封装
作用: 减少了全局变量,解决命名冲突。
问题: 数据不安全(外部可以直接修改模块内部的数据)。
let myModule = {
data: 'www.baidu.com',
foo() {
console.log(`foo() ${this.data}`)
},
bar() {
console.log(`bar() ${this.data}`)
}
}
myModule.data = 'other data' //能直接修改模块内部的数据
myModule.foo() // foo() other data
这样的写法会暴露所有模块成员,内部状态可以被外部改写。
IIFE模式:匿名函数自调用(闭包)
作用: 数据是私有的, 外部只能通过暴露的方法操作
编码: 将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口
问题: 如果当前这个模块依赖另一个模块怎么办?
// index.html文件
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
myModule.bar()
console.log(myModule.data) //undefined 不能访问模块内部数据
myModule.data = 'xxxx' //不是修改的模块内部的data
myModule.foo() //没有改变
</script>
// module.js文件
(function(window) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
//用于暴露有函数
console.log(`foo() ${data}`)
}
function bar() {
//用于暴露有函数
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar } //ES6写法
})(window)
IIFE模式增强 : 引入依赖
这就是现代模块实现的基石。
// module.js文件
(function(window, $) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
//用于暴露有函数
console.log(`foo() ${data}`)
$('body').css('background', 'red')
}
function bar() {
//用于暴露有函数
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar }
})(window, jQuery)
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
</script>
上例子把jQuery库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。
模块化的好处
避免命名冲突(减少命名空间污染)
更好的分离, 按需加载
更高复用性
高可维护性
引入多个<script>后出现出现问题
- 请求过多
首先我们要依赖多个模块,那样就会发送多个请求,导致请求过多 - 依赖模糊
我们不知道他们的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。 - 难以维护
以上三种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。
模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。而这些问题可以通过模块化规范来解决。
模块化规范
根据平台划分
平台 | 规范 | 特性 |
---|---|---|
浏览器 | AMD、CMD | 存在网络瓶颈,使用异步加载 |
非浏览器 | CommonJS | 直接操作 IO,同步加载 |
根据同步异步划分
特性 | 规范 |
---|---|
同步加载 | CommonJS |
异步加载 | AMD、CMD |
AMD、CMD两大规范
规范 | 约束条件 | 代表作 |
---|---|---|
AMD | 依赖前置 | requirejs |
CMD | 就近依赖 | seajs |
我们用一个例子,来讲清楚这两个规范之间最大的差异:依赖前置和就近依赖。
AMD:
// hello.js
define(function() {
console.log('hello init');
return {
getMessage: function() {
return 'hello';
}
};
});
// world.js
define(function() {
console.log('world init');
});
// main
define(['./hello.js', './world.js'], function(hello) {
return {
sayHello: function() {
console.log(hello.getMessage());
}
};
});
// 输出
// hello init
// world init
CMD:
// hello.js
define(function(require, exports) {
console.log('hello init');
exports.getMessage = function() {
return 'hello';
};
});
// world.js
define(function(require, exports) {
console.log('world init');
exports.getMessage = function() {
return 'world';
};
});
// main
define(function(require) {
var message;
if (true) {
message = require('./hello').getMessage();
} else {
message = require('./world').getMessage();
}
});
// 输出
// hello init
结论:CMD的输出结果中,没有打印"world init"
。但是,需要注意的是,CMD没有打印"world init"
并是不world.js
文件没有加载。AMD与CMD都是在页面初始化时加载完成所有模块,唯一的区别就是就近依赖是当模块被require
时才会触发执行。
CommonJS
概述
CommonJS定义一个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。在node.js的实现中,也给每个文件赋予了一个module
对象,这个对象包括了描述当前模块的所有信息,我们尝试打印module
对象。
// index.js
console.log(module);
// 输出
{
id: '/Users/x/Documents/code/demo/index.js',
exports: {},
parent: { module }, // 调用该模块的模块,可以根据该属性查找调用链
filename: '/Users/x/Documents/code/demo/index.js',
loaded: false,
children: [...],
paths: [...]
}
也就是说,在CommonJS里面,模块是用对象来表示。我们通过“循环加载”的例子进行来加深了解。
// 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
console.log('index.js', require('./a.js').x);
// 输出
b.js a1
a.js b2
index.js a2
我们的理论依据是模块对象,根据该依据我们进行如下分析。
1、 a.js准备加载,在内存中生成module对象moduleA
2、 a.js执行exports.x = 'a1'; 在moduleA的exports属性中添加x
3、 a.js执行console.log('a.js', require('./b.js').x); 检测到require关键字,开始加载b.js,a.js执行暂停
4、 b.js准备加载,在内存中生成module对象moduleB
5、 b.js执行exports.x = 'b1'; 在moduleB的exports属性中添加x
6、 b.js执行console.log('b.js', require('./a.js').x); 检测到require关键字,开始加载a.js,b.js执行暂停
7、 检测到内存中存在a.js的module对象moduleA,于是可以将第6步看成console.log('b.js', moduleA.x);
在第二步中moduleA.x赋值为a1,于是输出b.js, a1
8、 b.js继续执行,exports.x = 'b2',改写moduleBexports的x属性
9、 b.js执行完成,回到a.js,此时同理可以将第3步看成console.log('a.js', modulerB.x); 输出了a.js, b2
10、 a.js继续执行,改写exports.x = 'a2'
11、 输出index.js a2
例子里面还出现了一个保留字exports
。其实exports
是指向module.exports
的一个引用。举个例子可以说明他们两个之间的关系。
const myFuns = { a: 1 };
let moduleExports = myFuns;
let myExports = moduleExports;
// moduleExports 重新指向
moduleExports = { b: 2 };
console.log(myExports);
// 输出 {a : 1}
// 也就是说在module.exports被重新复制时,exports与它的关系就gg了。解决方法就是重新指向
myExports = modulerExports;
console.log(myExports); // 输出 { b: 2 }
特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接* * 读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
基本语法
- 暴露模块:
module.exports = value
或exports.xxx = value
- 引入模块:
require(xxx)
,如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
此处我们有个疑问:CommonJS暴露的模块到底是什么? CommonJS规范规定,每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的exports
属性(即module.exports
)是对外的接口。加载某个模块,其实是加载该模块的module.exports
属性。
// example.js
var x = 5;
var addX = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
上面代码通过module.exports
输出变量x
和函数addX
。
var example = require('./example.js');//如果参数字符串以“./”开头,则表示加载的是一个位于相对路径
console.log(example.x); // 5
console.log(example.addX(1)); // 6
require
命令用于加载模块文件。require
命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports
对象。如果没有发现指定模块,会报错。
模块的加载机制
CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。这点与ES6模块化有重大差异,请看下面这个例子:
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
上面代码输出内部变量counter
和改写这个变量的内部方法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
了。这是因为counter
是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
ES6 module
ES6模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS和AMD模块,都只能在运行时确定这些东西。比如,CommonJS模块就是对象,输入时必须查找对象属性。
ES6模块化语法
export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。
/** 定义模块 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);
}
如上例所示,使用import
命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default
命令,为模块指定默认输出。
// export-default.js
export default function () {
console.log('foo');
}
// import-default.js
import customName from './export-default';
customName(); // 'foo'
模块默认输出, 其他模块加载该模块时,import
命令可以为该匿名函数指定任意名字。
我们从保留字对比下ES6和CommonJS。
保留字 | CommonJS | ES6 |
---|---|---|
require | 支持 | 支持 |
export / import | 不支持 | 支持 |
exports / module.exports | 支持 | 不支持 |
除了require
两个都可以用之外,其他实际上还是有明显差别的。那么问题来了,既然require
两个都可以用,那这两个在require
使用上,有差异吗?
我们先对比下ES6 module和CommonJS之间的差异。
模块输出 | 加载方式 | |
---|---|---|
CommonJS | 值拷贝 | 对象 |
ES6 | 引用(符号链接) | 静态解析 |
我们先通过例子来介绍一下值拷贝和引用的区别。
// 值拷贝 vs 引用
// CommonJS
let a = 1;
exports.a = a;
exports.add = () => {
a++;
};
const { add, a } = require('./a.js');
add();
console.log(a);
// 1
// ES6
export const a = 1;
export const add = () => { a++;
};
import { a, add } from './a.js';
add();
console.log(a);
// 2
// 显而易见CommonJS和ES6之间,值拷贝和引用的区别吧。
静态解析,什么是的静态解析呢?区别于CommonJS的模块实现,ES6的模块并不是一个对象,而只是代码集合。也就是说,ES6不需要和CommonJS一样,需要把整个文件加载进去,形成一个对象之后,才能知道自己有什么,而是在编写代码的过程中,代码是什么,它就是什么。
AMD
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是非同步加载模块,允许指定回调函数。
AMD规范基本语法
定义暴露模块:
//定义没有依赖的模块
define(function(){
return 模块
})
//定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
return 模块
})
引入使用模块:
require(['module1', 'module2'], function(m1, m2){
使用m1/m2
})
CMD
CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。在Sea.js中,所有JavaScript模块都遵循CMD模块定义规范。
CMD规范基本语法
定义暴露模块:
//定义没有依赖的模块
define(function(require, exports, module){
exports.xxx = value
module.exports = value
})
//定义有依赖的模块
define(function(require, exports, module){
//引入依赖模块(同步)
var module2 = require('./module2')
//引入依赖模块(异步)
require.async('./module3', function (m3) {
})
//暴露模块
exports.xxx = value
})
引入使用模块:
define(function (require) {
var m1 = require('./module1')
var m4 = require('./module4')
m1.show()
m4.show()
})
UMD
UMD = AMD + CommonJS
常用的场景就是当你封装的模块需要适配不同平台(浏览器、node.js),例如你写了一个基于Date对象二次封装的,对于时间的处理工具类,你想推广给负责前端页面开发的A同学和后台Node.js开发的B同学使用,你就需要考虑你封装的模块,既能适配Node.js的CommonJS协议,也能适配前端同学使用的AMD协议。
工具时代
webpack
webpack兴起之后,什么 AMD、CMD、CommonJS、UMD,似乎都变得不重要了。
webpack在定义模块上,可以支持CommonJS、AMD和ES6的模块声明方式,换句话说,就是你的模块如果是使用CommonJS、AMD或ES6的语法写的,webpack都支持!我们看下例子:
//say-amd.js
define(function() {
'use strict';
return {
sayHello: () => {
console.log('say hello by AMD');
}
};
});
//say-commonjs.js
exports.sayHello = () => {
console.log('say hello by commonjs');
};
//say-es6.js
export const sayHello = () => {
console.log('say hello in es6');
};
//main
import { sayHello as sayInAMD } from './say-amd';
import { sayHello as sayInCommonJS } from './say-commonjs';
import { sayHello as sayInES6 } from './say-es6';
sayInAMD();
sayInCommonJS();
sayInES6();
不仅如此,webpack识别了你的模块之后,可以将其打包成UMD、AMD等等规范的模块重新输出。例如上文提及到的你需要把Date模块封装成UMD格式。只需要在webpack的output
中添加libraryTarget: 'UMD'
即可。