前言
很久之前,js只是负责比较简单的交互,代码量很少,所以所有功能代码都混在一起,但是随着前端技术的发展,js能做的事情也越来越多,像node已经是跑在服务器上的js了。如果js不能模块化,所有的代码都写到一个文件里,必然导致单个文件代码量大,业务功能模块逻辑不够清晰,难以维护。基于以上历史原因和发展需要,js模块化概念顺其自然的产生了。
概念
将文件(复杂的程序)按照一定的语法,遵循一定的规范将它拆分为几个独立的文件,这些文件应该具有相互独立和功能逻辑单一的特性,对外暴露数据或接口,在需要的时候在进行导入或引用
模块化的发展历程
1、全局函数
将不同的单一功能封装成不同的全局函数,然后在对这些功能函数进行组合,实现相应的业务逻辑。但是这样的方式会存在以下一些问题。
- 污染全局命名变量
- 容易引起命名冲突导致数据不够安全(数据被不知的情况下修改了)
- 模块之间看不出有什么直接的关系
function module1(){}
function module2(){}
function add(){
module1()
module2()
}
这里有个值得借鉴的地方,就是对函数的抽离和封装成功能单一。这在平常开发的时候也是很实用的,可以让函数更加的纯粹,只负责单一的事情,这样就比较好维护和扩展,是符合单一原则的。
2、命名空间简单的对象封装
将每一个模块封装成一个简单的对象,方法函数和变量都封装为这个空间对象的属性,以对象为一个整体,减少了全局变量的污染和命名冲突,但是数据安全并没有解决,外部可以直接修改对象内容,因为对象的属性可以重写,导致一些数据被外部意外的修改了。
let waterModule = {
data:'water',
log(){
console.log(`log() ${this.data}`)
}
go(){
console.log(`go() ${this.data}`)
}
}
waterModule.data = 'newWater'
waterModule.log()
这在一定程度上改变全局函数方式的一些问题,但是这样会暴露所有模块成员,内部状态可以被外部改写。
3、 IIFE匿名函数自调用(闭包)
- 数据是私用的,外部只能通过暴露的方法操作
- 将数据和行为封装到一个函数内部,通过window添加属性来向外暴露接口
- 如果当前这个模块依赖另一个模块怎么办
// index.html文件
<script type="text/javascript" src="waterModule.js"></script>
<script type="text/javascript">
waterModule.logData1()
waterModule.logData2()
console.log(waterModule.data) //undefined 不能访问模块内部数据
waterModule.data = 'xxxx' //不是修改的模块内部的data
waterModule.logData1() //没有改变
</script>
// waterModule.js文件
(function(window) {
let data = 'water'
//操作数据的函数
function logData1() {
//用于暴露有函数
console.log(`logData1() ${data}`)
}
function logData2() {
//用于暴露有函数
console.log(`logData2() ${data}`)
otherFun() //内部调用
}
function otherFunc() {
//内部私有的函数外部无法调用
console.log('otherFunc()')
}
//暴露行为
window.waterModule = { logData1, logData2 }
})(window)
这个封装方式,是内部数据的做到私有和独立。修改内部的数据只能通过调用暴露的方法才能修改,不然直接修改或者添加的数据并不是同一个数据比如:
(function (window) {
var data = "water";
function showData() {
console.log(`data is ${data}`);
}
function updateData() {
data = "newWater";
console.log(`data is ${data} `);
}
window.waterModule = { showData, updateData };
})(window);
只能通过updateData方法来修改内部的变量值,这就保证了数据的安全,不会被外部调用方法的时候无意间修改的变量的值
3、IIFE增强引入依赖
// module.js文件
(function(window, $) {
let data = 'water'
//操作数据的函数
function logData1() {
//用于暴露有函数
console.log(`logData1 ${data}`)
$('body').css('height', '200px')
}
function logData2() {
//用于暴露有函数
console.log(`logData2() ${data}`)
otherFunc() //内部调用
}
function otherFunc() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.waterModule = { logData1, logData2 }
})(window, jQuery)
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="waterModule.js"></script>
<script type="text/javascript">
waterModule.logData1()
</script>
通过jQuery的方法将页面dom的属性,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显,这个模式是现代模块化实现的基石。
这样的模块化有如下优点:
- 避免命名冲突和减少命名空间污染
- 更好的分离和按需加载
- 高复用
- 高可维护
但是同时也暴露出一些问题
-
http请求变得很多,一个功能需要依赖多少个模块就会发送多少个http请求来后去模块,导致http请求过多。基于性能考虑方面,过多的http请求肯定是不行的,因为http的请求数是有限制的
-
依赖模糊,不知道他们的具体依赖关系是什么,很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。进而导致代码执行报错,比如需要使用的模块加载滞后,会导致暴露的方法找不见,代码报错。
-
以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。
而这些问题可以通过接下来介绍的模块化规范来解决。
其实jQuery
库也是如此方式实现的,jQuery
的做法就是使用了一个匿名函数形成一个闭包,然后自执行,所有逻辑都在这个闭包中完成,这样不会污染全局变量,也无法在其他地方访问闭包内的变量。最后将 jQuery
对象进行暴露,这样在外部就可以通过 jQuery
或者 $
访问闭包内的其他变量了。
代码片段如下:
(function (window, undefined) {
//...
if (typeof window === "object" && typeof window.document === "object") {
window.jQuery = window.$ = jQuery;
}
})(window);
很多人最开始不能理解为什么自执行函数要传入 window
,主要有两个原因:
- 使
window
全局变量变成局部变量,当内部代码访问window
对象时,不用顺着作用域链逐级查找,可以更快的访问window
;- 为了压缩代码时更好的优化;
另外传入 undefined 一部分原因是因为压缩优化,另一部分是由于一些低版本浏览器的兼容需要。
此时,模块化已经初具规模,已经可以实现一些基础功能。事实上,这就是现代模块化方案的基石。
下面介绍开发中最流行的CJS、 AMD、ES6,、CMD、UMD规范。
CJS(CommonJS)
概念
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块加载的顺序,按照其在代码中引入的顺序。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
module.exports
属性输出的是值的拷贝,一旦输出操作完成,模块内发生的任何变化不会影响到已输出的值。module.exports
和exports
的用法以及区别。
基本语法
- 暴露模块:
module.exports = value
或exports.key = value
- 引入模块:
require(mod)
,如果是第三方模块,mod为模块名;如果是自定义模块,mod为模块文件路径
cjs规定每个模块内部。module变量代表当前模块。这个变量是一个对象,它的exports属性(module.exports)是对外的接口,加载某个模块,其实就是加载该模块的module.exports属性
var x =5
var sum = function(value){
return value + x
}
module.exports.x = x
module.exports.sum = sum
上面代码通过module.exports输出变量x和函数sum
var example = require('./example.js')
console.log(example.x)
console.log(example.sum(1))
require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。
加载模式
CommonJS规范加载模块是同步的,在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。也就是说,只有加载完成,才能执行后面的操作。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,读取非常快,所以这样做不会有问题。
模块的加载机制
CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。这点与ES6模块化有重大差异(下文会介绍),请看下面这个例子:
// lib.js
var a = 1;
function funcAdd() {
a++;
}
module.exports = {
a: a, // a
funcAdd: funcAdd, // funcAdd
};
上面代码输出内部变量a和改写这个变量的内部方法funcAdd。
// main.js
var a = require('./lib').a;
var funcAdd = require('./lib').funcAdd;
console.log(a); // 1
funcAdd();
console.log(a); // 1
上面代码说明,a输出以后,lib.js模块内部的变化就影响不到a了。这是因为a是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
module对象
Node内部提供一个Module构建函数。所有模块都是Module的实例。每个模块内部,都有一个module
对象,代表当前模块。它有以下属性。
module.id
模块的识别符,通常是带有绝对路径的模块文件名。-module.filename
模块的文件名,带有绝对路径。module.loaded
返回一个布尔值,表示模块是否已经完成加载。module.parent
返回一个对象,表示调用该模块的模块。module.children
返回一个数组,表示该模块要用到的其他模块。module.exports
表示模块对外输出的值。
如果在命令行下调用某个模块,比如node cli.js,那么module.parent就是null。如果是在脚本之中调用,比如require('./cli.js'),那么module.parent就是调用它的模块。利用这一点,可以判断当前模块是否为入口脚本。
module.exports和exports 详解
- module.exports:
module.exports
属性表示当前模块对外输出的接口,当其他文件加载该模块,实际上就是读取module.exports
这个属性; - exports
node 为每一个模块提供了一个
exports
对象 ,这个exports
对象的引用指向module.exports
。这相当于隐式的声明var exports = module.exports;
。 这样,在对外输出时,可以在这个变量上添加属性方法。 例如:exports.test = function () { // ... };
注意:不能把exports
直接指向一个值(exports = xxx
方式赋值),这样会改变exports
的引用地址,相当于切断了exports
和module.exports
的关系。
总结下 module.exports 和 exports 的区别就是:
exports = module.exports = {}
,exports
是module.exports
的一个引用require
引用模块后,返回给调用者的是module.exports
而不是exports
; 3.exports.xxx
的方式更新属性,相当于修改了module.exports
,那么该属性对调用模块可见;exprots = xxx
的方式相当于给exports
重新赋值,改变引用,失去了之前的module.exports
引用,该属性对调用模块不可见;
如果你分不清,那么就使用 module.exports
。
AMD
概念
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要来着早。
基本语法
定义模块
// 语法
define([], function () {
// 模块可以直接返回函数,也可返回对象
return {
fn() {
// ...
},
};
});
// 例子
define(['mod'], function(mod){
function func(){
mod.log('water');
}
return {
func
};
});
define函数是定义模块的,但是可以看到,如果这个模块依赖其他模块,那么就需要把依赖的模块放在第一个参数数组内,因为是数组所以可以依赖多个模块,在回调函数中进行使用。
引用使用模块
// 语法
require([module], callback);
// 例子
require(['jquery', 'math'],function($, math){
var sum = math.add(1,2);
$("#sum").html(sum);
});
AMD规范也采用require方法加载模块,但是不同于CJS规范,它要求两个参数:第一个参数就是要加载的模块的数组集合,第二个参数就是加载成功后的回调函数。
简单看下require.js在全局定义了define和require,并且在最外层包裹的是一个IIFE函数,将global和settimeout传入其中
var requirejs,require,define;
(function(){
// ...
}(this,(typeof setTimeout === 'undefined'?undefined:setTimeout)))
AMD模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。AMD模式可以用于浏览器环境,并且允许非同步加载模块,也可以根据需要动态加载模块。
CMD
概念
CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。而且CMD是综合了CJS和AMD的特点,可以同步也可以异步。AMD的实现者require.js在申明依赖的模块时,会在第一时间加载并执行模块内的代码。
基本语法
定义模块
//定义没有依赖的模块
define(function(require, exports, module){
exports.key = value
module.exports = value
})
//定义有依赖的模块
define(function(require, exports, module){
//引入依赖模块(同步)
var module2 = require('./module2')
//引入依赖模块(异步)
require.async('./module3', function (m3) {
})
//暴露模块
exports.key = value
})
引入使用模块
define(function (require) {
var m1 = require('./module1')
var m4 = require('./module4')
m1.func()
m4.func()
})
CMD的代表就是sea.js
ES6
概念
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。有几个需要注意的点
- CJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CJS 模块是运行时加载,ES6 模块是编译时输出接口,静态化,编译时就确定模块之间的关系,每个模块的输入和输出变量也是确定的。
- ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
基本语法
定义模块
//module1.js文件
// 分别暴露
export function func1() {
console.log('func1')
}
export function func2() {
console.log('func2')
}
//module2.js文件
// 统一暴露
function func3() {
console.log('func3()')
}
function func4() {
console.log('func4()')
}
export { func3, func4 }
//module3.js文件
// 默认导出 可以暴露任意数据类项,暴露什么数据,接收到就是什么数据
export default () => {
console.log('默认导出')
}
引入使用模块
import { func1, func2 } from './module1'
import { func3, func4 } from './module2'
import module3 from './module3'
func1()
func2()
func3()
func4()
module3()
这里就简单写一下基本用法,还有一些其他的用法可以去看下es6的文档,写的比较详细
UMD
概念
就是一种通用模块定义规范,可以通过运行编译时让同一个代码模块在使用CJS、CMD、AMD的项目中运行。这样就使得js包运行在浏览器端、服务端等,兼容性更强,写法上更加统一一致。
基本语法
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD 规范
define(["b"], factory);
} else if (typeof module === "object" && module.exports) {
// 类 Node 环境,并不支持完全严格的 CommonJS 规范
// 但是属于 CommonJS-like 环境,支持 module.exports 用法
module.exports = factory(require("b"));
} else {
// 浏览器环境
root.returnExports = factory(root.b);
}
})(this, function (b) {
// 返回值作为 export 内容
return {};
});
小结
-
CJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
-
AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
-
CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。
-
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。