为什么要使用模块
模块化可以使你的代码低耦合,功能模块直接不相互影响。模块化有这几个优点:
- 可维护性
根据定义,每个模块都是独立的。良好设计的模块会尽量与外部的代码撇清关系,以便于独立对其进行改进和维护。维护一个独立的模块比起一团凌乱的代码来说要轻松很多。 - 命名空间
在JavaScript中,最高级别的函数外定义的变量都是全局变量(这意味着所有人都可以访问到它们)。也正因如此,当一些无关的代码碰巧使用到同名变量的时候,我们就会遇到“命名空间污染”的问题。 - 可复用性
现实来讲,在日常工作中我们经常会复制自己之前写过的代码到新项目中。
JavaScript作为一门动态语言,无法做到在一个JS文件中使用import这样的语法引用别的JS文件,正确的姿势是直接在其中调用别的JS下的方法来使用。能否调用成功,完全取决于有没有正确的引用其他的JS文件,这显然不利于代码的维护,于是就有了初代的模块化概念。
1 | <html> |
初代模块化的形成过程
上面的问题在于index.js无法对add方法的来源作区分,缺乏命名空间的概念
尝试的解决方式是,先把函数放到一个对象中,这样我们可以暴露一个对象,让使用者调用这个对象的多个方法:
1 | //math.js |
这样做之后index.js指定了一个丐版命名空间math。而带来的问题就是其中的私有属性也会暴露出去,而全局作用域下它是可以被修改的。于是诞生了初代的模块化。
初代模块化的构想是利用闭包的原理,将函数放在一个对象中,其中包含的公有方法可以访问对象的私有属性和方法,然后暴露出全局对象使得全局作用域下都可以使用这个对象上的公有方法,同时可以很好地保护私有属性不受侵犯。
使用独立的对象接口:1
2
3
4
5
6
7
8
9
10
11// math.js
var math = (function() {
var base = 0;
return {
add: function(a, b) {
return a + b + base;
},
};
})();
math.add(2,9)//11
而关于IIFE的例子中,还有比如Jquery的全局注入方式。预先声明全局变量,使代码更加清晰可读。1
2
3(function(undefined,$,document,window){
//your code...
})(undefined,jQuery,document,window);
关于模块化的方案,其中一个重要概念是命名空间,我们不希望math是全局的对象,它应该是按需导入的,这样一来即使多个文件暴露同名的对象也不会影响使用。
例如node.js的方案,需要暴露的模块定义自己的export内容,然后调用方使用require方法。所有的exports内容放在了一个中间层储存,这是唯一的全局变量。
1 | // global.js |
然后在math.js中暴露对象:1
2
3
4
5
6
7
8
9var math = (function() {
var base = 0;
return {
add: function(a, b) {
return a + b + base;
},
};
})();
module.exports.math = math;
使用这个方法的index.js:1
2
3
4
5
6var math = module.exports.math;
function onPress() {
var p = document.getElementById('hello');
// math
p.innerHTML = math.add(1, 2);
}
现有的模块化
以上出现的简单的模块化有一些问题,例如index.js必须依赖于math.js执行,因为只有math.js执行完才会向全局的module.exports注册自己。这需要developer手动管理js文件加载顺序,随着项目的体积增加,依赖的维护成本会越来越高
第二点是JS单线程的原因,浏览器加载JS时会停止GUI线程,因此还需要JS文件能够异步按需加载。
最后的一个还是同样的,并没有解决命名空间的问题,相同的导出仍然会替换掉以前的内容,解决方案是维护一个“文件路径 <-->
导出内容”的表,并且根据文件路径加载。
针对以上的三个问题,出现了很多套的模块化解决方案。比较知名的规范有CommonJS、AMD、CMD,实现方式有Node.js、Require.js、Sea.js。
CommonJS
最早的规范是CommonJS,Node.js使用了这一规范,与初代的模块化方式类似,同步加载JS脚本,这在服务器端是可以的,文件都在磁盘上,但是在客户端会造成假死的状态,长时间的等待影响用户体验。所以CommonJS规范是无法直接在浏览器中使用
AMD
浏览器端的模块管理工具Require.js的方法是异步加载,通过Webworker的importScripts(url);函数加载JS脚本,然后执行当初注册的回调函数。写法是:1
2
3
4
5require(['myModule1','myModule2'],function(m1,m2){
//主回调逻辑
m1.printName();
m2.printName();
})
这两个模块是异步加载,所以先后顺序并不care,但是肯定是在主回调逻辑执行前加载完成的。这样的写法也叫依赖前置,在写主逻辑之前必须指定所有的依赖,同时这些依赖也会立刻被异步加载。由Require.js引申出来的规范被称为AMD(Asynchronous Module Definition)。
CMD
另一种模块管理工具是Sea.js,它的写法是:1
2
3
4
5
6
7
8define(function(require, exports,module){
var foo = require('foo'); // 同步
foo.add(1, 2);
...
require.async('math', function(math) { // 异步
math.add(1, 2);
});
});
Sea.js 也被称为就近加载,从它的写法上可以很明显的看到和 Require.js 的不同。我们可以在需要用到依赖的时候才申明。
Sea.js 遇到依赖后只会去下载 JS 文件,并不会执行,而是等到所有被依赖的 JS 脚本都下载完以后,才从头开始执行主逻辑。因此被依赖模块的执行顺序和书写顺序完全一致。
由 Sea.js 引申出来的规范被称为 CMD(Common Module Definition)。
ES6 模块化
在 ES6 中,使用 export 关键字来导出模块,使用 import 关键字引用模块。需要说明的是,ES 6 的这套标准和目前的标准没有直接关系,目前也很少有 JS 引擎能直接支持。因此 Babel 的做法实际上是将不被支持的 import 翻译成目前已被支持的 require。
尽管目前使用 import 和 require 的区别不大(本质上是一回事),但依然强烈推荐使用 import 关键字,因为一旦 JS 引擎能够解析 ES 6 的 import 关键字,整个实现方式就会和目前发生比较大的变化。如果目前就开始使用 import 关键字,将来代码的改动会非常小。