在依赖关系复杂的大型项目中,很容易出现依赖循环加载的情况。即 a 模块加载了 b 模块,b 模块又加载了 a 模块。因此模块化方案必须要考虑到循环加载的情况。不过开发中应尽量避免模块循环加载的情况,因为出现循环加载,意味着循环加载的模块之间存在强耦合,并且容易出现 bug 。
CommonJS 模块和 ES 模块都支持模块的循环加载,他们的处理方式不一样,返回的结果也不一样。
CommonJS 循环加载
当 CommonJS 模块遇到循环加载时,会只输出模块代码中已经执行的部分,还未执行的部分不会输出。具体可看一下关于 CommonJS 模块循环加载的经典例子:
定义 a 模块,主要逻辑很简单,就是导出值为 false 的变量 done
,然后加载 b 模块,最后将变量 done
设置为 true
// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
定义 b 模块,主要逻辑也很简单,就是导出值为 false 的变量 done
,然后加载 a 模块,以此与 a 模块循环加载,最后将变量 done
设置为 true
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
定义 main 模块,分别引入 a 、b 模块,并输出 a 、b 模块中导出的变量 done
的值
// main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
在终端执行 main 模块,执行结果为:
具体代码的执行过程是这样的,在 main 模块中,首先加载 a 模块,然后进入 a 模块的代码中,执行 a 模块中的第一行代码:
// a.js
exports.done = false;
导出变量 done
,并且值为 false ,然后执行第二行代码,加载 b 模块,由于 CommonJS 模块是运行时且同步加载的,因此,a 模块后面的代码会被阻断执行,此时进入 b 模块中,执行 b 模块的代码。
进入 b 模块和,便执行 b 模块的第一行代码:
// b.js
exports.done = false;
导出变量值为 false 的变量 done
,然后执行第二行代码,即加载 a 模块的代码,然后输出 a 模块导出的变量 done
的值。
// b.js
console.log('在 b.js 之中,a.done = %j', a.done);
由于 a 模块的代码还未执行完,因此此时 a 模块导出的变量 done
值为 false ,所以在终端输出:在 b.js 之中,a.done = false
然后继续执行 b 模块的代码,将导出的变量 done
设置为 true 。
b 模块的代码执行完后,代码的执行权回到模块 a 中,因为 b 模块的代码已执行完,最终 done
变量为 true,因此在终端输出:在 a.js 之中,b.done = true
最后执行 a 模块剩余代码,将变量 done
的值设为 true。
最后代码执行权回到 main 模块中,输出:在 main.js 之中, a.done=true, b.done=true
通过上面代码执行过程的分析可知道,当 CommonJS 模块遇到循环加载时,会只输出模块代码中已经执行的部分,还未执行的部分不会输出。因此当 CommonJS 模块遇到循环加载时,模块内得到的值可能不是最终的值,具体取决于代码执行到了哪里,所以极易产生 bug 。
为啥 CommonJS 模块的循环加载没有死循环?
为啥 CommonJS 模块的循环加载没有死循环?因为 CommonJS 模块加载器在加载模块时,会在内存中产生一个模块缓存,当 CommonJS 模块加载器在加载模块时,会先在缓存中查找是否有该模块,如果有就直接在缓存中获取该模块,而不会再次加载该模块,从而避免了无限循环加载的情况。这也是在一个模块中,加载同一个模块多次,该模块的代码只会执行一次的原因。
还是那个经典的例子:
// main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
然后利用 VSCode 的断点调试功能查看代码的执行过程。可以发现在 require 属性下面有个 cache
属性,cache
属性就是模块缓存。
首先从入口模块 main 模块开始执行代码,这步会将 main 模块加入到模块缓存中,main 模块中会加载 a 模块,因此 a 模块也会加入到模块缓存中,然后执行 a 模块的代码。
// main.js
var a = require('./a.js');
a 模块中会先导出值为 false 的变量 done
,然后加载 b 模块,此时 b 模块便会加入到模块缓存中。
// a.js
exports.done = false;
var b = require('./b.js');
因为 CommonJS 的模块是运行时同步加载的,a 模块后续的代码会中断执行,然后进入到 b 模块,执行 b 模块的代码。
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
b 模块首先会导出值为 false 的变量 done
,然后加载 a 模块,在加载 a 模块之前,会先在模块缓存中查找是否存在 a 模块,然后发现已经存在 a 模块,则直接从模块缓存中获取 a 模块,而不会再次加载 a 模块的逻辑,从而避免了 a 、b 模块无限循环加载的问题。
然后在模块缓存中找到了 a 模块导出的变量 done
的值为 false:
因此在终端也输出了 done
的值为 false
ES 模块循环加载
ES 模块的运行机制与 CommonJS 模块的不一样,ES 模块导出的是值的引用,当模块内部的值变化时,外部能够通过该引用得到模块内部最新的值。当 ES 模块发遇到循环加载时,需要开发者自己保证,真正取值的时候能够取到值。
具体看下面的例子:
a 模块中定义 foo 函数,然后执行 foo 函数,并在 foo 函数内部调用从 b 模块中引入的 bar 函数:
// a.js
import { bar } from './b.js'
export function foo() {
console.log('执行foo')
bar();
console.log('执行完毕')
}
foo()
b 模块中定义 bar 函数,在 random 大于 0.5 的情况下执行从 a 模块中引入的 foo 函数。
// b.js
import { foo } from './a.js'
export function bar() {
const random = Math.random()
console.log('random ', random)
console.log('foo ', foo)
if (random > 0.5) {
foo();
}
}
由于 ES 模块导出的是值的引用,虽然 a 、b 模块是循环引用的关系,但是在 b 模块中能够正常通过引用拿到 a 模块中 foo 函数的值,所以代码能正常运行。
如果上面的代码按照 CommonJS 模块规范改造是无法正常运行的,因为 CommonJS 模块输出的是值的拷贝,b 模块引用 a 模块时,a 模块没有导出任何值,所以 foo 函数是 undefined ,根本无法执行。
// a.js
var bar = require('./b.js').bar
function foo() {
console.log('执行foo')
bar();
console.log('执行完毕')
}
exports.foo = foo
foo()
// b.js
var foo = require('./a.js').foo
function bar() {
const random = Math.random()
console.log('random ', random)
console.log('foo ', foo)
if (random > 0.5) {
foo();
}
}
exports.bar = bar
在终端运行 a 模块代码,发现 b 模块输出的 foo 函数是 undefined。
使用 VSCode 断点调试的功能,会发现 b 模块加载完成后,a 模块没有导出任何值,因此 bar 函数中的 foo 函数是 undefined
ES 模块化规范不愧是语言标准层面支持的模块化规范,循环加载的情况方式比 CommonJS 模块化规范要好。这也进一步地启发我们要向语言标准看齐。
为啥 ES 模块的循环加载没有死循环?
也是因为缓存
ES 模块的设计思想是尽量的静态化,模块的代码在执行前会通过静态分析,确定不同模块之间的依赖关系,构建模块依赖图。不同模块之间的联系来自于模块中使用的 import 语句。
静态分析是指在不执行代码的情况下,通过分析代码本身的结构、语法和语义来获取代码的信息。
构建整个模块的依赖图有三个主要的步骤:
-
构造:寻找,下载并解析所有文件成模块记录
-
实例化:在内存中寻找位置存放所有导出的值(注意,此时内存地址上还没有填充上具体的值)然后让导出和导入都执行指向这些内存中的位置。同一变量的导出(export)和导入(import)会指向同一内存地址,当导出模块改变了其中导出的某个值,这个变化会迅速显现在导入模块中。这是 CommonJS 模块做不到的
-
求值:执行编码并给实例会中所对应的内存的位置填充实际的值
为了提升模块加载的性能,模块加载器会对模块实例进行缓存。对于特定全局作用域中的每一个模块,都只会拥有一个模块实例。这也可减少 JS 引擎的工作量。比如:一个模块文件只会获取一次,即使有多个模块同时依赖于他。
在构建整个模块依赖图的过程中,模块加载器通过一个叫 模块映射(module map)
的东西来管理模块缓存。模块映射(module map)
是模块 URL 和模块记录组成的 Map 数据结构。
在循环依赖中,比如 a 模块依赖 b 模块,b 模块依赖 a 模块,在执行 a 模块代码的时候,a 模块实例会放到 模块映射(module map)
的缓存中,执行 b 模块的时候,会直接在模块映射(module map)
的缓存中取到 a 模块导出的值,不会再去加载 a 模块,避免了死循环。
总结
模块的作用是更好地管理变量,有了模块,你可以显示地指定(导出
)哪些变量可以给外部使用,显示地指定(导入
)需要哪些变量,使得模块之间的依赖关系变得清晰。在模块未出现之前,只有函数作用域、全局作用域,如果某些变量需要在多个函数之间共享,则会将该变量放到上层的作用域中,比如:全局作用域。当项目越来越庞大,很难避免变量命名冲突、依赖关系混乱的问题。
而循环依赖(循环加载),也会使得模块之间的依赖关系变得复杂,虽然当前的模块化规范支持循环依赖,但是开发中应尽量避免。
CommonJS 模块和 ES 模块均支持循环加载,不过 ES 模块支持地会更好。CommonJS 模块和 ES 模块遇到循环加载不会死循环的原因都是缓存。
CommonJS 是运行时同步加载,在加载模块的时候会在内存中创建缓存对象,记录已经加载过的模块,当加载模块的时候,会先在模块中查找是否已经加载过该模块了,如果在缓存中找到了该模块,则不会执行该模块的代码,执行加载模块的逻辑,避免了无限循环加载。
ES 模块在加载过程中会依据模块中使用的 import 语句构建模块依赖图,在构建模块依赖图的过程中会实例化各个模块,创建模块记录,模块记录会放到 模块映射(module map)
中。模块映射(module map)
会缓存每个模块实例,在加载模块前,也会先在缓存中查找是否存在相应的模块实例,如果有,则不会执行加载模块的逻辑,因此在循环依赖(加载)中,避免了无限循环加载。