在模块化开发中,循环引用(Circular Dependency)是一个经典且隐蔽的问题。它指的是两个或多个模块互相引用,形成了一个闭环。比如模块 A 导入模块 B,模块 B 又直接或间接地导入模块 A。虽然循环引用在小型项目中不常见,但在大型项目中,随着模块依赖关系越来越复杂,很容易不小心写出循环引用。它不仅会导致意外的 undefined,还会让代码难以调试和维护。因此,理解其运行机制并掌握解决方案,是每个前端工程师的必备技能。
19.5.1 循环引用的症状
来看一个简单的循环引用例子:
// a.js
const b = require('./b');
console.log('a.js: b.value =', b.value);
module.exports = { done: true };
// b.js
const a = require('./a');
console.log('b.js: a.done =', a.done);
module.exports = { value: 42 };
当运行 node a.js 时,你会发现 b.js 中打印的 a.done 是 undefined。这就是循环引用带来的典型问题——模块在还未完全初始化时就被别的模块导入,导致拿到的只是部分填充的导出对象。
19.5.2 CommonJS 中的循环引用机制
CommonJS 模块规范是 Node.js 默认的模块系统,它采用同步加载和值拷贝的方式。循环引用的表现完全取决于其内部运行机制:
- 模块缓存:Node.js 通过
require.cache缓存所有已加载的模块。当再次require同一个模块时,会直接从缓存中取出模块的exports对象,而不会重新执行模块代码。 - 执行顺序:当遇到
require('./a')时,Node.js 会先检查缓存。如果缓存中没有,就会新建一个空的exports对象并放入缓存,然后开始执行模块代码。注意:这个缓存是在模块代码执行之前就完成的。 - 过程推演:
- 运行
a.js,缓存中存入一个a模块的空exports。 - 执行
a.js第一行:const b = require('./b'),此时暂停a的执行,去加载b。 b.js被缓存,并开始执行。执行到const a = require('./a')时,由于a已经在缓存中,所以直接返回那个还未执行完的空exports对象。b.js继续执行,打印a.done得到undefined。b.js执行完毕,将{ value: 42 }赋值给module.exports。- 回到
a.js,b拿到了完整的{ value: 42 },继续执行到最后,a的exports被重新赋值(或填充)为{ done: true }。但b模块已经结束了,它拿到的a还是那个初始的空对象。
正是“先缓存后执行”的特性导致了部分导出对象的暴露。如果循环引用中使用了解构赋值(const { something } = require('./a')),结果也同样是 undefined,因为解构时取到的值是缓存中的快照。
19.5.3 ES Modules 中的循环引用机制
ES Modules(ESM)采用的是静态导入和动态绑定。它与 CommonJS 的差异决定了循环引用时的表现截然不同:
- 动态绑定:ESM 的
import导入的是实时绑定引用,而不是值的拷贝。就像是指针或引用,指向的是导出模块的当前值。 - 模块初始化:ESM 模块在解析阶段就会建立好所有导入导出的映射关系(Module Map)。当执行时,如果遇到循环引用,会先执行被导入模块,但不会阻塞等待——如果导入的是一个未初始化的变量,引擎会使用“暂时性死区”(TDZ)的机制,在访问时抛出 ReferenceError 或等到变量被赋值后再正常读取。
看一个简单的 ESM 循环引用例子:
// a.mjs
import { bValue } from './b.mjs';
console.log('a: bValue =', bValue);
export const aDone = true;
// b.mjs
import { aDone } from './a.mjs';
console.log('b: aDone =', aDone);
export const bValue = 42;
如果你在支持 ESM 的环境中(如现代浏览器或 Node.js 的 --experimental-modules)运行 a.mjs,往往 aDone 能够得到期望的值(true),而非 undefined。原因在于 ESM 的“传递执行”:a.mjs 开始执行,遇到 import 语句时,会先执行 b.mjs;b.mjs 又回到 a.mjs,但此时引擎发现 a.mjs 已经解析过,它可以读取已经执行了的导出绑定。但由于 a.mjs 还未执行完,aDone 在 b.mjs 中被访问时可能仍处于 TDZ(取决于执行顺序),如果访问了还未初始化的导出变量,就会抛出 ReferenceError。因此,ESM 的循环引用更多表现为暂时性死区错误而非静默的 undefined。
虽然 ESM 比 CommonJS 更“聪明”,但仍然可能导致不可预测的行为,尤其是在复杂的循环依赖中。最好的策略依然是尽可能避免循环引用。
19.5.4 常见解决方案
1. 重构模块结构,消除循环依赖
这是最彻底、最推荐的做法。分析模块之间的耦合点,将共同依赖的部分提取到第三个模块,让原本两个模块都去依赖这个公共模块,而不是互相依赖。
// 重构前
// user.js 引用 order.js,order.js 引用 user.js
// 重构后
// user.js -> shared.js
// order.js -> shared.js
通常可以使用依赖倒置或依赖注入来解耦。如果两个模块都需要对方的某个类型或工具函数,可以把它们提取到 utils.js 或 common.js 中。
2. 调整导出内容的粒度,使用函数/类延迟访问
如果循环引用难以立即消除,可以将导出的对象改为函数,在函数内部再去引用对方模块,利用函数的延迟执行来避开模块初始化阶段的顺序问题。
// a.js
const b = require('./b');
function getB() {
return b.doSomething();
}
// 不直接导出依赖于 b 的静态值,而是导出函数
module.exports = { getB };
// b.js
const a = require('./a');
function doSomething() {
return a.getB();
}
module.exports = { doSomething };
对于 ESM,同样可以使用函数包装,以避免在模块顶层直接访问可能处于 TDZ 的变量。
3. 动态导入(Dynamic Import)
ESM 提供了 import() 函数,可以在运行时异步加载模块。这样可以将循环引用的加载推迟到所有模块都初始化完毕之后。
// a.js
let b;
export function setB(module) { b = module; }
export function useB() { b.doSomething(); }
// b.js
import('./a.js').then(a => {
a.setB({ doSomething() {} });
});
注意动态导入会返回 Promise,这可能需要调整调用方式,但能有效打破同步加载时的循环死锁。
4. 拆分入口,重新设计依赖方向
如果项目中有多个入口模块出现了循环,可以考虑重新审视整体架构,明确单向依赖链。比如在大型项目中,可以定义“核心层 -> 业务层 -> 应用层”的依赖规则,通过工具(如 ESLint 插件 import/no-cycle)强制规避。
5. 借助构建工具处理
Webpack、Vite 等现代打包工具在打包过程中能检测并警告循环依赖。Webpack 的 CircularDependencyPlugin 插件可以在构建时给你提示,让你及早发现问题。虽然打包工具可能会尝试通过异步分包或代码分割来减轻影响,但这不能从根源上解决设计问题。
19.5.5 检测与预防最佳实践
- 使用 ESLint 插件:安装
eslint-plugin-import并开启import/no-cycle规则,可以自动检测项目中的循环引用。 - 绘制依赖图:使用
madge等工具生成模块依赖关系图,可视化地发现循环。 - 培养模块设计的单向思维:在设计模块时,尽量保持依赖方向清晰,上层依赖下层,不要反向。时刻问自己:“这个模块真的需要依赖它的调用者吗?”
19.5.6 小结
循环引用是模块化编程中一个复杂但可控的问题。CommonJS 因缓存机制导致的“半成品”导出,往往是很多诡异 bug 的来源;ESM 的动态绑定虽然有所改善,但仍可能触发暂时性死区。解决循环引用的核心思路永远是重构依赖关系,变双向为单向。若短期内无法重构,函数包装和动态导入是实用的临时解药。记住:清晰的模块边界和良好的架构设计,是避免循环引用的最佳防线。