AMD, CMD, CommonJs, ES6 模块… 这是些什么鬼?

为什么引入一个模块, 有时用 require(“express”) , 而有时却要用 import Vue from ‘vue’ ?

让我们一起来梳理一下 JavaScript 的模块化吧 ~

1. 引例

什么是模块化? 为什么需要模块化? 让我们来看一个简单的例子…

假设有 2 个 js 文件: m1.jsm2.js, 代码分别如下:

/m1.js

1
2
3
4
5
const x = 'M1'

function sayHello() {
alert('Hello, I'm M1')
}

/m2.js

1
2
3
4
5
const x = 'M2'

function sayHello() {
alert('Hello, I'm M2')
}

然后, 我们再写一个 index.html, 代码如下:

/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<title>Module Example</title>
<script src="./m1.js"></script>
<script src="./m2.js"></script>
<script>
alert(x)
sayHello()
</script>
</head>
<body></body>
</html>

最后, 用浏览器打开index.html, 你看到了什么?

是不是前后弹出了消息框 “M1” 和 “Hello, I’m M1” ? 如果打开Chrome的”开发者工具”, 你还将在控制台看到报错信息:

“Uncaught SyntaxError: Identifier ‘x’ has already been declared” (m2.js : 1)

回看一下上面的代码, 你会发现在 m1.js 和 m2.js 中都定义了常量 x 和 函数 sayHello, 在 index.html 中, 我们先后将 m1.js 和 m2.js 引入, 将它们”整合”到了一起. 可以想像, 常量 x 和 函数 sayHello 势必会冲突, 冲突的结果就是 m2.js 完败, 因为代码按顺序执行到 m2.js 中的第1行就出错了, 导致整个 m2.js 中的代码都没有成功执行. 最终的结果就是你看到的那样…

现在, 把 m2.js 中的第1行注释掉, 刷新网页, 你会发现这次弹出的消息框分别是 “M1” 和 “Hello, I’m M2”, 有意思吧, m2.js 中 sayHello 函数的定义”覆盖”了 m1.js 中的定义.

经过上面一番折腾, 你悟到了什么?

是的, JavaScript 的”散装”特性导致我们将多个.js 文件”整合”到一起时, 极易发生过高的”耦合”, 导致各种冲突和错误.

在当初 JavaScript 还是一个小角色的时候, 这样的冲突基本可以通过程序员们自己”注意点”就可以避免. 但如今, 无论在前端还是在后端(如: Node.js) JavaScript 俨然成了重要角色, 项目自身的JavaScript代码量暴增, 第3方代码大量整合, 这一切都令上述”冲突”频发, 亟需实现”模块化”, 简单说就是让不同 .js 文件中的代码处于一个独立的”模块”中, 模块内怎么乱管不着, 但各个模块间应相互独立, 互不干扰.

这就是引入”模块化”思想的初衷.

2. “模块化”实践

知道了为什么需要”模块化”, 那么如何实现呢?

一个最朴素的想法可以这样…

/m1.js

1
2
3
4
5
6
const M1 = {
x: 'M1'
sayHello: function() {
alert('Hello, I'm M1')
}
}

m2.js 也作类似修改. 然后以M1.x, M1.sayHello()的方式使用.

嘿嘿, 有点简陋啊~

不过, 我们确实通过对象的”封装”实现了”模块化”, 哈哈~

实践中通常会使用”立即执行的函数”进行封装, 只是那样写的话, 小白可能不容易看明白, 暂不去扯了… 下面有更高级的方式, 继续往下看…

2.1 百花齐放, 还是群魔乱舞

我们俗称的 JavaScript, 其”学名”应叫 ECMAScript, 简称 ES.

在2015年6月以前, 我们使用的是 ES5 版本(2009年发布). 在那个年代, JavaScript 只被当作浏览器中运行的”小脚本”, 跑龙套的角色, 呵呵~ 在 ES5 标准中并没有模块的概念.

在那个时代, JavaScript 代码通过类似的方式引入后, 全部混杂在一起, 基本上相当于把各个 .js 文件中的代码都复制 + 粘贴在一起, 这就很凶险了… 就像一群疯子在公海搞军事演习, 一不小心就擦枪走火… 命名冲突, 代码相互耦合等问题层出不穷.

当然, 一些聪明的娃娃会使用函数/对象进行封装, 以隔离代码/变量. 但谁又能保证这世界上是没有几个二货…

随着时代的发展, JavaScript 逐渐从浏览器里的”小脚本”成长为使用广泛的编程语言, 模块化成为了大家梦寐以求的东西. 所谓模块化, 可简单理解为将代码封装成相对独立的单元(模块), 在模块内部你想翻天都行, 但模块之间的相互协作则需要遵循一套规范的语法. 这其实是软件工程”高内聚, 低耦合”思想的体现.

在 JavaScript 模块化探索的过程中, 出现过很多规范(标准/建议), 比如: AMD, CMD, CommonJs… 它们都算是民间标准, 因为…. ECMA 官方似乎睡着了, 标准严重滞后…

  • AMD (Asynchronous Module Definition) 从名字就可以看出, 它使用异步的模块加载策略, 因而更适合用于浏览器端, 以免前端页面”假死”.

AMD是一个规范, 它是 RequireJS 推广过程中对模块定义的规范化产出,而 RequireJS 是对这个概念的实现.

  • CMD(Common Module Definition) 是阿里的大神搞出来的东东(是的, 就是你的那个阿里爸爸), CMD同样是一个规范, 在阿里的Sea.js中遵循了这个规范. 更适合于浏览器端

  • CommonJS 使用的是同步加载策略, 更多适用于服务器端. 在 Node.js 出生的年代, ECMA 还在睡… 所以, 它在万千佳丽中选择了 CommonJS 作为其模块化方案.

直到 2015年6月, ECMA 终于醒了, “正规军”终于来了, 发布了 ECMAScript 6, ES6 顺应时代潮流, 引入了模块化规范. 但不幸的是, 它走出了一条自己的路… 虽然, 所体现的”思想”与世上已经存在的模块化方案一致, 但在具体的语法上多少有些细微的差异. 所以, 目前在模块化标准这事上, 似乎又开始群魔乱舞了…

随着 Node.js 版本的更替, 它也想着向正规军靠拢, 但时至今日, 在 Node.js v14.7.0 版本中, 对标准的 ECMAScript 模块规范的支持仍处于实验阶段.

2.2 怎么选?

BB了这么半天, 那有什么结论吗?

展望未来, 随着ECMAScript 标准逐步完善, 应该可以看到天下大统, 近年来, ECMAScript 标准高频更新, 应该在不久的将来就会天下归一, 到时也自然不用纠结该用什么模块化规范了, 选正规军就好. 只是, 目前的阶段还有点点乱…

个人拙见

3. 实操

写点代码实操一下吧~

3.1 浏览器端 ( ES Module )

引例中 m1.js 和 m2.js 的代码分别这样改一下…

/m1.js

1
2
3
4
5
6
7
8
9
10
11
// 命名导出
export const x = 'M1'

function sayHello() {
alert("Hello, I'm M1")
}

// 命名导出
export {
sayHello
}

/m2.js

1
2
3
4
5
6
7
8
9
10
11
const x = 'M2'

function sayHello() {
alert("Hello, I'm M2")
}

// 默认导出
export default {
x,
sayHello
}

以上代码分别简单演示了ECMAScript标准中, 模块向外界导出 feature 的两种方式: 命名导出默认导出

来看看怎么使用… 修改 index.html

/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<title>Module Example</title>
<script type="module">
import { x, sayHello } from "./m1.js";
import m2 from "./m2.js";

alert(x)
sayHello()

alert(m2.x)
m2.sayHello()
</script>
</head>
<body></body>
</html>
  • 注意第5行中的 type="module", 只有在一个模块(module)中才可以使用 import去引入别的模块

  • 默认导出的东西, 可以一坨的导入, 如第7行, 也可以使用 import { x, sayHello } from "./m2.js"的方式”解构”导入

  • 第6行不能写作import m1 from "./m1.js", 因为m1.js中没有默认导出.

    但可写作 import * as m1 from "./m1.js", 将命名导出的所有 feature “整合”为 m1, 然后使用m1.xm1.sayHello()的方式引用.

如果你用 VUE CLI 搭建过VUE项目, 你应该见过上面代码中的写法.

上面的例子不能直接双击在浏览器里打开执行, 即不能通过 file:// URL 方式运行 JS 模块 — 这将导致 CORS 错误.

你需要通过 HTTP 服务器运行, 也就是例如 http://localhost:8080/index.html 的方式打开.

你电脑上应该装了 Node.js 环境吧 ~ 那你可以使用 anywhere 之类的静态服务器启动项目试试 ~

1
2
3
4
5
# 全局安装 anywhere
npm i anywhere -g

# 进入index.html所在的目录, 启动anywhere
anywhere

3.2 服务器端(Node.js, CommonJS)

如前所述, 在 Node.js 的世界里使用的是 CommonJS 规范, 虽然它已经在向 ECMAScript 规范靠拢了, 但目前还处于试验阶段.

下面举个小例子, 展示一下CommonJS模块规范.

在Node.js的世界里, 每个 .js 文件都自成一个模块, 需要主动使用module.exports向外界导出需要”暴露”的内部 feature.

/module-example.js

1
2
3
4
5
6
7
8
9
10
11
12
const PI = 3.14

function getCircleArea(r) {
return PI * r * r
}

function innerFunc() { }

module.exports = {
PI,
getArea
}

index.js

1
2
3
4
5
6
const m = require('./module-example.js')

console.log(m.PI) // 3.14
console.log(m.getCircleArea(1)) // 3.14

m.innerFunc() // TypeError: m.innerFunc is not a function

其实单独看某种模块化规范, 无论是 ES Module 还是 CommonJS 都不难懂.

只是实践中常会一个会同时出现两种规范, 例如前端VUE, 后端Node.js的项目中两种规范的写法都会出现, 保存头脑清醒, 不要搞混了就行.