人人都会AI编程

27.4 模块循环引用引发的异常

更新时间:2026-07-11

模块循环引用是 Node.js 开发中一个经典的隐蔽陷阱。它不会在启动时直接报语法错误,而是在运行时产生不符合直觉的行为,导致模块获取到不完整的导出对象,进而触发难以排查的逻辑异常或类型错误。本节将分析循环引用的产生原因、Node.js 的内部处理机制、典型的表现症状,以及实际项目中的排查与规避方案。

27.4.1 什么是模块循环引用

当两个或多个模块互相 require 对方时,就形成了循环引用。最简单的例子:

// a.js
const b = require('./b');
module.exports = { name: 'module A', b };

// b.js
const a = require('./a');
module.exports = { name: 'module B', a };

a.js 加载 b.jsb.js 又尝试加载 a.js,而 a.js 尚未执行完毕,尚未导出完整对象,因此 b.js 中获取到的 a 可能是不完整或未初始化的导出对象。这种相互依赖在实际项目中往往不会如此直白,会隐藏在多级共享依赖或复杂的工具模块引用中。

27.4.2 Node.js 对循环引用的内部处理

Node.js 的 CommonJS 模块加载流程大致如下:

  1. 解析模块路径,确定绝对路径和文件位置。
  2. 检查该模块是否已缓存。如果缓存命中,直接返回 module.exports不再重新执行模块代码
  3. 如果未缓存,新建一个 Module 对象,将其放入缓存中(此时 exports 还是空对象)。
  4. 执行模块代码,填充 module.exports
  5. 返回 module.exports

关键在于第 3 步:模块被放入缓存的时机在执行之前。当 a.js 被执行时,它的 Module 对象已存在于缓存中,但 exports 还是初始的空对象。当 a.js 执行 require('./b') 时,Node.js 开始加载 b.jsb.js 又执行 require('./a'),此时 Node.js 发现 a.js 已经在缓存里,则直接返回尚未填充完毕的 module.exports。如果此时 a.js 还没有执行到赋值语句,b.js 拿到的就是一个空对象;如果已经执行了部分赋值,拿到的则是部分导出的对象。

执行顺序对于上例,假设先加载 a.js

  • a.js 执行,遇到 require('./b'),转而加载 b.js
  • b.js 执行,遇到 require('./a'),从缓存拿到当时 a.jsmodule.exports,此时它是 {}
  • b.js 继续执行,给 module.exports 赋值 { name: 'module B', a: {} }b.js 结束。
  • 控制权交回 a.jsb 变量得到完整的 { name: 'module B', a: {} }
  • a.js 继续执行,给 module.exports 赋值 { name: 'module A', b: { name: 'module B', a: {} } }

最终,a.js 导出对象的 b 属性引用完整的 b 模块,但 b 模块中的 a 属性却指向一个空对象。这是因为 a.js 的赋值发生在 b.js 执行之后,b.js 无法预知未来的赋值结果。

如果首先加载的是 b.js,则情况会反转,a 模块中的 b 会变成不完整对象。循环引用的结果依赖于模块的加载顺序,这在稍微复杂的项目中极不可靠。

27.4.3 典型异常表现

循环引用不会直接报错,而是导致模块导出不完整,从而在运行时触发各种间接错误:

  1. 函数未定义(TypeError: X is not a function)

当模块导出的是一个函数,而调用方在循环引用时拿到的是未初始化的对象,尝试调用时抛出 TypeError。例如:

   // a.js
   const b = require('./b');
   module.exports = () => console.log('A function');
   b.doWork(); // 正常工作

   // b.js
   const a = require('./a');
   module.exports.doWork = () => {
     a(); // 如果 a 此时还是空对象,报错 TypeError: a is not a function
   };
   
  1. 获取到 undefined 或空对象

某个模块被期望导出某个配置、常量或实例,但调用方在顶层使用时只拿到了 undefined{},导致后续逻辑产生空指针或属性缺失异常。

  1. 类实例化失败(TypeError: X is not a constructor)

与第一种类似,如果导出了一个类,但被误认为是空对象,new X() 会失败。

  1. 属性值为 undefined 却未作判空保护

拿到空对象时,访问深层属性 a.b.c 会抛出 Cannot read properties of undefined,这在业务逻辑中极易导致进程崩溃。

27.4.4 排查循环引用的实用方法

循环引用往往在大型项目中通过多层间接引入发生,很难通过肉眼发现。可以借助以下工具和方法排查:

使用 Node.js 原生 --trace-warnings 或调试

Node.js 在检测到循环 require 时会在控制台输出警告(不过默认情况下警告可能被抑制)。可以通过加上 --trace-warnings 参数启动应用,查看是否出现类似以下提示:

(node:1234) Warning: Accessing non-existent property 'xxx' of module exports inside circular dependency

或直接输出循环引用的模块路径。在 Node.js 14+ 版本中,这类警告会包含更多信息。

使用 require.cache 和调试工具

在 Node REPL 或调试脚本中,打印 require.cache 可以查看已加载的模块及其导出内容。找出出现问题的模块,逆序追踪依赖链路。

使用社区工具

  • madge:可以绘制模块依赖图,并自动检测循环引用。
  npx madge --circular src/index.js
  

它会列出形成循环的模块路径列表。

  • dpdm:另一个依赖分析工具,也能发现循环依赖:
  npx dpdm src/index.js
  
  • Webpack / Rollup 插件:如果用构建工具打包,社区也有循环依赖检查插件,可在构建阶段报错。

27.4.5 规避与修复的最佳实践

循环依赖本质是模块设计问题,需要从架构层面解耦。下面列举几种实用方案:

1. 提取公共模块,打破相互引用

最常见的修复方法是将双方共同依赖的逻辑提取到第三个模块 c.js 中,让 a.jsb.js 都只依赖 c.js,而不再互相引用。

// c.js
module.exports = { sharedUtil() {} };

// a.js: 依赖 c
// b.js: 依赖 c

2. 延迟加载(Lazy require)

如果确实存在循环依赖,可以使用函数内 require 代替顶层 require,让模块在真正调用时再加载。

// a.js
const b = require('./b');
module.exports = function doA() {
  // ...
};

// b.js
module.exports = function doB() {
  const a = require('./a'); // 延迟到运行时加载
  a();
};

这种方法虽然可以暂时工作,但治标不治本,并且可能让依赖关系更加混乱。仅适合在难以重构时作为权宜之计。

3. 拆分导出,避免整体引用

有时循环引用只是因为引用了一个大对象上的某个属性。可以将该属性独立成为一个模块,从而打破环。例如,如果 a.js 需要 b.js 的某个工具函数,但那个函数是纯逻辑,可以提取到 utils 模块,ab 分别引用 utils

4. 使用依赖注入(DI)或事件解耦

在框架(如 NestJS)中,通过 DI 容器解析依赖,可以天然避免循环引用造成的未初始化问题(因为框架会保证实例化顺序)。如果没有使用框架,也可以采用事件发射器(EventEmitter)来解耦模块间的直接调用。

5. 重构为函数导出而非顶层执行副作用

避免在模块顶部执行依赖其他模块的逻辑,将副作用操作封装为函数,在需要时调用。这样即使模块加载时其他模块未初始化,只要在调用时已完成初始化即可。

// bad: 顶层立即执行
const b = require('./b');
b.init();

// good: 延迟执行
module.exports.init = function() {
  const b = require('./b'); // 或提前 require 但调用放在函数内
  b.init();
};

27.4.6 真实场景总结

在多年实践中,循环引用常见于以下场景:

  • 工具类模块之间的相互调用:如 logger 依赖 configconfig 又依赖 logger 输出调试信息。
  • ORM 模型间的关联定义:多个数据库模型文件互相引用对方来建立关联(如 Sequelize 的 hasMany)。此时即使出现循环引用,由于仅引用构造函数而非立即调用,一般不会出错,但同样建议统一在关联索引文件中集中处理。
  • 服务层耦合:Service A 调用 Service B,Service B 又需要 Service A 的某些功能。此时应重新划分职责,或引入中介层。

务必在项目中配置自动化检测(如在 CI 中运行 madge --circular),确保循环依赖不会随着代码增长悄然滋生。当遇到由此引发的诡异运行时错误时,首要排查方向就是检查依赖图是否形成了环。

循环引用看似细微,但在生产环境中可能表现为难以复现的崩溃。理解 Node.js 的模块加载缓存机制,是防范和修复这类问题的基础。