为什么需要模块化呢?
在WEB开发的早期,为了团队协作和代码的维护,很多开发者会将js代码分开写在不同的文件里面,然后通过多个script
标签来加载它们:
html
<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./c.js"></script>
虽然每个代码块处在不同的文件中,但是最终所有JS变量还是会处在同一个全局作用域
下,这时候就需要额外注意由于作用域变量提升
所带来的问题:
html
<script>
// a.js
var num = 1;
setTimeout(() => console.log(num), 1000);
</script>
<script>
// b.js
var num = 2;
</script>
在这个例子中,我们分别加载了两个script标签,两段js都声明了num
变量。 第一段js代码的本意是希望在1s
后打印自己声明的num
变量1,但最终运行结果却打印了第二段js代码中的num
变量的结果2。虽然两段代码写在不同的文件中,但是因为运行时声明变量都在全局下,最终会产生冲突。 同时,如果代码块之间有依赖关系的话,需要额外注意脚本加载的顺序。如果文件依赖顺序有变动,就需要在html
中手动变更加载标签的顺序,这非常麻烦。 要解决这样的问题,就需要将这些js文件模块化
:
- 每个模块都要有自己的
变量作用域
,两个模块之间的内部变量不会参产生冲突; - 不同模块之间保留相互的
导入和导出
的方式方法,模块间能够相互通信。模块的执行和加载遵循一个规范,保证彼此之间的依赖关系;
一、CommonJs
在NodeJs
中,由于只有一个入口文件(启动文件),但是开发一个应用肯定需要多个文件来配合,所以NodeJs
对模块化的需求比浏览器要更大的多。 NodeJs刚刚出来的时候,没有官方的模块化规范,所以它选择使用社区提供的CommonJS作为模块化规范。 在学习CommonJS之前,需要了解模块的导出
和模块的导入
。
模块的导出和导入
导出
首先,模块可以理解为一个JS文件,它实现了某部分功能,并且隐藏自己的内部实现。同时提供了一些接口供其他模块使用。 所以说模块有两个核心要素:隐藏
和 暴露
。 隐藏的是自己内部的实现。
暴露的是希望外界使用的接口。
模块化标准,都应该默认隐藏模块中的内部实现,然后通过一些语法或者api的调用来暴露接口。 暴露接口的过程
就是我们说的模块的导出。
导入
当我们需要使用一个模块的时候,其实使用的是这个模块暴露的部分(也就是导出的部分),隐藏的部分是永远无法使用的。 所以,当我们通过某种语法或者api来使用一个模块
时,这个过程就是模块的导入。
CommonJS规范的内容
使用exports
导出模块,使用require
导入模块。
具体的规范如下:
- 如果一个JS文件中存在 exports 或者 require, 则这个JS文件是一个模块。
- 模块内的全部代码均为隐藏代码,包括全局变量、全局函数,这些全局的内容均不应该对全局变量造成污染。
- 如果一个模块需要暴露一些api提供给外界使用,需要通过
exports
导出,exports是一个空对象
,你可以为这个对象添加任何需要导出的内容。 - 如果一个模块需要导入其他的模块,需要通过
require
实现,它是一个函数,传入模块的路径就可以返回这个模块所导出的所有内容。
所以怎么导出呢?
javascript
// 需要隐藏的内部实现
var count = 0;
function getNumber() {
count++;
return count;
}
// 暴露内容
exports.getNumber = getNumber;
exports.abc = 123;
// export是一个空对象,也可以这么暴露:
exports: {
getNumber: getNumber;
abc: 123
}
// 还可以这么暴露
exports.getNumber = function getNumber() {
count++;
return count;
}
如何导入一个模块呢? 这里我们先建立一个入口文件index.js
, 这个文件将要导入我们刚刚写的 util.js
:
javascript
// require就是一个函数,返回一个模块导出的内容
// NodeJS中导入模块,使用相对路径,并且以./或者../开头
var util = require('./util.js');
console.log(util);
我们在终端执行命令node index.js
后,结果是:
bash
{ getNumber: [Function: getNumber], abc: 123 }
我们可以通过util
这个对象来访问getNumber函数,但是我们不能直接获取到count
这个变量,这是它内部需要隐藏的内部实现。 这种方式可以解决全局变量被污染的问题了。
NodeJS对于CommonJS规范的实现
1、为了保证高效的执行,仅加载必要的模块,NodeJS只有执行到require函数时才会加载并执行模块(会运行模块内部的代码)。
2、为了隐藏模块中的代码,NodeJS执行模块的时候,它会将模块中的代码放进一个立即执行函数中执行,以保证不污染全局变量。
javascript
(function (){
// 模块中的代码
})()
3、为了保证模块的顺利导出,NodeJS做了这些处理:
- 在模块开执行前,会初始化一个值
module.exports = {}
; module.exports
就是模块将要导出的内容;- 为了方便开发者导出内容,又声明了一个变量
exports = module.exports
javascript
(function (){
module.exports = {};
var exports = module.exports;
// module.exports 和 exports指向的是同一个地方
// 模块中的代码......
return module.exports;
})()
既然这样,那我们也可以使用module.exports
或者exports
来暴露一个模块:
javascript
module.exports = {
getNumber: function(){},
abc: 123,
}
// 也可以这么写
module.exports.abc = 123;
//使用exports
exports = {
getNumber: function(){},
abc: 123,
}
注意:模块最后返回的是module.exports, 而不是exports。 为什么这里要提醒呢?不都是指向同一个对象,有什么问题吗? 这里我们来看这么一种情况: 先看util.js
javascript
// 需要隐藏的内部实现
var count = 0;
function getNumber() {
count++;
return count;
}
module.exports = {
getNumber: getNumber,
abc: 123,
}
// 注意这里!
exports.bcd = 456;
再在index.js中打印bcd,看看结果是什么?
javascript
var util = require('./util.js');
console.log(util.bcd);
结果是: undefined
这里是为什么呢? 这里我们给util.js加点代码:
javascript
// 需要隐藏的内部实现
var count = 0;
function getNumber() {
count++;
return count;
}
console.log(module.exports === exports); // true
module.exports = {
getNumber: getNumber,
abc: 123,
}
exports.bcd = 456;
console.log(module.exports === exports); // false
打印结果是:true
和false
所以我们知道修改exports之后,exports和module.exports就不是同一个东西了。但是我们最后暴露模块实际是返回的module.exports
里的东西,而不是exports里的东西,所以在导出的对象里没有bcd。
4、为了避免反复加载同一个模块,NodeJS默认开启了模块缓存,如果模块加载过一次后,会自动使用之前的导出结果。
这里我们还是使用index.js
util.js
b.js
来举个🌰。 它们之间的依赖关系为:
javascript
var util1 = require("./util.js");
var util2 = require("./util.js");
require("./b.js");
javascript
// 需要隐藏的内部实现
var count = 0;
console.log('util模块执行了!')
module.exports = {
abc: 123,
}
javascript
console.log('b模块执行了');
var util = require("./util.js");
console.log(util.abc);
运行index.js,最后结果:
javascript
util模块执行了!
b模块执行了
123
{ abc: 123 }
可以看到util模块在index.js中被require了两次,而且b.js还require了一次,但是util模块里面的代码只执行了一次。
在每一次require一个模块的时候,都要去寻找这个文件,而找文件和读取文件的过程是比较耗时的,因为文件是在磁盘中的,而不是在内存中的。 而且我们还可以证明index.js中的util1
和util2
是一个东西:
javascript
var util1 = require("./util.js");
var util2 = require("./util.js");
console.log(util1 === util2);
// true
require("./b.js");
二、浏览器模块化遇到的难题
CommonJS的工作原理
当我们使用require
来导入一个模块的时候,Node会做这两件事:
- 通过模块的路径找到文件,然后读取这个文件;
- 将文件立面的代码放到一个函数执行环境中去执行,然后将执行后的
module.exports
的值作为require
函数的返回结果;
所以,可以认为 CommonJS是同步的,必须要等到加载完文件并执行完之后才能继续向后执行。每当一个模块require
一个子模块的时候,都会停止当前模块的解析直到子模块读取解析并加载。
当浏览器遇到CommonJS
1、浏览器想要加载js文件,它需要远程从服务器读取,但是网络传输的效率远远低于Node环境中读取本地文件的效率。由于CommonJS是同步的,所以导致性能不好。
2、如果需要读取js文件并把它放进一个函数环境中运行,这需要浏览器厂商来支持,但是浏览器厂商觉得CommonJS不是官方的标准,是社区的标准,所以不愿意支持。
三、新的规范出现了
其实想要浏览器支持上面说的模块化,主要就是能解决上面的那两个问题。
方法就是:
- 远程加载JS时间消耗太大? 那就做成异步,模块加载完成后调用回调函数也可以;
- 模块中的代码需要放到一个函数环境中执行? 那编写的时候我直接放到函数中也可以啊;
有了这种简单有效的思路,后面逐渐出现了AMD
和CMD
规范,有效地解决了浏览器模块化的问题。
AMD规范:
全称是Asynchronous Module Definition
,即异步模块加载机制,require.js
实现了AMD规范。在AMD中,导入和导出模块都必须放在define
函数中。
javascript
define([要依赖的模块列表], function(导入的模块名称列表){
// 模块内部的代码
return 导出的内容
})
我这里简单举个例子,依赖关系:index.js => a.js、 index.js => b.js、 a.js => b.js
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./require.js" data-main="./index.js"></script>
</body>
</html>
这里的data-main
就是指定入口文件:index.js
javascript
define(function() {
// 模块内部的代码
return {
name: 'b模块',
data: 'b模块的数据',
}
});
javascript
define([
'b',
], function(b) {
// 模块内部的代码 (前面模块加载完成之后,才会执行这部分的代码)
console.log(b);
});
再来看a模块也导入b模块,index.js也导入a模块
javascript
define([
'b',
'a'
], function(b,a) {
// 模块内部的代码 (前面模块加载完成后,才会执行这部分的代码)
console.log(b);
console.log(a);
});
使用live-server来运行index.html
,结果:
b模块的内部的代码只执行了一次,说明b模块也有缓存。 当然还有一种写法,直接传入一个函数:
javascript
define(function(require, exports, module) {
// 模块内部的代码
console.log('b模块的内部代码');
module.exports = {
name: 'b模块',
data: 'b模块的数据',
}
})
javascript
define(function(require, exports, module) {
var a = require('a'),
b = require('b');
console.log(a, b);
})
看到参数我们应该能大概明白这几个是什么意思了。最终还是会将module.exports
的内容导出。
CMD规范:
全称是Common Module Definition
,即公共模块定义。玉伯老师开发的sea.js
实现了CMD规范,在CMD中导入和导出模块的代码,都必须放在define函数中。
javascript
define(function(reuqire, exports, module){
// 模块内部的代码
})
咦?这不是AMD里面就有吗,为什么还需要CMD呢? 其实这跟require.js
的发展有关,一开始require.js
只支持这种写法:
javascript
define([要依赖的模块列表], function(导入的模块名称列表){
// 模块内部的代码
return 导出的内容
})
后来出现了CMD规范,sea.js
实现了,然后大受欢迎。后面require.js
也实现了CMD规范,所以站在现在来看require.js
也能在define中传入函数,并且函数提供reuqire
, exports
, module
这三个参数。 如何使用呢?
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./sea.js"></script>
<script>
seajs.use("./index");
</script>
</body>
</html>
javascript
define(function(require, exports, module) {
var a = require('a'),
b = require('b');
// 也可以采用异步的导入方式
var a = require.async('a', function(a) {
console.log(a);
})
var b = require.async('b', function(b) {
console.log(b);
})
console.log(a, b);
})
javascript
define(function(require, exports, module) {
var b = require('b');
console.log('a模块内部的代码');
module.exports = 'a模块的内容';
});
javascript
define(function(require, exports, module) {
// 模块内部的代码
console.log('b模块内部的代码')
module.exports = {
name: 'b模块',
data: 'b模块的数据',
}
})
启动index.html,结果:
AMD和CMD的区别?
1、对于依赖的模块,AMD 是提前执行 ,CMD 是延迟执行。不过 RequireJS 后面也改成可以延迟执行(根据写法不同,处理方式不同);
2、AMD 推崇依赖前置, CMD 推崇依赖就近:
javascript
// AMD
define(['a', 'b'], function(a, b){
a.doSomething();
b.doSomething();
})
define(function(rerquire, exports, module){
var a = require('a');
a.doSomething();
var b = require('b');
b.doSomething();
})
3、它们加载模块都是异步的,但依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同:
-
AMD
在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入require
的回调函数,执行主逻辑。这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,会看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。 -
CMD
加载完某个依赖模块后并不执行,只是下载而已。在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。
四、ES6模块化
在ES6之后,JS有了语言层面的模块化导入导出关键词和语法拍匹配的ESModule
规范。 它的特点就是:
- 使用依赖
预声明
的方式导入模块;
-
- 1、CommonJS是依赖延迟声明 优点:某些场景下可以提高效率; 缺点:无法在一开始确定模块之间的依赖关系;
-
- 2、依赖预声明 优点:在一开始就可以确定模块间依赖关系; 缺点:某些场景下效率较低(后面构建工具会优化);
- 导入导出方式灵活;
- 路径表示规范:所有的模块路径必须以
./
或者../
开头;
模块的引入
这部分不是模块化标准。 目前浏览器使用这种方式来引入一个ES6模块文件:
html
<script src="入口文件" type="module" ><script/>
模块的导入和导出
分为两种:
- 基本导入导出;
- 默认导入导出;
基本导出
基本导出必须有名称,所以导出内容必须跟上声明表达式
或者具名符号
。
javascript
// 导出a,值为1
export var a = 1;
// 不能写成这样:
// var a = 1;
// export a;
// 导出函数
export function fn() {}
// 或者
var age = 18;
var name = 'sheep';
// 将age变量的名称作为导出的名称,age变量的值作为导出的值
export {age, name}
基本导入
因为是依赖预加载
,因此导入任何其他模块,导入代码必须放在所有代码的最前面。
javascript
// import {导入的名称列表} from "模块路径"
import {name, age} from "./a.js"
注意:
- 导入时可以通过关键字
as
对导入的模块进行重新命名;
javascript
import {a as a2} from "./a.js"
- 导入的时候使用的符号是常量,不可以修改;
javascript
import {name, age} from "./a.js"
name = "xxx";
console.log(name, age)
结果:
- 可以使用
*
来导入所有的基本导出,形成一个对象;
javascript
import * as obj from "./a.js"
// 这里的obj是别名,可以自定义
默认导出
每个模块,除了允许有多个基本导出之外,还允许有一个默认导出
,只有一个所以不需要具名。
javascript
export default 默认导出的数据
// 或者
export {默认导出的数据 as default}
javascript
export var a = 1;
export default main = 2;
// 或者
// export default {
// name: "sheep",
// age:20,
// }
// 再或者
// var number = 3;
// export {number as default}
注意:基本导出和默认导入可以同时存在,但默认导出只有一个。
默认导入
javascript
// import 接受的名称 from "模块路径"
import main from "./a.js"
由于默认导入时变量名称时自行定义的,所以没有别名一说。 如果我想同时导入某个模块的默认导出和基本导出,可以这么写:
javascript
// import 接收默认导出的变量, {接收基本导出的变量} from "模块路径"
import main, {a} from "./a.js"
注意: 如果使用*
,会将所有基本导出和默认导出聚合到一个对象中,默认导出会作为属性default存在。 示例:
javascript
export var a = 1;
export var b = 2;
export default {
fn: function() {},
name: 'main',
}
javascript
import * as data from "./a.js"
console.log("默认导出的内容", data.default);
// a模块中的a和b都在data中
console.log("导出的全部内容", data);
使用live server来查看结果:
其他细节
1、尽量导出不可变值
导出内容时,尽量保证导出的内容是不可变的。虽然导入模块后,无法更改导入内容,但是在导入的模块内部却有可能发生更改,很有可能会发生无法预料的事情。
这里注意,导入模块后,无法在导入模块的那个文件里修改模块的内容,浏览器会报错。 但是可以间接地去改变内容:
javascript
export var name = "模块b";
export default function () {
name = "module b";
}
javascript
import method, {name} from "./b.js"
console.log(name);
method();
console.log(name);
通过live server查看结果:
通过这种方式就能改变模块的内容。可以通过const
来声明就可以避免这种情况:
javascript
export const name = "模块b";
export default function () {
name = "module b";
}
2、可以使用无绑定的导入用于执行一些初始化代码
如果只是想执行模块中的一些代码,但又不需要导入它的任何内容,可以使用无绑定的导入。 这里说的绑定就值得是上面index.js
中一开始导入模块的时候的method
、 name
。 示例:
javascript
Array.prototype.print = function() {
console.log(this);
}
javascript
import "./array.js"
var arr = [1, 2, 3];
arr.print();
3、可以使用绑定再导出,来重新导出来自另一个模块的内容
有的时候,我们可能需要用一个模块封装多个模块,然后有选择的将多个模块的内容分别导出,可以使用下面的方式来完成:
javascript
export {绑定的标识符} from "模块路径"
比如这种场景: 我们一开始可能会这么写:
javascript
import {a, b} from "./m1.js"
import m2, {e} from "./m2.js"
export {a, b, e, m2 as default};
export const r = "m模块的r";
但是ES6模块化给我们提供一种类似语法糖的写法(更加方便简洁):
javascript
export {a, b} from "./m1.js"
export {e, default} from "./m2.js"
export r = "m模块的r"
如果我们要把m1模块中的所有内容都导出,还可以使用*
:
javascript
export * from "./m1.js"
4、import {data} from "./moduleA"
这里的括号并不代表获取结果是一个对象,虽然与ES6之后的对象结构赋值语法相似。 错误示例: 这些语法都是错误的,这里不能使用对象默认值,对象 key 为变量这些语法
javascript
import {var1 = 1} from "./moduleA"
import {[test]: a} from "./moduleA"
五、后模块化时代
根据前面的了解,我们知道使用ESModule
明显更符合JS的开发,随着web的发展,任何一个支持JS的环境,最终都将会支持ESModule
的标准。但是,WEB端受限于用户使用的浏览器版本,并不能随时使用JS的最新特性。为了能让新代码也能运行在用户的低版本了浏览器上,社区里也有很多工具,它们能静态将高版本规范的代码编译为低版本的代码,比较熟知的就是babel
。
但是,对于模块化相关的import
和export
关键字,babel
最终会将它编译为包含require
和exports
的CommonJs规范。
这就有了一个新的问题,这样带有模块化关键词的模块,编译折后还是没有办法直接运行在浏览器中,因为浏览器端并不能运行CommonJs的模块。为了能在WEB端直接使用CommonJs规范的模块,除了编译之外,我们还需要一个步骤,就是打包(bundle)
。
所以打包工具比如webpack
/rollup
,编译工具babel
它们之间的区别和作用就是:
- 打包工具主要处理的是JS不同版本间模块化的区别;
- 编译工具主要处理的是JS版本间语义的问题;