人人都会AI编程

5.2 循环引用的产生原因与运行结果

更新时间:2026-07-11

在实际项目中,模块之间的依赖关系并不总是单向的。当模块 A 依赖模块 B,而模块 B 又依赖模块 A 时,就会形成循环引用。这种场景在业务代码里并不少见:用户模块需要引用订单模块的某些工具函数,而订单模块又需要从用户模块获取用户信息。Node.js 的 CommonJS 模块系统对循环引用有明确的处理规则,但运行结果往往出乎初学者的意料,理解其内部机制对于排查相关问题至关重要。

5.2.1 循环引用产生的原因

循环引用的根本原因是依赖关系出现了环路。在 CommonJS 规范下,require() 函数的工作流程是:

  1. 解析模块的绝对路径作为唯一标识。
  2. 检查缓存 require.cache,如果已经加载过,直接返回缓存的 module.exports 对象。
  3. 如果未加载,创建新的 module 对象并放入缓存,然后执行模块代码,最后返回 module.exports

这里的第 2 步“先缓存后执行”正是处理循环引用的关键。当 A 加载 B,而 B 又回过头加载 A 时,A 已经有一个未完成的 module 对象缓存在 require.cache 中。B 获取到的就是这个尚未执行完毕的半成品 exports

举个具体的例子。假设有这样的目录结构:

project/
├── a.js
└── b.js

a.js 的内容:

console.log('a.js 开始加载');
const b = require('./b');
exports.message = 'Hello from A';
console.log('a.js 加载完毕,b.message =', b.message);

b.js 的内容:

console.log('b.js 开始加载');
const a = require('./a');
exports.message = 'Hello from B';
console.log('b.js 加载完毕,a.message =', a.message);

当我们执行 node a.js 时,加载过程如下:

  1. a.js 作为入口模块,系统创建 a 模块,将其放入缓存(此时 module.exports{}),开始执行 a.js 代码。
  2. 执行到 require('./b') 时,b.js 开始加载。系统创建 b 模块,放入缓存,开始执行 b.js。
  3. b.js 执行到 require('./a'),发现 a 模块已在缓存中,直接返回此刻 a 模块的 exports 对象(仍然为 {},因为 a 还没执行完)。
  4. b.js 继续执行,设置 exports.message = 'Hello from B',打印 b.js 加载完毕,a.message = undefined(因为 a 的 exports 还是空对象)。
  5. b.js 执行完毕,控制权回到 a.js。a.js 接收到 b 的完整 exports 对象,其中 message 已是 'Hello from B'
  6. a.js 继续执行,设置自己的 exports.message = 'Hello from A',打印 a.js 加载完毕,b.message = Hello from B

控制台输出为:

a.js 开始加载
b.js 开始加载
b.js 加载完毕,a.message = undefined
a.js 加载完毕,b.message = Hello from B

可以看到,在 b.js 中获取到的 a 是一个空对象,a.messageundefined,因为那时 a 模块还没有导出任何属性。这就是循环引用最典型的运行结果:被循环引用的模块可能会拿到一个不完整的导出对象

5.2.2 不同加载顺序的差异

循环引用的表现还取决于首先加载哪个模块。如果执行 node b.js,加载过程对称,但输出的顺序和现象会略有不同,b.js 中拿到的 a 同样在早期不完整,只不过末尾的展示不同。总体规律不变:在模块代码执行到 require 那一刻之前,被依赖方的 exports 只有已经执行的顶层代码所赋的值。

另一个常见情况是:如果导出的是函数或类等引用类型,并且在模块被循环引用时尚未完成赋值,则会获取到 undefined。例如 a.js 写成:

exports.getMsg = () => 'Hello A';
const b = require('./b');

b.js 中在 a 执行完 exports.getMsg 之前就获取 a,那么 a.getMsg 此时已经是可用的函数。因为函数声明(箭头函数赋值)在 require 之前就执行了。所以,只要导出的内容在循环引用点之前已经定义好,就能正常使用。这就是为什么将导出提前到模块顶端,或者采用“在构造函数中延迟使用”的方式可以避免很多问题。

5.2.3 循环引用的设计原则与避免策略

Node.js 对循环引用的处理是被动容忍而非主动报错。当出现循环引用时,开发者拿到的可能是未完全初始化的 exports 对象,这就会引发运行时错误,比如类型错误(xxx is not a function)或 undefined 访问。为了避免这种情况,可以参考以下策略:

  1. 重构模块结构,打破循环依赖

最彻底的方案是抽取出共同依赖的公共模块。比如 A 和 B 互相引用,可以把双方都需要的功能提取到 C 中,让 A 和 B 各自依赖 C,从而消除环路。这样既保持了模块职责单一,又避免了循环。

  1. require 放到函数内部(延迟加载)

如果无法立即打破循环,可以将某个 require 语句移到实际使用时才调用的函数内部,而不是放在模块的顶层。因为只有模块顶层代码在首次加载时执行,而函数体内的 require 会在函数被调用时才执行,此时所有模块早已加载完毕,不会出现半成品 exports。例如:

   // a.js
   module.exports = { name: 'A' };
   
   // 延迟加载 b
   function getB() {
     return require('./b');
   }
   

这种方法虽然有效,但会模糊模块依赖关系,不宜滥用。

  1. 将导出逻辑前置

确保模块在被其他模块 require 之前,关键属性或方法已经完成了挂载。例如在文件顶部先执行 exports.x = ...,然后再 require 其他模块。这样对方拿到的 exports 对象至少包含了已定义的部分。

  1. 使用 ES Modules 的静态引用特性(长期方案)

尽管 Node.js 对 ESM 的循环引用也无法完全避免,但 ESM 的 import 语句是静态分析的,导出的是绑定引用,而不是值的拷贝。这意味着在循环引用时,访问到的导出值是动态绑定的,不会出现 CommonJS 中那种拿到空对象的问题(但仍需注意变量提升导致的暂时性死区)。如果你的项目已全面迁移到 ESM,循环引用的容错性会更好一些。

5.2.4 真实场景中的循环引用排查

当项目中出现类似 TypeError: x is not a functionundefined is not an object 且错误发生在模块加载阶段时,就应该警觉是否存在循环引用。可以通过以下方式排查:

  • 在 Node.js 启动时添加 --trace-warnings 或查看完整错误堆栈,看是否发生在 require 过程中。
  • 使用动态分析工具如 madge 来生成依赖图,查找循环依赖:
  npx madge --circular --extensions js .
  

它会列出项目中存在循环引用的文件路径。

  • 在调试中加入 console.log 打印 module.exports 在各个阶段的状态,观察对象内容的变化。

理解循环引用的产生原因和运行结果,是掌握 CommonJS 模块系统的关键一环。它不是 Node.js 的 bug,而是“缓存优先”加载策略的一个必然特性。合理的项目架构设计和对模块边界的控制,能够在绝大多数情况下自然避免这种隐蔽问题。即使偶尔遇到,掌握了上面这些原则,也能快速定位和修复。