循环依赖

什么是循环依赖

循环依赖是模块化机制下的一种现象。循环依赖是指当模块a依赖于模块b,但是模块b又依赖于模块b的情况。ES Module和CommonJs两种模块化机制对循环依赖的处理是不相同。
可以用下面这个例子来理解循环依赖:
// a.js const obj = require("./b.js"); exports.a = 1; console.log(obj); // b.js const { a } = require("./a.js"); const obj = { a, }; module.exports = obj
在上面的例子中,模块a引用了模块b的obj对象,但是模块b的obj对象的a属性却又来源于模块a,这就导致了两个模块互相依赖,产生了循环依赖。
 

CommonJs中的循环依赖

上面这段代码在CommonJs模块化机制下的输出是undefined 。这是由于CommonJs的实现机制决定的。
对于上面的代码,我们想要的结果应当是obj = {a: 1},但是实际上结果确是 {a: undefined} 。并且nodejs还会提示你出现了循环依赖。
在介绍具体的原理前我们需要知道几个知识点:
  • CommonJs实现模块化的方式是利用对象的方式实现的,导入和导出的都是js对象
  • require函数导入的实际上是另一个模块的exports对象,exports默认是空对象,即{},并且exports和module.exports是同一个对象。
由此可以分析出,当模块a执行到require("./b.js") 语句时,此时模块a的exports对象是空的,即exports = {} ,这个时候js会执行模块b中的代码,当执行到require("./a.js") 时,由于模块a已经执行过了,所以不会再执行,而是直接返回模块a的exports对象,前面分析过模块a的exports对象是空对象,因此require("./a.js") 返回的也是这个空对象,因此a=undefined ,之后再将obj对象导出,模块a的require("./b.js") 就返回了{a: undefined}
 
你可以通过debugger的方式来分析此过程。
// a.js debugger; // 执行到require函数调用语句时会立即进入模块b中执行代码 // 注意这里require()不会马上返回结果,要等模块b代码都执行完毕后才会返回模块b的exports对象 const obj = require("./b.js"); // 导出变量a exports.a = 1; console.log(obj); // 结果: {a: undefined} // b.js // require("./a.js")返回的是模块a的exports的对象,由于此时模块a还未导出,因此a是undefined。 const { a } = require("./a.js"); const obj = { a, }; // 模块b导出对象obj module.exports = obj
 

ES Module中的循环依赖

同样是上面的例子,我们改写成ES Module(简称:ESM)的写法来看一下。
// a.mjs import obj from "./b.mjs"; export let a = 1; console.log(obj); // b.mjs import { a } from "./a.mjs"; export default { a, }; // node a.mjs // ReferenceError: Cannot access 'a' before initialization
这次的结果和CommonJs中的结果不同,会直接报错,报错信息提示不能在变量a初始化之前访问。
导致CommonJs和ES Module对循环依赖不同现象的原因是CommonJs是通过js对象来导入导出的,它的导入是在运行时确定的,require是一个函数,exports是一个对象;而ES Module(ESM)的导入导出(importexport)是一种JavaScript语法,ESM是静态的,它有一个静态编译阶段,它的导入导出是在编译时确定的。
首先js引擎执行到import obj from "./b.mjs"; 语句后会执行模块b的代码,在模块b中的又导入了模块a的变量a,此时js引擎会认为从模块a中导入的变量a已经定义好了,并不会去执行模块a的代码,而是继续执行模块b后面的代码,知道执行到export default {a}; 时去访问变量b,才发现并没有导入成功,因此报错。
 

解决循环依赖

提前导出

在CommonJs中,由于导出导入都是通过js对象实现的,因此我们可以通过提前导出来避免出现循环依赖的问题。
// a.js // 导出语句先执行 exports.a = 1; const obj = require("./b.js"); console.log(obj);
当然这里也有弊端,如果导出的变量取决于导入的值,那么就不能用这个方法。
// a.js // 当导出的是明确的值时,可以提前导出 // exports.a = 1; // 但是如果导出的值取决于导入的值时,那么就会出问题了 exports.b = obj.b; const obj = require("./b.js"); console.log(obj); // b.js const { a } = require("./a.js"); const b = 1; const obj = { a, b, }; module.exports = obj; // node a.js // Uncaught ReferenceError ReferenceError: Cannot access 'obj' before initialization
另外在ESM中也无法起作用。

利用函数提升

在ESM即使你把export写在import语法的前面,js引擎也仍然会先执行import语句,解决的方法是利用函数提升来实现提前导出。
// a.mjs import obj from "./b.mjs"; console.log(obj); export function a() { return 1; } // b.mjs import { a } from "./a.mjs"; export default { a: a(), };
注意只有函数声明才会有函数提升,其他例如箭头函数、函数表达式等都没有函数提升现象,因此无法其作用。
这种方法的思路仍然是提前导出,只不过它利用了js函数提升的特性实现。而在CommonJs中由于导入导出都是通过对象完成的,因此可以直接吧导出语句写在导入语句的前面来实现。

优化模块逻辑

循环依赖很容易出现bug,在ESM下会报错提示,但是在CommonJs却不会有报错提示,只能靠开发者自己检测,这很容易出现意想不到的问题,因此最好的办法是优化模块逻辑来避免循环依赖的情况。