ESModule和CommonJS的比较以及注意事项

在 ES6 中,我们知道 import、export 取代了 require、module.exports 用来引入和导出模块,但是如果不了解 ES6 模块特性的话,代码可能就会运行出一些匪夷所思的结果,下面我将通过这篇文章为你揭开 ES6 模块机制特点。

关于二者的使用方式我就不具体介绍了,有兴趣的可以看一下我以前的博客:JavaScript Module使用语法

本文主要针对以下几个问题:

  • 这二者输出的是拷贝还是引用,拷贝的话是深拷贝还是浅拷贝?
  • 二者的加载运行时机有什么不同?
  • 它们是如何解决循环依赖以及重复加载问题的?
  • 二者的运行环境有什么不同,它们什么情况下可以混合使用,为什么可以?

拷贝 or 引用

CommonJS

我们看一下下面这段代码及其运行结果:

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
// b.js
let foo = 1;
let bar = {
a: 1
}
setTimeout(() => {
foo = 2;
bar.a = 2;
}, 500);
module.exports = {
foo: foo,
bar
};

// a.js
const b = require('./b');
console.log(b.foo);
console.log(b.bar.a);
setTimeout(() => {
console.log(b.foo);
console.log(b.bar.a);
}, 1000);

// node a.js 结果
1
1
1
2

这段代码在a引入b之后,在b的内部修改了foo和bar,然后再次打印了foo和bar,可以看到,foo的值没有改变,而bar改变了。

这说明了什么?module.exports.foo已经与内部的foo不是一个变量了,而module.exports.bar与内部的bar还是一个变量。

但是这能说明CommonJS是拷贝还是引用吗?

这里做一个简单说明,module.exports在初始时就是exports,就是说,有一个exports变量,然后我们把module.exports赋值为这个exports,所以exports.a 与 module.exports.a赋值是完全相同的效果。

区别在于我们require的是module.exports,而不是exports,如果我们直接修改了module.exports的引用,那我们在exports上挂载的属性是完全无用的。

举个例子:

exports.a = 1;

exports.b = 2;

module.exports = { c: 3 };

如果只有前两行,我们require的时候会得到 { a: 1, b: 2 },但是第三行直接覆写了module.exports,这个时候我们require的话,得到的就是{ c: 3 }

我再举一个例子,大家思考一下它的结果:

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
// b.js
let foo = 1;
let bar = {
a: 1
}
setTimeout(() => {
module.exports.foo = 2; // 不同之处
bar.a = 2;
}, 500);
module.exports = {
foo: foo,
bar
};

// a.js
const b = require('./b');
console.log(b.foo);
console.log(b.bar.a);
setTimeout(() => {
console.log(b.foo);
console.log(b.bar.a);
}, 1000);

// node a.js 结果
1
1
2 // 这里就是2了,为什么?
2

从上面这个例子我们可以看出,require的结果其实就是module.exports的引用

  • 为什么第一个例子中foo没有改而第二个改变了,因为在生成module.exports的时候,foo是基本类型,被深复制给了module.exports.foo,我们require进来的是module.exports.foo而不是内部的b内部的foo。
  • 为什么bar一直都是改了,因为在生成module.exports的时候,bar是个对象,module.exports.bar中保存的是bar的地址,而不是深复制,所以我们require进来的module.exports.bar就是内部的bar,它们两个指向同一个内存地址

你也可以这么理解:

我们在构造module.exports的时候其实是一层浅拷贝,把值浅拷贝给了module.exports中的属性。

但是我们require的时候引入的是module.exports的引用。

ESModule

ESModule是ES6引入的,所以没有babel,node是没法识别的,我们就用浏览器来执行。

但如果你直接用html引入js的方式,有两点需要注意:

  1. script的type需要是module
  2. 你需要其一个服务器,可以用anywhere这个npm package,因为浏览器对于file协议认为是跨域的

老规矩,看代码

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>Document</title>
<script type="module" src="./a.js"></script>
</head>
<body>

</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// b.js
export let foo = 1;
setTimeout(() => {
foo = 2;
}, 500)

// a.js
import { foo } from './b.js';
console.log(foo);
setTimeout(() => {
console.log(foo);
import('./b.js').then(({ foo }) => {
console.log(foo);
});
}, 1000);

// 结果
1
2
2

从这个结果来看,ES6 模块是动态关联模块中的值,也就是说看起来是引用

之所以说是看起来,是因为我还没有去看过具体实现,只是从表象上来看,是引用

二者的加载运行时机有什么不同?

ES6 模块编译时执行,而 CommonJS 模块总是在运行时加载,也就是说

对于CommonJS只有在require的时候,才会去引入。

ES6 模块编译时执行会导致有以下两个特点:

  1. import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。
  2. export 命令会有变量声明提前的效果。
1
2
3
4
5
6
7
8
9
10
11
// a.js
console.log('a.js')
import { foo } from './b';

// b.js
export let foo = 1;
console.log('b.js 先执行');

// 执行结果:
// b.js 先执行
// a.js

重复加载问题

CommonJS

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
// b.js
let foo = 1;
let bar = {
a: 1
}
setTimeout(() => {
module.exports.foo = 2;
bar.a = 2;
}, 500);
module.exports = {
foo: foo,
bar
};

//a.js
const b = require('./b');
console.log(b.foo);
console.log(b.bar.a);
setTimeout(() => {
console.log(b.foo);
console.log(b.bar.a);

b.foo = 3;
console.log(require('./b').foo);
}, 1000);

// node a.js
1
1
2
2
3

这个例子说明,每个module只会加载一次,每次require返回的都是同一个引用。

ESModule

这个很好理解,无论是 ES6 模块,当你重复引入某个相同的模块时,模块只会执行一次。

1
2
3
4
5
6
7
8
9
// a.js
import './b';
import './b';

// b.js
console.log('只会执行一次');

// 执行结果:
// 只会执行一次

循环依赖问题

解决了重复引用到问题,那么循环依赖的问题就可以解释了

CommonJS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a');
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');

// node a.js
// 执行结果:
// a starting
// b starting
// in b, a.done = false
// b done
// in a, b.done = true
// a done

结合之前讲的特性很好理解,当你从 b 中想引入 a 模块的时候,因为 node 之前已经加载过 a 模块了,所以它不会再去重复执行 a 模块,而是直接去生成当前 a 模块吐出的 module.exports 对象,因为 a 模块引入 b 模块先于给 done 重新赋值,所以当前 a 模块中输出的 module.exports 中 done 的值仍为 false。而当 a 模块中输出 b 模块的 done 值的时候 b 模块已经执行完毕,所以 b 模块中的 done 值为 true。

从上面的执行过程中,我们可以看到,在 CommonJS 规范中,当遇到 require() 语句时,会执行 require 模块中的代码,并缓存执行的结果,当下次再次加载时不会重复执行,而是直接取缓存的结果。正因为此,出现循环依赖时才不会出现无限循环调用的情况。虽然这种模块加载机制可以避免出现循环依赖时报错的情况,但稍不注意就很可能使得代码并不是像我们想象的那样去执行。因此在写代码时还是需要仔细的规划,以保证循环模块的依赖能正确工作。

ESModule

跟 CommonJS 模块一样,ES6 不会再去执行重复加载的模块,又由于 ES6 动态输出绑定的特性,能保证 ES6 在任何时候都能获取其它模块当前的最新值。

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
// a.js
console.log('a starting')
import {foo} from './b';
console.log('in b, foo:', foo);
export const bar = 2;
console.log('a done');

// b.js
console.log('b starting');
import {bar} from './a';
export const foo = 'foo';
console.log('in a, bar:', bar);
setTimeout(() => {
console.log('in a, setTimeout bar:', bar);
})
console.log('b done');

// babel-node a.js
// 执行结果:
// b starting
// in a, bar: undefined
// b done
// a starting
// in b, foo: foo
// a done
// in a, setTimeout bar: 2

动态 import()

ES6 模块在编译时就会静态分析,优先于模块内的其他内容执行,所以导致了我们无法写出像下面这样的代码:

1
2
3
4
5
6
7
8
if(some condition) {
import a from './a';
}else {
import b from './b';
}

// or
import a from (str + 'b');

因为编译时静态分析,导致了我们无法在条件语句或者拼接字符串模块,因为这些都是需要在运行时才能确定的结果在 ES6 模块是不被允许的,所以 动态引入 import() 应运而生。

import() 允许你在运行时动态地引入 ES6 模块,想到这,你可能也想起了 require.ensure 这个语法,但是它们的用途却截然不同的。

  • require.ensure 的出现是 webpack 的产物,它是因为浏览器需要一种异步的机制可以用来异步加载模块,从而减少初始的加载文件的体积,所以如果在服务端的话 require.ensure 就无用武之地了,因为服务端不存在异步加载模块的情况,模块同步进行加载就可以满足使用场景了。 CommonJS 模块可以在运行时确认模块加载。
  • 而 import() 则不同,它主要是为了解决 ES6 模块无法在运行时确定模块的引用关系,所以需要引入 import()

我们先来看下它的用法:

  1. 动态的 import() 提供一个基于 Promise 的 API
  2. 动态的import() 可以在脚本的任何地方使用
  3. import() 接受字符串文字,你可以根据你的需要构造说明符

举个简单的使用例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// a.js
const str = './b';
const flag = true;
if(flag) {
import('./b').then(({foo}) => {
console.log(foo);
})
}
import(str).then(({foo}) => {
console.log(foo);
})

// b.js
export const foo = 'foo';

// babel-node a.js
// 执行结果
// foo
// foo

当然,如果在浏览器端的 import() 的用途就会变得更广泛,比如 按需异步加载模块,那么就和 require.ensure 功能类似了。

二者什么情况下可以混用

Node.js 的区分

Node.js 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"

如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module

1
2
3
{
"type": "module"
}

一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。

1
2
# 解释成 ES6 模块
$ node my-app.js

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

总结为一句话:.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

CommonJS 模块加载 ES6 模块

CommonJS 的require()命令不能加载 ES6 模块,会报错,只能使用import()这个方法加载。

1
2
3
(async () => {
await import('./my-app.mjs');
})();

上面代码可以在 CommonJS 模块中运行。

require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await命令,导致无法被同步加载。

ES6 模块加载 CommonJS 模块

ES6 模块的import命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。

1
2
3
4
5
// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';

这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是module.exports,是一个对象,无法被静态分析,所以只能整体加载。

加载单一的输出项,可以写成下面这样。

1
2
import packageMain from 'commonjs-package';
const { method } = packageMain;

同时支持两种格式的模块

一个模块同时要支持 CommonJS 和 ES6 两种格式,也很容易。

如果原始模块是 ES6 格式,那么需要给出一个整体输出接口,比如export default obj,使得 CommonJS 可以用import()进行加载。

如果原始模块是 CommonJS 格式,那么可以加一个包装层。

1
2
import cjsModule from '../index.js';
export const foo = cjsModule.foo;

上面代码先整体输入 CommonJS 模块,然后再根据需要输出具名接口。

你可以把这个文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的package.json文件,指明{ type: "module" }

另一种做法是在package.json文件的exports字段,指明两种格式模块各自的加载入口。

1
2
3
4
"exports":{ 
"require": "./index.js"
"import": "./esm/wrapper.js"
}

上面代码指定require()import,加载该模块会自动切换到不一样的入口文件。

webpack + babel编译

webpack本身就可以支持用自身的方式去__webpack_exports____webpack_require__去替代CommonJS,ESModule等。

而Babel则是将ES6的import,export转化为CommonJS,也就是说转化后的代码是没有import和export这种写法的。

具体的源码我们另开一篇博客分析,目前的内容已经很多了。