为什么需要模块化?
早期的开发没有模块化,会有两个灾难性的问题:即 全局污染 以及 依赖管理混乱。
1. 全局污染:
A 引入 a.js,B 引入 b.js,这些代码最后都是存在于全局作用域里,难保不会出现变量命名冲突的问题。
2. 依赖管理混乱:
js 文件之间存在依赖关系,那么被依赖项必须出现在前面,也就是说要遵守一定的顺序。要是有几十个文件,那么就得先确定好互相之间的依赖关系,然后手动排序,累觉不爱。
早期解决方案:
IIFE
每个 js 文件中都用一个匿名自执行函数来封装数据。
// a.js
(function(){
var num = 1;
function add(){
num++;
}
})()
// b.js
(function(){
var num = 2;
function sub(){
num--;
}
})()
nice,这样子 a.js 和 b.js 都有各自的 num,互不影响了。但是,我在全局作用域下好像拿不到函数里的东西???
IIFE 增强版
让 IIFE 返回一个对象,暴露给全局作用域
// a.js
var moduleA = (function(){
var num = 1;
return {
gain:function(){
return num;
},
add:function(){
num++;
}
}
})()
这样,全局可以通过 moduleA
拿到函数里的变量。不过,要是 b.js 不小心脑袋抽筋,也将 IIFE 返回给一个叫做 moduleA
的变量呢?命名冲突的问题还是没解决。
这之后提出了模块化的概念。
模块化解决方案:
那么,模块化到底需要解决什么问题呢?我们先设想一下可能有以下几点:
- 安全地包装一个模块的代码,避免全局污染
- 唯一标识一个模块
- 优雅地将模块 api 暴露出去
- 方便地使用模块
- …
1.CommonJS
1.1 介绍:
CommonJS 的一个模块就是一个脚本文件,通过执行该文件来加载模块
。CommonJS 规范规定,每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的 exports
属性(即 module.exports
)是对外的接口。加载某个模块,其实是加载该模块的 module.exports
属性。
1.2 导出模块:
Node.js 是 CommonJS 规范的实现。为了方便,Node.js 为每个模块提供一个 exports
变量,指向 module.exports
。这等同在每个模块头部,有一行这样的命令:
var exports = module.exports;
所以,我们有两种导出模块的方式:
// module.js
var num = 1;
function print(){
num++;
return num;
}
// 方式1
module.exports = {
num,
print
}
// 方式2
exports.num = num;
exports.print = print;
1.3 加载模块:
另外,我们也有两种加载模块的方式:
// main.js
// 方式1
var obj = require('./module.js');
console.log(obj.num);
console.log(obj.print());
// 方式2(解构赋值)
var { num,print } = require('./module.js');
console.log(num);
console.log(print());
CommonJS 的特点是:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
2.AMD
CommonJS 是针对服务端的模块化解决方案,为何它不能用于前端呢?因为 CommonJS 是同步而不是异步的,在我们 require 模块的时候,如果迟迟没有返回结果,那么就会阻塞后面代码的执行,甚至会阻止页面的渲染。
所以这时候有了 AMD 规范,即异步模块加载规范。
AMD 与 CommonJS 的主要区别就是异步模块加载 —— 即使 require 的模块还没有获取到,也不会影响后面代码的执行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
RequireJS 实现了这个规范。
当然,后面还出现了 CMD、UMD。
3.ES Module
3.1 介绍:
ES6 在语言规格层面上实现了模块功能,完全可以取代现有的 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
3.2 导出模块
有三种方式可以导出模块:
// module.js
// 方式一(声明的同时导出)
export var num = 1;
export function add(){
num++;
}
// 方式二(统一导出。推荐)
var num = 1;
function add(){
num++;
}
export { num,add }
// 方式三(允许重命名,次数不限)
var num = 1;
function add(){
num++;
}
export {
num as new_num;
add as new_add;
add as newer_add;
}
3.3 加载模块
同样的,加载模块也有多种方式。其中,整体加载会把之前导出的变量和函数挂载在一个对象上。
// main.js
// 方式一:
import { num,add } from './module.js'
// 方式二(允许重命名):
import {
num as new_num;
add as new_add;
} from './module.js'
// 方式三(整体加载):
import * as obj from './module.js'
3.4 export default
export default
其实用得更多。import
在非整体加载的时候要求我们事先知道导出的变量或者函数的名字,但是如果使用 export default
导出,那么后续加载模块的时候,名字可以任取,也就是说,我们并不需要知道原模块中变量或者函数的名字。例如:
// module.js
export default function(){
.....
}
// main.js
import func from './module.js'
此外,要注意两点:
export default
实际上是把后面跟着的东西赋值给default
变量,所以后面不能是变量的声明因为
export default
是指定的默认输出,这意味着一个模块文件中只能有一条export default
语句(当然,可以与export
一起用),也因为这样,import
后面不需要大括号,因为它只可能接受一个项。
CommonJS 与 ES Module 的差异
总结
- CommonJS 输出的是值的拷贝,内部和外部的修改是分开的,不会互相影响;ES Module 输出的是值的引用,内部的修改会影响外部
- CommonJS 是运行时加载,ES Module 是编译时就确定好依赖关系,无法基于逻辑动态决定是否导入导出
- CommonJS 始终导出的都只有 module.exports/exports 对象,而 ES Module 可以导出多个值
- CommonJS 的 this 指向当前模块,ES Module 的 this 指向 undefined
其一
CommonJS 模块输出的是值的拷贝:
也就是说,输出之后,原模块内部该值怎么变化,都不会影响到导出去的那个值,两者在内存中有各自的空间。
关于这点,很多文章会用类似下面的方式去证明:
// module.js
var num = 1;
function add(){
num++;
};
module.exports = { num,add };
// main.js
var obj = require('./module.js');
console(obj.num); // 1
obj.add();
console.log(obj.num); // 1
因为这里是拷贝了 num
,所以 add
操作后只是 module.js
中的 num
加一(词法作用域),main.js
中拷贝得到的 num
不变。
这个证明方法其实有问题。因为 module.exports
对象中的 num
属性本来就有值的拷贝了,此方法并不能证明值的拷贝是由 CommonJS 的底层实现的。而且,把上面代码改为对应的 ES Module 版本(此时本来应该是引用),会发现得到同样的结果,更证明了这一点。详情看:
如何正确证明 CommonJS 模块导出是值的拷贝,而 ES module 是值的引用?
ES Module 输出的是值的引用:
JS 引擎对脚本静态分析的时候,遇到模块加载命令 import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
这意味着,原模块中值的改变会动态映射到 main.js
中
// module.js
var num = 1;
function add(){
num++;
}
export { num.add };
// main.js
import { num,add } from './module.js';
console.log(num); // 1
add();
console.log(num); // 2
注意这个引用是动态变化的。
另外,原模块导出的变量在 main.js
中表现为一个只读常量,也就是说我们不能在 main.js
中对它重新赋值,这会报错:
import { num,obj } from './module.js';
console.log(num); // 1
num++; // TypeError: Assignment to constant variable
console.log(obj); // {.......}
obj.name = "Sam"; // 没毛病
obj = {}; // TypeError: Assignment to constant variable
对于引用类型,可以给它添加属性,但赋值同样是不行的。
其二
运行时加载:
CommonJS 是运行时加载的。也就是说,在 require 时,先执行整个模块(加载里面所有的方法),生成一个对象,然后再从这个对象上面读取实际要用到的方法,这种加载称为“运行时加载”。
编译时加载:
ES Module 是编译时加载的。也就是说,其设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量(只加载需要的方法)。这种加载称为“编译时加载”。
import 有提升现象,因为这是在编译阶段就执行的。
以这段代码为例:
//ES6模块
import { a,b,c } from 'module.js';
//CommonJS 模块
let { a,b,c } = require('module.js');
对于 CommonJS,当 require 模块时,原模块会运行一遍,并返回一个包含所有 api 的对象,并将这个对象缓存起来。此后,无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
对于 ES Module,在编译阶段遇到 import 时,不会像 CommonJS 一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以 ES Module 是动态引用,并且不会缓存值。
参考:
https://zhuanlan.zhihu.com/p/41568986
https://es6.ruanyifeng.com/#docs/module