人人都会AI编程

19.5 循环引用的运行机制与解决方案

更新时间:2026-07-11

在模块化开发中,循环引用(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.doneundefined。这就是循环引用带来的典型问题——模块在还未完全初始化时就被别的模块导入,导致拿到的只是部分填充的导出对象

19.5.2 CommonJS 中的循环引用机制

CommonJS 模块规范是 Node.js 默认的模块系统,它采用同步加载和值拷贝的方式。循环引用的表现完全取决于其内部运行机制:

  1. 模块缓存:Node.js 通过 require.cache 缓存所有已加载的模块。当再次 require 同一个模块时,会直接从缓存中取出模块的 exports 对象,而不会重新执行模块代码。
  2. 执行顺序:当遇到 require('./a') 时,Node.js 会先检查缓存。如果缓存中没有,就会新建一个空的 exports 对象并放入缓存,然后开始执行模块代码。注意:这个缓存是在模块代码执行之前就完成的。
  3. 过程推演
  • 运行 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.jsb 拿到了完整的 { value: 42 },继续执行到最后,aexports 被重新赋值(或填充)为 { 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.mjsb.mjs 又回到 a.mjs,但此时引擎发现 a.mjs 已经解析过,它可以读取已经执行了的导出绑定。但由于 a.mjs 还未执行完,aDoneb.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.jscommon.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 的动态绑定虽然有所改善,但仍可能触发暂时性死区。解决循环引用的核心思路永远是重构依赖关系,变双向为单向。若短期内无法重构,函数包装和动态导入是实用的临时解药。记住:清晰的模块边界和良好的架构设计,是避免循环引用的最佳防线。