模块系统之 CommonJS vs ES6

想了半天标题,终于决定还是它了。主要就是对比两个规范下的模块系统。
对于ES6模块系统,截止目前还没有环境原生支持,大多通过babel转换来实现。node环境的模块系统本身就是CommonJS的一个实现,浏览器环境通过webpack进行构建也可以支持大部分CommonJS,所以这个话题多数场景与宿主环境没有直接关系。

接下来开始VS:

导入模块

CommonJS:

1
2
const Koa = require('koa')
const app = new Koa()

ES6:

1
2
import Koa from 'koa'
const app = new Koa()

看起来似乎ES6更加『正统』,还霸占了两个关键字呢(/得意).

导出模块

CommonJS:

1
2
3
4
let anyData
// ... 为anyData赋值需要导出的任何数据
module.exports = anyData
// => 别的模块导入此模块,拿到的是anyData

这是较常见的,还可以这样:

1
2
3
4
5
exports.a = 'a'
exports.b = () => {
// do something
}
// => 别的模块导入此模块,拿到的是{ a: 'a', b: function }

甚至这样:

1
2
3
4
5
6
let myModule = module.exports
myModule.c = 'ccc'
myModule.d = {
m: 111
}
// => 别的模块导入此模块,拿到的是{ c: 'ccc', d: { m: 111 } }

ES6:

1
2
3
4
5
6
7
let anyData
// ... 为anyData赋值需要导出的任何数据
export default anyData
export const a = 'a'
export const b = () => {
// do something
}

别的模块可以按需取出需要的东西,比如我只想要anyData:

1
2
3
// 这里可以直接重命名为你想要的变量
import ad from './any-data'
// do something with ad

比如我只想要a:

1
2
3
import { a } from './any-data'
// 注意这里的a,必须写a,如果希望重命名需要这样写 import { a as A } from './any-data'
// do something with a

看到这里,似乎还是ES6看起来更加优雅。是的,这是一般的,多数的场景。而且,经过babel的转译,ES6的导入还兼容CommonJS的导出。

ES6兼容CommonJS

CommonJS导出 a.js

1
2
3
module.exports = {
greeting: 'hello'
}

ES6导入:

1
2
3
4
5
6
7
import a from './a'
console.log(a.greeting) // => hello
export default 'xxx'
export function test() {
// do something
}

观察一下babel转译后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.test = test;
var _a = require('./a');
var _a2 = _interopRequireDefault(_a);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log(_a2.default.greeting); // => hello
exports.default = 'xxx';
function test() {
// do something
}

发现导出模块时,babel会给当前模块对象添加__esModule: true这个键值对,表示是ES6模块。为ES6的默认导出,指定default这个key。当导入模块时,会做一个预处理,判断当前模块的__esModule是否为true,如果为true,则直接返回,否则返回包装了一层的{ default: obj }。如此就兼容了CommonJS模块,感觉棒棒哒,但是如果反过来呢?

ES6导出 b.js:

1
2
3
4
5
export default z = 'z'
export const m = 'm'
export const n = function () {
// do something
}

CommonJS模块:

1
2
const b = require('./b')
console.log(b)

我们拿到的将会是:

1
2
3
4
5
{
default: 'z',
m: 'm',
n: 'n'
}

我们必须通过default这个key去访问默认导出, 虽然可行,但就是觉得不舒服!也许你可以说,我们可以用单向导入啊,只允许ES6模块导入CommonJS模块,不允许反过来,这样就不会出现default这种『异类』了。但是你真的能保证,永远是单向吗?难保哪个CommonJS模块有一天就『逆反』,需要引入ES6模块了。node服务端有些配置模块,没有被babel转译,只能使用CommonJS,有时候需要加载被babel转译的ES6模块,这时候真的就要疯了。如果你依然觉得ES6模块很完美的话,请接着往下看。

CommonJS杀手锏–动态加载

ES6模块是不支持动态加载的,即它的导入模块不能是表达式,只能是字面量。而CommonJS是可以的,而且node环境已经完美实现了。我想这应该是node6.x到目前为止还未原生实现ES6模块系统的原因之一吧.
举个栗子: 我想导入test目录下的所有模块。ES6无法实现,CommonJS可以这样写:

1
2
3
4
5
6
let fs = require('fs')
fs.readdirSync('./test').forEach((file) => {
const testModule = require('./test' + file)
// do something with testModule
})

但是,需要强调的是,动态加载在node环境下表现良好。在浏览器端,源码是经过构建工具(典型如webpack)构建过的,构建工具只能做静态分析, 为了保证模块不丢失,在运行时不会找不到目标模块,构建工具会把匹配目标模块表达式的所有模块全部打包,极端情况下,会造成很大浪费。因为浏览器需要从服务器下载代码,打包多余的代码对网页性能是一种隐患。

这一点,就是开头所说的『所以这个话题多数场景与宿主环境没有直接关系。』

——-update—–
用webpack做了个实验验证:
假设目录zzz下有a.js, b.js, c.js三个模块,模块内容分别为:

1
2
// a.js
module.exports = 'aaaaa'
1
2
// a.js
module.exports = 'bbbbb'
1
2
// a.js
module.exports = 'ccccc'

我在主模块下动态引入a.js:

1
2
3
4
5
let m = 'a'
require(['./zzz/' + m], (q) => {
console.log(q)
})
`

结果是对的,打印了aaaaa
但是观察一下打包出来的bundle,里面也附带了b.jsc.js的内容!!! 也就是我上面说的隐患!!!

注意: webpack的require.ensure不支持动态模块写法

总结

虽说ES6模块在功能上比CommonJS稍逊,但ES6模块毕竟是未来的标准。网上据说ES6有动态加载的API,但是我没有找到,似乎还在TODO列表中。鉴于浏览器使用动态加载有一定隐患,我们尽量避免使用动态加载。另外,前端代码可以通过构建工具全部转译(至少能够保证单向依赖),因此前端代码使用ES6模块并没有什么问题。而在node环境,对动态加载的需求更高,那么还是使用CommonJS吧。
ps: 观察了一些知名node框架/库,它们也还是使用CommonJS。