CommonJS 和 ES6module 的区别
动态与静态
CommonJS 与 ES6 Module 最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段:而“静态”则表示模块依赖关系的建立发生在代码编译阶段。
看一个 CommonJS 的例子:
// calculator.js
module.exports = { name: 'calculator' };
// index.js
const name = require('./calculator.js').name;
模块 A 在加载模块 B 时会执行 B 中的代码,并将其 module.exports
对象作为 require
函数的返回值返回。require的模块路径可以动态指定,支持传入一个表达式,甚至可以通过 if
语句判断是否加载某个模块。因此,在 CommonJS 模块被执行前,我们并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。
针对同样的例子,我们再对比看下 ES6 Module 的写法:
// calculator.js
export const name ='calculator';
// index.js
import name from './calculator.js';
ES6 Module 的导入、导出语句都是声明式的,它不支持将表达式作为导入路径,并且导入、导出语句必须位于模块的顶层作用域(比如不能放在语句中)。因此我们说,ES6 Module是一种静态的模块结构,在 ES6 代码的编译阶段就可以分析出模块的依赖关系。它相比 CommonJS 来说具备以下几点优势:
- 死代码检测和排除。我们可以用静态分析工具检测出哪些模块没有被调用过。比如,在引入工具类库时,工程中往往只用到了其中一部分组件或接口,但有可能会将其代码完整地加载进来。未被调用到的模块代码永远不会被执行,也就成了死代码。通过静态分析可以在打包时去掉这些未曾使用过的模块,以减小打包资源体积。
- 模块变量类型检查。JavaScript 属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。ES6 Module 的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。
- 编译器优化。在 CommonJS 等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而 ES6 Module 支持直接导入变量,减少了引用层级,程序效率更高。
值复制与动态映射
在导入一个模块时,对于 CommonJS 来说获取的是一份导出值的副本;而在 ES6 Module 中则是值的动态映射,并且这个映射是只读的。看一个例子,了解一下什么是 CommonJS 中的值复制:
// calculator.js
var count = 0;
module.exports = {
count: count,
add: function(a,b){
count += 1;
return a + b;
}
}
// index.js
var count = require('./calculator.js').count;
var add = require('./calculator.js').add;
console.log(count); // 0(这里的count是calculator.js中count值的副本)
add(2, 3);
console.log(count); // 0(calculator.js中变量值的改变不会对这里的副本造成影响)
count += 1;
console.log(count); // 1(副本的值可以更改)
index.js 中的 count
是 calculator.js 中 count
的一份副本,因此在调用 add
函数时,虽然更改了原本 calculator.js 中 count
的值,但是并不会对 index.js 中导入时创建的副本造成影响。
另一方面,在 CommonJS 中允许对导入的值进行更改。我们可以在 index.js 中更改 count
和 add
,将其赋予新值。同样,由于是值的副本,这些操作不会影响 calculator.js 本身。
下面使用 ES6 Module 对上面的例子进行改写:
// calculator.js
let count = 0;
const add = function(a, b){
count += 1;
return a + b;
}
export { count, add }
// index.js
import { count, add } from'./calculator.js';
console.log(count); // 0(对calculator.js中count值的映射)
add(2, 3);
console.log(count); // 1(实时反映calculator.js中count值的变化)
// count += 1; // 不可更改,会抛出SyntaxError:"count”is read-only
上面的例子展示了 ES6 Module 中导入的变量其实是对原有值的动态映射 index.js 中的 count
是对 calculator.js 中 count
值的实时反映,当我们通过调用 add
函数更改了 calculator.js 中的 count
值时,index.js 中 count
的值也随之变化。并且 ES6 Module 规定不能对导入的变量进行修改,当我们尝试去修改时它会抛出该变量只读的错误。
循环依赖
循环依赖是指模块 A 依赖于模块 B ,同时模块 B 依赖于模块 A ,或者是 A 依赖于 B ,B 依赖于 C ,C 依赖于 D ,最后绕了一大圈,D 又依赖于 A 。当中间模块太多时我们就很难发现 A 和 B 之间存在隐式的循环依赖了。
因此,如何处理循坏依赖是开发者必须要面对的问题。首先看一下在 CommonJS 中循环依赖的例子:
// foo.js
const bar = require('./bar.js');
console.log('value of bar:', bar);
module.exports ='This is foo.js';
// bar.js
const foo = require('./foo.js');
console.log('value of foo:', foo);
module.exports ='This is bar.js';
// index.js
require('./foo.js');
而当我们运行上面的代码时,实际输出却是:
value of foo:{}
value of bar:This is bar.js
为什么 foo 的值会是一个空对象呢?让我们从头梳理一下代码的实际执行顺序:
- index.js 导入了 foo.js ,此时开始执行 foo.js 中的代码。
- foo.js 的第 1 句导入了 bar.js ,这时 foo.js 不会继续向下执行,而是会进入 bar.js 内部。
- 在 bar.js 中又对 foo.js 进行了导入,这里产生了循环依赖。需要注意的是,执行权并不会再交回 foo.js,而是直接取其导出值,也就是
module.exports
。但由于 foo.js 未执行完毕,导出值在这时为默认的空对象,因此当 bar.js 执行到打印语句时,我们看到控制台中的 value of foo 就是一个空对象。 - bar.js 执行完毕,将执行权交回 foo.js 。
- foo.js 从
require
语句继续向下执行,在控制台打印出 value of bar (这个值是正确的),整个流程结束。
接下来我们使用 ES6 Module 的方式重写上面的例子:
// foo.js
import bar from './bar.js'
console.log('value of bar:', bar);
export default 'This is foo.js';
// bar.js
import foo from './foo.js';
console.log('value of foo:', foo);
export default This is bar.js';
// index.js
import foo from './foo.js';
执行结果如下:
value of foo:undefined
foo.js:3 value of bar:This is bar.js
很遗憾,在 bar.js 中同样无法得到 foo.js 正确的导出值,只不过和 CommonJS 默认导出一个空对象不同,这里获取到的是 undefined 。
上面我们谈到,在导入一个模块时,CommonJS 获取到的是值的副本,ES6 Module 则是动态映射,那么我们能否利用 ES6 Module 的特性使其支持循环依赖呢?请看下面这个例子:
// index.js
import foo from './foo.js';
foo ('index.js');
// foo.js
import bar from './bar.js'
function foo(invoker){
console.log(invoker + 'invokes foo.js');
bar ('foo.js');
}
export default foo;
// bar.js
import foo from './foo.js'
let invoked = false;
function bar (invoker){
if(!invoked){
invoked = true;
console.log(invoker + 'invokes bar.js');
foo ('bar.js');
}
}
export default bar;
上面代码的执行结果如下:
index.js invokes foo.js
foo.js invokes bar.js
bar.js invokes foo.js
可以看到,foo.js 和 bar.js 这一对循环依赖的模块均获取到了正确的导出值。下面让我们分析一下代码的执行过程。
- index.js 作为入口导入了 foo.js ,此时开始执行 foo.js 中的代码。
- 从 foo.js 导入 bar.js ,,执行权交给 bar.js 。
- 在 bar.js 中一直执行到结束,完成 bar 函数的定义。注意,此时由于 foo.js 还没执行完,
foo
的值现在仍然是undefined
。 - 执行权回到 foo.js 继续执行直到结束,完成
foo
函数的定义。由于 ES6 Module 动态映射的特性,此时在 bar.js 中foo
的值已经从undefined
成为我们定义的函数,这是与 CommonJS 在解决循环依赖时的本质区别,CommonJS 中导入的是值的副本,不会随着模块中原有值的变化而变化。 - 执行权回到 index.js 并调用
foo
函数,此时会依次执行 foo一bar一foo ,并在控制台输出正确的值。
由上面的例子可以看出,ES6 Module 的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。