在实际项目中,模块之间的依赖关系并不总是单向的。当模块 A 依赖模块 B,而模块 B 又依赖模块 A 时,就会形成循环引用。这种场景在业务代码里并不少见:用户模块需要引用订单模块的某些工具函数,而订单模块又需要从用户模块获取用户信息。Node.js 的 CommonJS 模块系统对循环引用有明确的处理规则,但运行结果往往出乎初学者的意料,理解其内部机制对于排查相关问题至关重要。
5.2.1 循环引用产生的原因
循环引用的根本原因是依赖关系出现了环路。在 CommonJS 规范下,require() 函数的工作流程是:
- 解析模块的绝对路径作为唯一标识。
- 检查缓存
require.cache,如果已经加载过,直接返回缓存的module.exports对象。 - 如果未加载,创建新的
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 时,加载过程如下:
- a.js 作为入口模块,系统创建 a 模块,将其放入缓存(此时
module.exports为{}),开始执行 a.js 代码。 - 执行到
require('./b')时,b.js 开始加载。系统创建 b 模块,放入缓存,开始执行 b.js。 - b.js 执行到
require('./a'),发现 a 模块已在缓存中,直接返回此刻 a 模块的exports对象(仍然为{},因为 a 还没执行完)。 - b.js 继续执行,设置
exports.message = 'Hello from B',打印b.js 加载完毕,a.message = undefined(因为 a 的 exports 还是空对象)。 - b.js 执行完毕,控制权回到 a.js。a.js 接收到 b 的完整 exports 对象,其中
message已是'Hello from B'。 - 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.message 为 undefined,因为那时 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 访问。为了避免这种情况,可以参考以下策略:
- 重构模块结构,打破循环依赖
最彻底的方案是抽取出共同依赖的公共模块。比如 A 和 B 互相引用,可以把双方都需要的功能提取到 C 中,让 A 和 B 各自依赖 C,从而消除环路。这样既保持了模块职责单一,又避免了循环。
- 将
require放到函数内部(延迟加载)
如果无法立即打破循环,可以将某个 require 语句移到实际使用时才调用的函数内部,而不是放在模块的顶层。因为只有模块顶层代码在首次加载时执行,而函数体内的 require 会在函数被调用时才执行,此时所有模块早已加载完毕,不会出现半成品 exports。例如:
// a.js
module.exports = { name: 'A' };
// 延迟加载 b
function getB() {
return require('./b');
}
这种方法虽然有效,但会模糊模块依赖关系,不宜滥用。
- 将导出逻辑前置
确保模块在被其他模块 require 之前,关键属性或方法已经完成了挂载。例如在文件顶部先执行 exports.x = ...,然后再 require 其他模块。这样对方拿到的 exports 对象至少包含了已定义的部分。
- 使用 ES Modules 的静态引用特性(长期方案)
尽管 Node.js 对 ESM 的循环引用也无法完全避免,但 ESM 的 import 语句是静态分析的,导出的是绑定引用,而不是值的拷贝。这意味着在循环引用时,访问到的导出值是动态绑定的,不会出现 CommonJS 中那种拿到空对象的问题(但仍需注意变量提升导致的暂时性死区)。如果你的项目已全面迁移到 ESM,循环引用的容错性会更好一些。
5.2.4 真实场景中的循环引用排查
当项目中出现类似 TypeError: x is not a function 或 undefined is not an object 且错误发生在模块加载阶段时,就应该警觉是否存在循环引用。可以通过以下方式排查:
- 在 Node.js 启动时添加
--trace-warnings或查看完整错误堆栈,看是否发生在require过程中。 - 使用动态分析工具如
madge来生成依赖图,查找循环依赖:
npx madge --circular --extensions js .
它会列出项目中存在循环引用的文件路径。
- 在调试中加入
console.log打印module.exports在各个阶段的状态,观察对象内容的变化。
理解循环引用的产生原因和运行结果,是掌握 CommonJS 模块系统的关键一环。它不是 Node.js 的 bug,而是“缓存优先”加载策略的一个必然特性。合理的项目架构设计和对模块边界的控制,能够在绝大多数情况下自然避免这种隐蔽问题。即使偶尔遇到,掌握了上面这些原则,也能快速定位和修复。