循环依赖

什么是循环依赖

循环依赖是模块化机制下的一种现象。循环依赖是指当模块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却不会有报错提示,只能靠开发者自己检测,这很容易出现意想不到的问题,因此最好的办法是优化模块逻辑来避免循环依赖的情况。

最后更新于