跳到主要内容

CommonJS和ES模块差异

它们有几个重大差异

  • CommonJS 模块输出的是一个值的拷贝; ES6 模块输出的是值的引用。

  • CommonJS 模块是运行时加载; ES6 模块是静态定义的,在编译时输出接口。

CommonJS 输出值的拷贝

// lib.js
var counter = 3
function incCounter() {
counter++
}
module.exports = {
counter: counter,
incCounter: incCounter,
}
// main.js
var mod = require('./lib')

console.log(mod.counter) // 3
mod.incCounter()
console.log(mod.counter) // 3

上面代码说明,lib.js模块加载以后,它的内部变化就影响不到输出的 mod.counter 了。这是因为 mod.counter 是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

// lib.js
var counter = 3
function incCounter() {
counter++
}
module.exports = {
get counter() {
return counter
},
incCounter: incCounter,
}

ES6 模块输出值的引用

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。

// lib.js
export let counter = 3
export function incCounter() {
counter++
}
// main.js
import { counter, incCounter } from './lib'
console.log(counter) // 3
incCounter()
console.log(counter) // 4

另一个示例

// m1.js
export var foo = 'bar'
setTimeout(() => (foo = 'baz'), 500)
// m2.js
import { foo } from './m1.js'
console.log(foo) // bar
setTimeout(() => console.log(foo), 500) // baz

循环加载

CommonJS 模块的循环加载

CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

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

上面代码之中,a.js 脚本先输出一个 done 变量,然后加载另一个脚本文件 b.js。注意,此时 a.js 代码就停在这里,等待 b.js 执行完毕,再往下执行。

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

上面代码之中,b.js 执行到第二行,就会去加载 a.js,这时,就发生了“循环加载”。系统会去 a.js 模块对应对象的 exports 属性取值,可是因为 a.js 还没有执行完,从 exports 属性只能取回已经执行的部分,而不是最后的值。

a.js 已经执行的部分,只有一行。

exports.done = false

因此,对于 b.js 来说,它从 a.js 只输入一个变量 done,值为 false

然后,b.js 接着往下执行,等到全部执行完毕,再把执行权交还给 a.js。于是,a.js 接着往下执行,直到执行完毕。我们写一个脚本 main.js,验证这个过程。

// main.js
console.log('main starting')
const a = require('./a.js')
const b = require('./b.js')
console.log('in main, a.done = %j, b.done = %j', a.done, b.done)

执行 main.js,运行结果如下。

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

所以,输入变量的时候,必须非常小心。

下面代码中,如果发生循环加载,require('a').foo 的值很可能后面会被改写,改用 require('a') 会更保险一点。

var a = require('a') // 安全的写法
var foo = require('a').foo // 危险的写法

exports.good = function (arg) {
return a.foo('good', arg) // 使用的是 a.foo 的最新值
}

exports.bad = function (arg) {
return foo('bad', arg) // 使用的是一个部分加载时的值
}

ES6 模块的循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用 import 从一个模块加载变量(即 import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar)
export let foo = 'foo'
// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo)
export let bar = 'bar'

上面代码中,执行 a.mjs 以后会报错,foo 变量未定义!!!

让我们一行行来看,ES6 循环加载是怎么处理的。首先,执行 a.mjs 以后,引擎发现它加载了 b.mjs,因此会优先执行 b.mjs,然后再执行 a.mjs。接着,执行 b.mjs 的时候,已知它从 a.mjs 输入了 foo 接口,这时不会去执行 a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行 console.log(foo) 的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让 b.mjs 运行的时候,foo 已经有定义了。这可以通过将 foo 写成函数来解决。

// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar())
function foo() {
return 'foo'
}
export { foo }
// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo())
function bar() {
return 'bar'
}
export { bar }

这是因为函数具有提升作用,在执行 import {bar} from './b' 时,函数 foo 就已经有定义了,所以 b.mjs 加载的时候不会报错。这也意味着,如果把函数 foo 改写成函数表达式,也会报错。

参考