为什么前端开发需要模块化?
在讨论模块化之前,我们先了解一下以前的代码是怎么组织的:
<body>
<!-- 许多html代码 -->
<script scr='./index.js'></script>
</body>
复制代码
一开始,我们可能会将所有的JS代码写在一个index.js文件里,然后通过script标签引入。后来,随着业务逐渐复杂,index.js文件变得庞大,也变得难以维护。所以我们考虑将代码按照功能模块,拆分到不同的文件里,然后引入多个js文件:
<body>
<script src='./a.js'></script>
<script src='./b.js'></script>
<script src='./c.js'></script>
...
</body>
复制代码
通过这样的拆分,我们各个文件的代码量变少了,看起来确实会更清晰,但是也会带来几个问题:
-
命名冲突和变量污染
// a.js var a = 1; // b.js var b = 2; // index.html <script src="./a.js"></script> <script src="./b.js"></script> <script> console.log(a, b); </script> 复制代码
假设有两个功能模块a和b,分别拆分到a.js和b.js中,上面的代码打印出来的是
1 2
,其实没有什么问题,但如果有一天,一个新接手的程序员,在b.js中又定义了一个变量a。// a.js var a = 1; // b.js var b = 2; var a = 3; 复制代码
这时候,控制台打印的代码变成:
3 2
。由于a和b都是定义到了全局作用域,所用两个模块的变量命名有了冲突。我们可以通过修改变量名来规避,但这是治标不治本的,因为他们没有自己的作用域,所以我们依然可以在b模块中,修改a模块的变量,可能会造成一些bug。 -
资源或模块之间的依赖
如上面的代码片段,在html中添加了很多script标签来一个个引入资源,首先可能会造成很多请求,导致页面卡顿。
更重要的是,如果资源之间有依赖关系,还要按照依赖关系从上到下排序。如果引入了defer或async属性,逻辑将会变得更加复杂,难以维护。
实现模块化
那么我们要如何解决这两个问题呢?
解决命名冲突和变量污染
为了避免命名冲突和变量污染,我们想到为每一个模块创建一个私有的作用域,避免命名冲突,模块外只能访问被暴露的一些变量,避免变量污染。
由于在函数内部可以形成一个局部作用域,所以我们可以将模块的代码包裹到一个函数中,而且考虑到我们往往只需要调用一次这个函数,所以可以使用立即执行函数表达式(IIFE):
// a.js
var moduleA = (function() {
var a = 1;
function foo(){
console.log(a);
}
return {
foo,
};
})();
// b.js
var moduleB = (function() {
var b = 2;
function foo(){
console.log(a, b);
}
var a = 3;
return {
foo,
}
})();
复制代码
<!-- index.html -->
<script src="./a.js"></script>
<script src="./b.js"></script>
<script>
moduleA.foo(); // 1
moduleB.foo(); // 3 2
</script>
复制代码
这个时候,尽管两个module都有变量a和方法foo,但是他们都在各自的作用域里,不会造成命名冲突,而且moduleB也无法修改moduleA的变量,不会造成变量污染。
解决模块间的依赖
我们的一个模块,可以会依赖另外一个模块,所以在html中,我们要注意按顺序引入,比如有这样一个依赖图:
通过拓扑排序,我们的引入顺序可能是:
<script src="./d.js"></script>
<script src="./c.js"></script>
<script src="./b.js"></script>
<script src="./a.js"></script>
复制代码
这时候,如果新添加一个moduleE,那么我们要重新分析依赖,把moduleE的引入放在合适的位置。随着业务的发展,每次添加一个module,都要重新分析的话,不仅笨重,而且给维护也带来了一定的困难。
所以,手动管理依赖终究不是一个好办法。
模块化规范
虽然上面我们讨论了模块化的实现,但还是存在两个问题:
- 模块化实现方式不统一
- 手动维护模块依赖困难
为了解决这两个问题,开发人员们提出了模块化规范,也就是统一定义模块的方法,以及解放手动维护依赖。
目前主流的三种模块化规范分别是:
- CommonJS
- AMD
- CMD
CommonJS
这个规范在Node.js中被广泛使用,每个文件就是一个模块。有四个关键的环境变量:
module
:每个模块内部都有一个这样的变量,表示当前模块exports
:module的一个属性,表示对外暴露的接口global
:表示全局环境(Node)require
:同步加载某个模块的exports属性
// a.js
var a = 1;
var addA = function(value) {
return a + value;
}
module.exports = {
a,
addA
}
// b.js
const { a, addA } = require('./a.js');
console.log(a); // 1
console.log(addA(2)); // 3
复制代码
这个规范中,以同步的方式加载模块。因为在服务端,模块文件都存在本地磁盘,读取速度快,这这样做不会有问题,但如果在浏览器端,由于网络原因,应该使用异步加载,提前编译打包好。
CommonJS模块的加载机制是,加载被输出的值的拷贝,也就是说,一旦这个值输出了,模块内部对这个值的修改,不会影响被输出的值。
// a.js
var a = 1;
var addA = function() {
a++;
}
var getA = function() {
return a;
}
module.exports = {
a,
addA,
getA
}
// b.js
var { a, addA, getA } = require('./a');
console.log(a); // 1
addA();
console.log(a); // 1
console.log(getA()); // 2
复制代码
从上面一段代码可以看到,调用了addA方法,虽然a.js内部的a值已经变成了2,但是这不会影响到b.js模块引入的a值,因因为a是原始类型的值,载入时被缓存起来了。
AMD
一般在浏览器环境下采用,异步加载模块,所有依赖这个模块的语句,都会被定义在一个回调函数中。
主要命令:define(id?, dependency?, factory)
、require(modules, callback)
使用方法:
// 定义没有依赖的模块A
define('moduleA', function(require, exports, module) {
//...
});
// 定义依赖模块A、B、C的模块D
define('moduleD', ['moduleA', 'moduleB', 'moduleC'], function(a, b, c) {
// 在最前面声明并初始化了依赖的所有模块
if(false){
// 即便没用到b,但b还是初始化且执行了
b.dosomething();
}
// 通过return方法暴露接口
return {
...
}
});
// 加载模块
require(['moduleA'], function(a) {
// ...
});
复制代码
实现这个规范,需要模块加载器require.js,所以引入模块之前,我们要先引入require.js文件,新建main.js,作为入口文件,也可以使用require.config()定义模块依赖的配置。
<script src="./require.js"></script>
<script src="./main.js"></script>
复制代码
// main.js
require(['a'], function(a) {
a.say(); // b a
});
// a.js
define('a', ['b'], function(b) {
function say() {
b.say();
console.log('a');
}
return {
say
}
});
// b.js
define('b', function(require, exports, module){
function say() {
console.log('b');
}
exports.say = say;
})
复制代码
CMD
另一种js模块化方案,与AMD类似。AMD推崇依赖前置,提前执行。而CMD推崇依赖就近、延迟执行。在使用某个模块时,需要显式声明。
define(['a', 'b', 'c'], function(a, b, c) {
var a = require('./a'); // 使用时需声明
a.doSomething();
})
复制代码
要使用这个规范,需要引入模块加载器SeaJS,用法类似requireJS,只是需要在回调函数里显式的使用require引入模块,在这里暂时不赘述。
随着时代的发展,Web相关的标准不断更新,ES6也引入了新的模块规范,requireJS和SeaJS虽然能用,但是也过时了,人们对它们不再像从前一样依赖。
ES6 module
无论是CommonJS还是AMD,都是在运行的时候,才能确定模块的依赖关系和输入输出,而ES6的模块设计,则是追求尽量静态化。在模块中你使用 import 和 export 关键字来导入或导出模块中的东西。
ES6 module有几个特点:
- 自动开启严格模式
- 一个 JS 文件就代表一个 JS 模块
- 每个模块就是一个单例,只会加载一次
和CommonJS相比:
-
commonJS输出的是值的拷贝,ES6输出的是值的引用
-
commonJS是运行时加载,ES6是编译时输出接口。commonJS模块就是对象,在输入时加载整个模块,再从这个对象上读方法。而ES6允许import指定加载某个输出值。
因为CommonJS 加载的是一个对象(即
module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
// a.js
export var a = 1;
a = 2;
// main.js
import { a } from './a';
console.log(a); // 2
复制代码
总结
为了解决命名冲突、变量污染以及模块依赖管理的问题,前端引入了模块化的规范。
其中,CommonJS规范是同步加载,适合服务器端使用。
在浏览器端,我们要使用异步的模块化规范,早期提出了AMD和CMD规范.
随着时代的发展,ES6 module参考CommonJS和AMD,标准化了模块的加载和解析方式,实现起来也更加简单,提供了更加简洁的语法,成为了浏览器和服务器端通用的一种规范。
参考资料: