commonjs循环引用导致部分导出为空对象,根本原因是模块缓存与执行时机耦合:require时立即创建并缓存空exports对象,若此时被其他模块提前引用,就会读到尚未赋值的初始空状态。

CommonJS 中的循环引用导致部分导出值为空对象({}),根本原因在于模块缓存机制与执行时机的耦合:模块在首次 require 时立即创建空对象并缓存,后续导出赋值发生在模块执行过程中,若此时已被其他模块提前引用,就会读到尚未赋值的初始空对象。
模块缓存和 exports 初始化是同步发生的
Node.js 在加载模块时,会为每个模块创建一个 Module 实例,其 exports 属性默认指向一个空对象 {},并立即放入 require.cache。即使模块体还没开始执行,这个空对象已可供其他模块同步访问。
- A.js 执行到
const b = require('./B');时,B.js 尚未执行完,但它的module.exports已被设为{}并缓存 - B.js 内部若又
require('./A'),拿到的就是 A.js 当前的exports(可能还是空对象,因为 A.js 的导出语句还没运行到) - 最终两个模块互相看到的都是对方“半成品”的
exports
导出必须是对象引用,不能靠后期重赋值修复
CommonJS 不支持直接替换 module.exports(除非在模块顶部一次性赋值),而习惯性写法如 exports.foo = ... 实际是在修改缓存中那个初始空对象的属性。但如果另一模块已经持有该对象引用,它就能看到后续添加的属性;可如果它读取太早(比如在 require 后立刻访问),就只能看到空状态。
- 错误示范:
exports.a = 1; setTimeout(() => { exports.b = 2; }, 0);—— 异步赋值无法保证调用方能等到 - 正确思路:把所有要导出的内容在模块顶层就定义好,或使用 getter、函数封装延迟求值
实用缓解策略:避免裸对象导出 + 使用函数封装
不直接导出未完成的对象字段,而是通过函数返回所需值,把“获取时机”交给调用方控制。
- A.js:
module.exports = { getB: () => require('./B') }; - B.js:
module.exports = { getName: () => 'B module' }; - 这样即使循环 require,拿到的也是函数,调用时模块已基本就绪
- 更稳健的做法是提取共享逻辑到第三个模块,彻底打破循环
这种现象不是 bug,而是 CommonJS 模块系统设计的自然结果。理解 require 的同步性、缓存时机和对象引用特性,比试图“绕过”它更重要。真正健壮的代码应主动规避强循环依赖,而非依赖执行顺序巧合。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











