Js模块化
# 模块化
# CommonJS
Node.js 就是⼀个基于 V8 引擎,事件驱动 I/O 的服务端 JS 运⾏环境,在 2009 年刚推出时,它就实现了 ⼀套名为 CommonJS 的模块化规范。
在 CommonJS 规范⾥,每个 JS ⽂件就是⼀个 模块 (module) ,每个模块内部可以使⽤ require 函数和 module.exports 对象来对模块进⾏导⼊和导出。
// ⼀个⽐较简单的 CommonJS 模块
const moduleA = require("./moduleA"); // 获取相邻的相对路径 `./moduleA` ⽂件导出的结果
module.exports = moduleA; // 导出当前模块内部 moduleA 的值
2
3
下⾯这三个模块稍微复杂⼀些,它们都是合法的 CommonJS 模块:
// index.js
require("./moduleA");
var m = require("./moduleB");
console.log(m);
// moduleA.js
var m = require("./moduleB");
setTimeout(() => console.log(m), 1000);
// moduleB.js
var m = new Date().getTime();
module.exports = m;
2
3
4
5
6
7
8
9
10
**index.js **
代表的模块通过执⾏ require 函数,分别加载了相对路径为 ./moduleA 和 ./moduleB 的 两个模块,同时输出 moduleB 模块的结果。
**moduleA.js **
⽂件内也通过 require 函数加载了 moduleB.js 模块,在 1s 后也输出了加载进来的结 果。
**moduleB.js **
⽂件内部相对来说就简单的多,仅仅定义了⼀个时间戳,然后直接通过 module.exports 导出。
它们之间的 物理关系 和 逻辑关系 如下图:
在装有 Node.js 的机器上,我们可以直接执⾏ node index.js 查看输出的结果。我们可以发现,⽆论执⾏ 多少次,最终输出的两⾏结果均相同。
虽然这个例⼦⾮常简单,但是我们却可以发现 CommonJS 完美的解决了最开始我们提出的痛点:
模块之间内部即使有相同的变量名,它们运⾏时没有冲突。这说明它有处理模块变量作⽤域的能 ⼒。上⾯这个例⼦中三个模块中均有 m 变量,但是并没有冲突。
moduleB 通过 module.exports 导出了⼀个内部变量,⽽它在 moduleA 和 index 模块中能被加载。 这说明它有导⼊导出模块的⽅式,同时能够处理基本的依赖关系。
我们在不同的模块加载了 moduleB 两次,我们得到了相同的结果。这说明它保证了模块单例。
# 适合 WEB 开发的 AMD 模块化规范
另⼀个为 WEB 开发者所熟知的 JS 运⾏环境就是浏览器了。浏览器并没有提供像 Node.js ⾥⼀样的 require
⽅法。不过,受到 CommonJS 模块化规范的启发,WEB 端还是逐渐发展起来了 AMD, SystemJS 规范等适合浏览器端运⾏的 JS 模块化开发规范。
AMD 全称 Asynchronous module definition,意为 异步的模块定义
,不同于 CommonJS 规范的同步加 载,AMD 正如其名所有模块默认都是异步加载,这也是早期为了满⾜ web 开发的需要,因为如果在 web 端也使⽤同步加载,那么⻚⾯在解析脚本⽂件的过程中可能使⻚⾯暂停响应。
⽽ AMD 模块的定义与 CommonJS 稍有不同,上⾯这个例⼦的三个模块分别改成 AMD 规范就类似这样:
// index.js
require(['moduleA', 'moduleB'], function(moduleA, moduleB) {
console.log(moduleB);
});
// moduleA.js
define(function(require) {
var m = require('moduleB');
setTimeout(() => console.log(m), 1000);
});
// moduleB.js
define(function(require) {
var m = new Date().getTime();
return m;
});
2
3
4
5
6
7
8
9
10
11
12
13
14
我们可以对⽐看到,AMD 规范也⽀持⽂件级别的模块,模块 ID 默认为⽂件名,在这个模块⽂件中,我 们需要使⽤ define
函数来定义⼀个模块,在回调函数中接受定义组件内容。这个回调函数接受⼀个 require
⽅法,能够在组件内部加载其他模块,这⾥我们分别传⼊模块 ID,就能加载对应⽂件内的 AMD 模块。不同于 CommonJS 的是,这个回调函数的返回值即是模块导出结果。
差异⽐较⼤的地⽅在于我们的⼊⼝模块,我们定义好了 moduleA 和 moduleB 之后,⼊⼝处需要加载进 来它们,于是乎就需要使⽤ AMD 提供的 require
函数,第⼀个参数写明⼊⼝模块的依赖列表,第⼆个 参数作为回调参数依次会传⼊前⾯依赖的导出值,所以这⾥我们在 index.js 中只需要在回调函数中打印 moduleB 传⼊的值即可。
Node.js ⾥我们直接通过 node index.js
来查看模块输出结果,在 WEB 端我们就需要使⽤⼀个 html ⽂ 件,同时在⾥⾯加载这个⼊⼝模块。这⾥我们再加⼊⼀个 index.html 作为浏览器中的启动⼊⼝。 如果想要使⽤ AMD 规范,我们还需要添加⼀个符合 AMD 规范的加载器脚本在⻚⾯中,符合 AMD 规范实 现的库很多,⽐较有名的就是 require.js。
<html>
<!-- 此处必须加载 require.js 之类的 AMD 模块化库之后才可以继续加载模块-->
<script src="/require.js"></script>
<!-- 只需要加载⼊⼝模块即可 -->
<script src="/index.js"></script>
</html>
2
3
4
5
6
使⽤ AMD 规范改造项⽬之后的关系如下图,在物理关系⾥多了两个⽂件,但是模块间的逻辑关系仍与 之前相同。
启动静态服务之后我们打开浏览器中的控制台,⽆论我们刷新多少次⻚⾯,同 Node.js 的例⼦⼀样,输 出的结果均相同。同时我们还能看到,虽然我们只加载了 index.js 也就是⼊⼝模块,但当使⽤到 moduleA 和 moduleB 的时候,浏览器就会发请求去获取对应模块的内容。
从结果上来看,AMD 与 CommonJS ⼀样,都完美的解决了上⾯说的 变量作⽤域 和 依赖关系 之类的问 题。但是 AMD 这种默认异步,在回调函数中定义模块内容,相对来说使⽤起来就会麻烦⼀些。
同样的,AMD 的模块也不能直接运⾏在 node 端,因为内部的 define
函数, require
函数都必须配合 在浏览器中加载 require.js 这类 AMD 库才能使⽤。
# UMD 模块
有时候我们写的模块需要同时运⾏在浏览器端和 Node.js ⾥⾯,这也就需要我们分别写⼀份 AMD 模块和 CommonJS 模块来运⾏在各⾃环境,这样如果每次模块内容有改动还得去两个地⽅分别进⾏更改,就⽐ 较麻烦。
// ⼀个返回随机数的模块,浏览器使⽤的 AMD 模块
// math.js
define(function() {
return function() {
return Math.random();
}
});
// ⼀个返回随机数的模块,Node.js 使⽤的 CommonJS 模块
module.exports = function() {
return Math.random();
}
2
3
4
5
6
7
8
9
10
11
基于这样的问题, UMD(Universal Module Definition) 作为⼀种 同构(isomorphic) 的模块化解决⽅案出 现,它能够让我们只需要在⼀个地⽅定义模块内容,并同时兼容 AMD 和 CommonJS 语法。
写⼀个 UMD 模块也⾮常简单,我们只需要判断⼀下这些模块化规范的特征值,判断出当前究竟在哪种 模块化规范的环境下,然后把模块内容⽤检测出的模块化规范的语法导出即可。
(function(self, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
// 当前环境是 CommonJS 规范环境
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// 当前环境是 AMD 规范环境
define(factory)
} else {
// 什么环境都不是,直接挂在全局对象上
self.umdModule = factory();
}
}(this, function() {
return function() {
return Math.random();
}
}));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上⾯就是⼀种定义 UMD 模块的⽅式,我们可以看到⾸先他会检测当前加载模块的规范究竟是什么。如 果 module.exports
在当前环境中为对象,那么肯定为 CommonJS,我们就能⽤ module.exports
导出模 块内容。如果当前环境中有 define
函数并且 define.amd
为 true
,那我们就可以使⽤ AMD 的 define
函数来定义⼀个模块。最后,即使没检测出来当前环境的模块化规范,我们也可以直接把模块内容挂载 在全局对象上,这样也能加载到模块导出的结果。
# ESModule 规范
前⾯我们说到的 CommonJS 规范和 AMD 规范有这么⼏个特点:
- 语⾔上层的运⾏环境中实现的模块化规范,模块化规范由环境⾃⼰定义。
- 相互之间不能共⽤模块。例如不能在 Node.js 运⾏ AMD 模块,不能直接在浏览器运⾏ CommonJS 模块。
在 EcmaScript 2015 也就是我们常说的 ES6 之后,JS 有了语⾔层⾯的模块化导⼊导出关键词与语法以及 与之匹配的 ESModule 规范。使⽤ ESModule 规范,我们可以通过 import
和 expor
t 两个关键词来对模 块进⾏导⼊与导出。
还是之前的例⼦,使⽤ ESModule 规范和新的关键词就需要这样定义:
// index.js
import './moduleA';
import m from './moduleB';
console.log(m);
// moduleA.js
import m from './moduleB';
setTimeout(()) => console.log(m), 1000);
// moduleB.js
var m = new Date().getTime();
export default m;
2
3
4
5
6
7
8
9
10
ESModule 与 CommonJS 和 AMD 最⼤的区别在于,ESModule 是由 JS 解释器实现,⽽后两者是在宿主环 境中运⾏时实现。ESModule 导⼊实际上是在语法层⾯新增了⼀个语句,⽽ AMD 和 CommonJS 加载模块 实际上是调⽤了 require
函数。
// 这是⼀个新的语法,我们没办法兼容,如果浏览器⽆法解析就会报语法错误
import moduleA from "./moduleA";
// 我们只需要新增加⼀个 require 函数,就可以⾸先保证 AMD 或 CommonJS 模块不报语法错误
function require() {}
const moduleA = require("./moduleA");
2
3
4
5
ESModule 规范⽀持通过这些⽅式导⼊导出代码,具体使⽤哪种情况得根据如何导出来决定:
import { var1, var2 } from './moduleA';
import * as vars from './moduleB';
import m from './moduleC';
export default {
var1: 1,
var2: 2
}
export const var1 = 1;
const obj = {
var1,
var2
};
export default obj;
2
3
4
5
6
7
8
9
10
11
12
13
这⾥⼜⼀个地⽅需要额外指出, import {var1} from "./moduleA"
这⾥的括号并不代表获取结果是个对象, 虽然与 ES6 之后的对象解构语法⾮常相似。
// 这些⽤法都是错误的,这⾥不能使⽤对象默认值,对象 key 为变量这些语法
import {var1 = 1} from "./moduleA"
import {[test]: a} from "./moduleA";
// 这个才是 ESModule 导⼊语句种正确的重命名⽅式
import {var1 as customVar1} from "./moduleA";
// 这些⽤法都是合理的,因为 CommonJS 导出的就是个对象,我们可以⽤操作对象的⽅式来操作导出结果
const {var1 = 1} = require("./moduleA");
const {[test]: var1 = a} = require("./moduleA");
// 这种⽤法是错误的,因为对象不能这么使⽤
const {var1 as customVar1} = require("./moduleA");
2
3
4
5
6
7
8
9
10
⽤⼀张图来表示各种模块规范语法和它们所处环境之间的关系:
每个 JS 的运⾏环境都有⼀个解析器,否则这个环境也不会认识 JS 语法。它的作⽤就是⽤ ECMAScript 的 规范去解释 JS 语法,也就是处理和执⾏语⾔本身的内容,例如按照逻辑正确执⾏ var a = "123";
, function func() {console.log("hahaha");}
之类的内容。
在解析器的上层,每个运⾏环境都会在解释器的基础上封装⼀些环境相关的 API。例如 Node.js 中的 global
对象、 process
对象,浏览器中的 window
对象, document
对象等等。这些运⾏环境的 API 受到各⾃规范的影响,例如浏览器端的 W3C 规范,它们规定了 window
对象和 document
对象上的 API 内容,以使得我们能让 document.getElementById
这样的 API 在所有浏览器上运⾏正常。
事实上,类似于 setTimeout
和 console
这样的 API,⼤部分也不是 JS Core 层⾯的,只不过是所有运 ⾏环境实现了相似的结果。
setTimeout
在 ES7 规范之后才进⼊ JS Core 层⾯,在这之前都是浏览器和 Node.js 等环境进⾏实现。 console
类似 promise
,有⾃⼰的规范,但实际上也是环境⾃⼰进⾏实现的,这也就是为什么 Node.js 的 console.log
是异步的⽽浏览器是同步的⼀个原因。同时,早期的 Node.js 版本是可以使⽤ sys.puts
来代替 console.log
来输出⾄ stdout 的。
ESModule 就属于 JS Core 层⾯的规范,⽽ AMD,CommonJS 是运⾏环境的规范。所以,想要使运⾏环 境⽀持 ESModule 其实是⽐较简单的,只需要升级⾃⼰环境中的 JS Core 解释引擎到⾜够的版本,引擎 层⾯就能认识这种语法,从⽽不认为这是个 语法错误(syntax error) ,运⾏环境中只需要做⼀些兼容⼯ 作即可。
Node.js 在 V12 版本之后才可以使⽤ ESModule 规范的模块,在 V12 没进⼊ LTS 之前,我们需要加上 -- experimental-modules
的 flag 才能使⽤这样的特性,也就是通过 node --experimental-modules index.js
来 执⾏。浏览器端 Chrome 61 之后的版本可以开启⽀持 ESModule 的选项,只需要通过这样的标签加载 即可。
这也就是说,如果想在 Node.js 环境中使⽤ ESModule,就需要升级 Node.js 到⾼版本,这相对来说⽐较 容易,毕竟服务端 Node.js 版本控制在开发⼈员⾃⼰⼿中。但浏览器端具有分布式的特点,是否能使⽤ 这种⾼版本特性取决于⽤户访问时的版本,⽽且这种解释器语法层⾯的内容⽆法像 AMD 那样在运⾏时 进⾏兼容,所以想要直接使⽤就会⽐较麻烦。
# 后模块化时代
通过前⾯的分析我们可以看出来,使⽤ ESModule 的模块明显更符合 JS 开发的历史进程,因为任何⼀个 ⽀持 JS 的环境,随着对应解释器的升级,最终⼀定会⽀持 ESModule 的标准。但是,WEB 端受制于⽤户 使⽤的浏览器版本,我们并不能随⼼所欲的随时使⽤ JS 的最新特性。为了能让我们的新代码也运⾏在 ⽤户的⽼浏览器中,社区涌现出了越来越多的⼯具,它们能静态将⾼版本规范的代码编译为低版本规范 的代码,最为⼤家所熟知的就是 babel
。
它把 JS Core 中⾼版本规范的语法,也能按照相同语义在静态阶段转化为低版本规范的语法,这样即使 是早期的浏览器,它们内置的 JS 解释器也能看懂。