JavaScript的模块规范

JavaScript的模块规范

主要有以下几种规范:

  • AMD
  • CMD
  • CommonJS
  • UMD
  • ES6 module

AMD(Asynchronous Module Definition)

实现库:

RequireJS

RequireJS is a JavaScript file and module loader. It is optimized for in-browser use, but it can be used in other JavaScript environments, like Rhino and Node. Using a modular script loader like RequireJS will improve the speed and quality of your code.

RequireJS是一个JavaScript文件和模块的加载器,为浏览器的使用做了优化,但是也可以在其他的JavaScript环境中使用,比如RhinoNode。使用像RequireJS一样的模块化的脚本加载器能够提高代码的加载速度和质量。

直接在浏览器引入即可使用,或者下载到项目中,CDN地址:https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js

使用http-server跑一个简易的http服务器,项目目录如下:

定义一个util模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
define(function () {
function add(a, b) {
return a + b;
}

function cut(a, b) {
return a - b;
}

return {
add,
cut,
};
});

编写index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>模块化</title>
</head>
<body>
<div>RequireJS</div>
<script src="script/require.js" data-main="script/main.js"></script>
</body>
</html>

通过data-main我们可以指定当RequireJS加载完毕之后执行的第一个js文件,一般叫这个文件为入口文件(Entry Point)。

编写main.js文件:

1
2
3
4
require(["util"], function (util) {
console.log(util);
console.log(util.add(1, 2));
});

require函数第一个参数指定需要的依赖数组,第二个参数以回调函数的形式,当指定的依赖加载完毕时,运行回调函数。

这里指定了util模块,那么回调函数的第一个参数就为util模块返回的对象。

AMD的特点是依赖前置,即回调函数一定在模块执行完毕之后才会执行,即使某些模块在代码中存在不使用到的情况,比如:

1
2
3
4
5
6
7
8
9
// 假定存在module1和module2模块
require(["module1", "module2"], function (m1, m2) {
let isUseM2 = true;
// 一些计算
// 现在isUseM2可能不是true了
if (isUseM2) {
m2.func(); // 使用m2的某个功能
}
});

同时AMD定义的模块都是异步加载的,以上面的例子,我们可以通过控制台查看DOM情况,发现RequireJS插入了两个script标签来加载相应的模块,这两个script都添加了async属性,也就不会阻塞到DOM的渲染了。

引入和加载模块的工作交给了RequireJS,没有必要手写进index.html文件中,只需以RequireJS定义的模式向RequireJS表明需要的模块即可,即通过require函数的第一个参数来指定模块。

CMD(Common Module Definition)

实现库:

SeaJS

PS:文档页可能打不开,可以把项目拉到本地,打开doc/index.html即可查看文档。git地址https://github.com/seajs/seajs.git

文档首页:

SeaJS是由国内开源的项目,由腾讯,阿里共同维护,不过现在基本没人维护了,最近一次push已经是3年前了(和RequireJS一样)。

RequireJS一样,可以使用CDN也可以下载到本地引入,https://cdn.bootcdn.net/ajax/libs/seajs/2.3.0/sea.js

SeaJS 2.1版本删除了对data-main的支持,现在使用seajs.use来代替data-main来启动主模块。

html修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>模块化</title>
</head>
<body>
<div>SeaJS</div>
<script src="script/sea.js"></script>
<script>
// 加载主模块main.js
seajs.use("./script/main.js");
</script>
</body>
</html>

使用define函数来定义模块util

1
2
3
4
5
6
7
8
9
10
11
12
13
14
define(function (require, exports, module) {
function add(a, b) {
return a + b;
}

function cut(a, b) {
return a - b;
}

return {
add,
cut,
};
});

main模块中使用util模块:

1
2
3
4
5
define(function (require, exports, module) {
const util = require("util.js");
console.log(util);
console.log(util.add(1, 2));
});

然后就可以看到如下的效果图:

可以看出CMD的理念是依赖后置,或者说依赖就近,即无需提前执行完当前模块需要的全部依赖模块的逻辑即可执行本模块的函数,模块只在需要的时候才被执行对应的函数然后require进来,即用即返,这里是一个同步执行的概念。

比如之前在RequireJS中的例子,在SeaJS中为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 假定存在module1和module2模块
define(function (require) {
let isUseM2 = true;
// 一些计算
// 现在isUseM2可能不是true了
if (isUseM2) {
// 这里才执行module2的模块的逻辑
const m2 = require("module2");
m2.func(); // 使用m2的某个功能
} else {
// 这里不会加载module2
}
});

CommonJS

CommonJS是服务端(NodeJS)所使用的一套规范,它使用同步加载机制来加载模块。

由于在服务端运行,CommonJS在引入和执行都是同步的,这和AMDCMD有着本质的不同的,后两者在引入阶段都是异步的。AMD在执行阶段是异步的,而CMD为同步的。引入可以理需要执行的解为使用HTTP来获取一个js文件,而执行可以理解为模块本身所逻辑。比如上面例子中util模块,在模块的函数中需要定义add函数和cut函数,这为模块的执行阶段。

CommonJS其实和CMD都是相似的,CommonJS也定义每个文件为一个模块,每个模块可以通过require来引入需要的依赖,通过module.exports或者exports来返回模块需要暴露给第三方的接口。

可以将util.js改造为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// util.js
function add(a, b) {
return a + b;
}

function cut(a, b) {
return a - b;
}

// 暴露给外界的API
module.exports = {
add,
cut,
};

main中使用util模块:

1
2
const util = require("./util");
console.log(util.add(1, 2));

由于CommonJS只能在Node中运行,所以需要使用Node来启动main.js,启动之后就可以看到如下的效果:

UMD(Universal Module Definition)

UMD为通用模块定义规范,让产出的代码兼容不同的模块规范。

比如如下代码(取自webpackoutput.library.typeumd的打包代码片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function webpackUniversalModuleDefinition(root, factory) {
if (typeof exports === "object" && typeof module === "object") {
// commonjs2
module.exports = factory();
} else if (typeof define === "function" && define.amd) {
// amd
define([], factory);
} else if (typeof exports === "object") {
// commonjs
exports["MyLibrary"] = factory();
} else {
// 挂载到全局
root["MyLibrary"] = factory();
}
})(global, function () {
// 逻辑
});

ES6 module

es6实现了模块功能,主要通过两个关键字importexport

修改util.js文件为如下:

1
2
3
4
5
6
7
export function add(a, b) {
return a + b;
}

export function cut(a, b) {
return a - b;
}

修改main.js为如下:

1
2
3
4
import { add } from "./util.js";

console.log(add);
console.log(add(1, 2));

然后需要在index.html中通过scripttype=module来引入main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>模块化</title>
</head>
<body>
<div>es6 module</div>
<!-- type="module" -->
<script src="/script/main.js" type="module"></script>
</body>
</html>

然后访问浏览器,效果如下:

CommonJSES6 Module区别

看起来es6的模块代码写起来和CommonJS差不多,但是他们有着本质的区别。

  • CommonJS模块输出的是一个值的拷贝,ES6 Module模块输出的是值的引用;
  • CommonJS模块是运行时加载,ES6 Module是编译时输出接口;
  • CommonJS是单个值导出,ES6 Module可以导出多个;
  • CommonJS是动态语法可以写在判断里,ES6 Module静态语法只能写在顶层;
  • CommonJSthis是当前模块,ES6 Modulethisundefined

以上的分点来自这:CommonJS 和 ES6 module 的区别是什么呢?

第一点可以用一个小例子来说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CommonJS 模块 util.js
let count = 1;

function addOne() {
count++;
}

function getCount() {
return count;
}

module.exports = {
count,
addOne,
getCount,
};

当我们引入这个模块使用addOne进行count自增时,导出的count并不会发生改变。

1
2
3
4
5
6
7
8
9
10
// CommonJS 模块 test.js
const { count, addOne, getCount } = require("./util.js");
// 导出的count为1
console.log(count);
// 给count自增
addOne();
// 还是为1
console.log(count);
// 内部的值变为了2
console.log(getCount());

输出如下:

其实不难解释,当我们在最后通过module.exports导出时,有一个赋值的过程。

1
2
3
4
module.exports = {
count: count,
// ...
};

这时候导出的count就和内部的count不是一个指向了。

如果使用ES6 Module

1
2
3
4
5
6
7
8
9
10
// ES6 Module 模块 util.mjs
export let count = 1;

export function addOne() {
count++;
}

export function getCount() {
return count;
}
1
2
3
4
5
6
7
8
9
10
// ES6 Module 模块 test.mjs
import { count, addOne, getCount } from "./util.js";
// 导出的count为1
console.log(count);
// 给count自增
addOne();
// 变为2
console.log(count);
// 内部的值也变为了2
console.log(getCount());

效果如下:

能够使用CommonJS写出类似ES6 Module的效果吗,当然是可以的,只需要以引用的方式使用count即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CommonJS 模块 util.js
function addOne() {
exports.count++;
}

function getCount() {
return exports.count;
}

module.exports = {
count: 1,
addOne,
getCount,
};

效果就和ES6的一样了。

第二点和第四点一起解释可能会更容易理解,CommonJSNode上的模块化,其实现的原理是构建一个Module对象,通过给代码文本包装到一个函数中,使得我们能够使用module.exportsexports这些变量,即:

1
2
3
function module(exports, require, module, __filename, __dirname) {
// 文件里的代码都会以字符串的形式拼接到这里。
}

ES6 Module不是使用函数封装的方式进行模块化,而是直接从语法层面提供了模块化的功能。

ES6 Module会在程序开始前先根据模块关系查找到所有模块,将所有模块实例都创建好。

这也可以从第四点看出来,由于CommonJS可以动态的引入,所以只要你想,完全可以在一个模块的任何地方使用require引入相应的模块。

ES6 Module则不行,因为运行前需要分析引入情况所,所以只能将有的import写在代码的开头。这也是Tree-Shaking(摇树)优化的原理。

第三点可以理解为CommonJS本质就是导出一个对象,这个对象为module.exports或者exports,这两者都指向了一个初始的空对象。

ES6 Module仅仅只是对需要导出的对象做标记而已,即使用export去标明需要导出的变量。

如果在运行中使用了未导出的属性,CommonJS会在运行时可能会报错,而ES6 Module在分析阶段就会直接报错了。