前端模块化:CommonJS,AMD,CMD,ES6

​ 模块化的开发方式可以提高代码复用率,方便代码的管理,并只向外暴露特定的变量和函数以保证自己的私密性。

​ 但是从网页端的脚本语言一路走来,到 NodeJS 后端语言的大热,再到前后端共用模块规范的制定,协议也一变再变。今天我们就来深究一下曾经我们使用过的规范以及其特点和发展历程。

​ PS:希望直接看结论的请移步文章末尾。

CommonJS

前情

​ NodeJS 在创始之初参考了诸多语言,如 Ruby 的 require、Java 的类文件、Python 的 import、PHP 的 include 等等。JS 使用 <script> 标签进行引入的方式使得逻辑杂乱无章,无法应用于后端开发语言,而 CommonJS 规范的提出才算是真正成就了 NodeJS。

​ 由于 nodeJS 是 CommonJS 的主要实践者,故我们介绍过程中主要对象为 nodeJS,在此提前说明。

详情

CommonJS 用同步的方式加载模块

node与浏览器及W3C组织、commonJS组织、ECMAScript之间的关系

​ 不同于其他语言,nodeJS 所践行的规范主要通过 exports 暴露接口,通过 require 引入对象。其关系如下图所示,每个文件都是一个独立的模块(module),可以向外暴露接口或引用其他 module

commonJS中的模块定义

CommonJS规范 (require) 采用同步方式引入依赖,将其放入缓存之中。一个模块被加载过一次后就会在缓存中维持一个副本,若遇到重复加载的模块则直接从缓存中提取,故无需担心重复引用的问题。而另一方面,同步引用的方式会极大的限制 CommonJS 的应用范围,令其局限于后端开发环境中,这一点我们在 AMD 部分详细介绍。

exports&module.exports

​ nodeJS 中为什么有了 exports 又要有 module.exports ?二者又有什么区别和联系?

按道理来讲,只要将需要暴露出的变量赋值给 exports 对象即可,但是通常都会得到一个失败的结果。原因就是 exports 对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但并不能更改作用域外的值。故若希望 require 引入一个类的效果,直接赋值给 module.exports 对象。

​ ——《深入浅出nodeJS》

​ 但看这句话的时候我并没有读懂作者的意思,那么我们接着往后看。

​ 事实上,nodeJS 执行一个 JS 脚本时会生成 exportsmodule 对象,而 module 自身拥有一个 exports 属性。二者指向同一区域。

​ 那么既然二者指向同一块区域,又为何要分出两个 API 来分别对应呢?

Node 中 exports 对象是唯一可以导出当前模块的方法变量的方法,而模块中的 module 对象则代表模块自身,exportsmodule 的属性

​ ——《深入浅出nodeJS》

​ 为了保证客观性和真实性我们举一个例子来验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//koala.js
let a = '程序员成长指北';

console.log(module.exports); //能打印出结果为:{}
console.log(exports); //能打印出结果为:{}

//这里辛苦劳作帮 module.exports 的内容给改成 {a : 'bbbbb'}
exports.a = 'aaaaa';

exports = '指向其他内存区'; //这里把exports的指向指走

//test.js

const a = require('/koala');
console.log(a) // 打印为 {a : 'bbbbb'}

​ 在官方文档中有这样一段描述

exports alias

Added in: v0.1.16

The exports variable that is available within a module starts as a reference to module.exports.As with any variable, if you assign a new value to it, it is no longer bound to the previous value.

To illustrate the behavior, imagine this hypothetical implementation of require():

1
2
3
4
5
6
7
8
9
10
11
> function require(...) {
> // ...
> ((module, exports) => {
> // Your module code here
> exports = some_func; // re-assigns exports is no longer
> // a shortcut, and nothing is exported
> module.exports = some_func; // makes your module export 0
> })(module, module.exports);
> return module;
> }
>

As a guideline, if the relationship between exports and module.exports seems like magic to you, ignore exports and only use module.exports

​ 如上所述,exportsmodule.exports 同宗同源,指向共同的一块存储区域。即,exports = module.exports = {}。而另一方面,虽然二者指代内容相同但是 exports 只是 module.exports 的引用,如果你给 exports 赋值则会改变其原本的指向,所以也就有了《深入浅出nodeJS》中的那段话。

exports = module.exports = {}

Tips

​ 综上所述,我们若想暴露某个功能属性,则使用 exports

1
2
3
exports.getTime = () => {
return new Date().getTime();
}

​ 而若是想直接暴露这个函数,则使用 module.exports

1
2
3
module.exports = function getTime() {
return new Date().getTime();
}

AMD 和 require.js

前情

​ 随着 nodeJS 在市场上逐渐占据一席之地,自然而然它希望获取更大的市场,开发前后端公用模块,但问题接踵而至:在后端环境下我们本地缓存的依赖包通过 CommonJS 规范可以高速的加载,(毕竟 nodeJS 擅长处理 IO 而不擅处理大型运算),而前端环境下,限于网络原因,显然异步更有市场。

​ 举个栗子,我们一般的 nodeJS 编写的后台的配置文件都是以 CommonJS 规范加载的。通过同步的方式使得我们在后边调用时候就可以直接使用无需等待。

AMD(Async hronous Module Definition,异步模块定义) 规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。

详情

​ 其模块定义如下:define(id?, dependencies?, factory);

​ 与 nodeJS 相似之处在于 factory 的内容就是实际代码的内容

所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。这里介绍用 require.js 实现 AMD 规范的模块化:用require.config()指定引用路径等,用define()定义模块,用require()加载模块。

​ AMD模块需要用 define 来明确定义一个模块,而在 Node 实现中则是隐式包装的。他们的目的是进行作用于隔离,仅在需要的时候被引入,避免调过去的那种通过全局变量或者全局命名空间的方式,以免变量污染和不小心被修改。另一个区别则是内容需要通过返回的方式实现导出

​ ——《深入浅出nodeJS》

才疏学浅,举一个别人的例子。

​ 首先我们需要引入 require.js 文件和一个入口文件 main.jsmain.js 中配置require.config()并规定项目中用到的基础模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
baseUrl: "js/lib",
paths: {
"jquery": "jquery.min", //实际路径为js/lib/jquery.min.js
"underscore": "underscore.min",
}
});
// 执行基本操作
require(["jquery","underscore"],function($,_){
// some code here
});

引用模块的时候,我们将模块名放在[]中作为 reqiure() 的第一参数;如果我们定义的模块本身也依赖其他模块,那就需要将它们放在[]中作为 define() 的第一参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 定义math.js模块
define(function () {
var basicNum = 0;
var add = function (x, y) {
return x + y;
};
return {
add: add,
basicNum :basicNum
};
});
// 定义一个依赖underscore.js的模块
define(['underscore'],function(_){
var classify = function(list){
_.countBy(list,function(num){
return num > 30 ? 'old' : 'young';
})
};
return {
classify :classify
};
})

// 引用模块,将模块放在[]内
require(['jquery', 'math'],function($, math){
var sum = math.add(10,20);
$("#sum").html(sum);
});

懒狗 CMD

CMD 与 AMD 规范的主要区别在于定义模块和依赖引入的部分。AMD 需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中

1
2
3
define(['dep1', 'dep2'], function (dep1, dep2) {
return function (){};
});

而 CMD 则支持动态引入

1
2
3
define(function (require, exports, module) {
// The module code goes here
})

require.js (AMD)在申明依赖的模块时会在第一之间加载并执行模块内的代码:

1
2
3
4
5
6
7
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
// 等于在最前面声明并初始化了要用到的所有模块
if (false) {
// 即便没用到某个模块 b,但 b 还是提前执行了
b.foo()
}
});

CMD 是另一种 js 模块化方案,它与 AMD 很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/** AMD写法 **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) {
// 等于在最前面声明并初始化了要用到的所有模块
a.doSomething();
if (false) {
// 即便没用到某个模块 b,但 b 还是提前执行了
b.doSomething()
}
});

/** CMD写法 **/
define(function(require, exports, module) {
var a = require('./a'); //在需要时申明
a.doSomething();
if (false) {
var b = require('./b');
b.doSomething();
}
});

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
var $ = require('jquery.js');
var add = function(a,b){
return a+b;
}
exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
var sum = math.add(1+2);
});

ES6 Module

前情

​ 在寻找前后端通用规范的道路上磕磕绊绊,直至 ES6 Module 的出现,使其在语言标准的层面上,实现了模块功能,成为

详情

​ 不同于 CommonJS 的模块化输出,ES6 Module 中的 export 对应的值是动态绑定关系,即,可以通过接口获取到模块内部实时的值。最典型的例子就是现在脚手架中的热部署功能。一旦模块发生改变那么通过观察者-监听者模式将会立即触发视图层的改变,从而达到无需刷新即可完成更新内容呈现的功能。

​ 该规范的具体内容主要分为两个:import & export

  • import 用于导入其他模块的功能

    • 配合 ES6 的解构赋值 import { pureComponent } from 'react';
    • 还可以通过 * 实现模块的整体加载 import * as cricle from ./utils/circle;
  • export 则用于从模块中向外暴露变量或方法

    • 可以向外暴露变量,通常用来约定常量值方便排查错误 export const VARIABLE = "variable";

    • 也可以将变量在脚本最后导出 export { variable1, variable2, variable3 };

    • 还可以向外导出一个类或函数 export function mutiply(x, y) { return x * y; }

    • 向外暴露的内容还可以进行命名

      • 1
        2
        3
        4
        5
        6
        7
        8
        function v1() { ... }
        function v2() { ... }

        export {
        v1 as streamV1,
        v2 as streamV2,
        v2 as streamLatestVersion
        };

export default

​ 最重要的事要最先说:一个模块中可以有多个 export,但只能有一个 export default !

​ ES6 的模块不是对象,import命令会被 JavaScript 引擎静态分析,在编译时就引入模块代码,而不是在代码运行时加载,所以无法实现条件加载。也正因为这个,使得静态分析成为可能。

​ ——《深入浅出nodeJS》

​ 理论上讲 import 引入和 export 导出已经够用,但是通过 import 引入时用户需要知道希望加载的函数 / 变量名。而 export default 则给了用户一个偷懒的机会 —— 默认导出

​ 举个例子,我们通过 export default 所导出的函数在引入时无需阅读文档,而是可以自己命名。

​ 同样的,在导入时也无需像接收 export 导出的函数一样用大括号 import { pureComponent } from "react";

1
2
3
4
5
6
7
8
// export-default.js
export default () => {
console.log('foo');
}

// import-default.js
import customName from './export-default';
customName(); // 'foo'

​ 当然,我们也可以通过 export default 导出一个变量

1
2
var a = 1;
export default a;

​ 同样的,我们还可以用 export default 输出一个类

1
2
3
4
5
6
// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass';
let o = new MyClass();

​ 既然只能有一个 export default,那么如果希望导出多个变量 / 函数,那么就需要我们把他们包成一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b) {
return a + b;
};
export { basicNum, add };

/** 引用模块 **/
import { basicNum, add } from './math';
function test(ele) {
ele.textContent = add(99 + basicNum);
}

模块继承

​ 既然每一个文件都是一个模块,那么其间的继承关系也相对比较好搞。具体形式如下

1
2
3
4
5
6
7
8
// circleplus.js

export * from 'circle';
export var e = 2.71828182846;

export default function(x) {
return Math.exp(x);
}

​ 其基本原理就是将 circle.js 中的所有属性和方法引入到 circleplus.js 中,再将新的 circleplus.js 向外暴露出去。有没有想起什么?没错就是构造函数式继承,有相关需求的 boy 去掘金上搜搜文章吧,这部分我博客中并没有总结。

exportexport default均可用于导出常量、函数、文件、模块等,但二者亦有所不同:

  1. exportimport可以有多个,而export default只能有一个
  2. export 导出的对象在导入时需加 { },而export default 则不需要
  3. export 能直接导出变量表达式

ES6 Module & CommonJS

拷贝&引用

  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
  • ES6 模块的运行机制和 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个制度引用。等到脚本真正执行的时候,再根据这个制度引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。
    • 这也就是热部署的原理。而由于配置文件,如vue.config.js之类的,用的是 commonJS 规范,所以更改后需要重启服务重新读入

运行时加载&编译时输出接口

  • 运行时加载
    • CommonJS 模块就是对象;即,在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法。这种加载称为“运行时加载”。
  • 编译时加载
    • ES6 Module不是对象,而是通过export命令显式指定输出的代码。import时采用静态命令的形式。即在import时可以指定加载某个输出值,而非整个模块,此即为“编译时加载”。

CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

结论

  • require 所导出的是 module.exports 所指向的内存块内容。简而言之,exports 只是 module.exports 的引用,辅助后者添加内容所用。使用中推荐用 module.exports 向外暴露接口。
  • AMD 是提前执行,CMD 是延迟执行。AMD 推崇依赖前置,CMD 推崇依赖就近。
  • CommonJS 模块输出的是值的拷贝(运行时加载),ES6 Module 输出的是值的 “制度引用” (编译时加载)。
  • 初学者只要记住:凡是带有 “s” 的都是 CommonJS 规范,如 exportsmodule.exports 等。而不带 “s” 的则是 ES6 Moudule。比如 exportexport default
  • export 能直接导出变量表达式

参考文档