commonjs循环依赖导致模块导出为{},本质是模块加载时module.exports被提前缓存而赋值未完成;解决方案包括延迟访问、导出可变对象动态填充或提取公共模块重构依赖。

CommonJS 循环依赖导致模块导出为 {}(空对象),本质是模块加载时的“导出对象提前初始化”和“赋值时机错位”问题。解决核心不是避免所有循环依赖,而是控制导出时机、拆分逻辑或改用更可控的模式。
理解为什么会出现空对象
CommonJS 模块在 require 时会立即执行模块代码,并把 module.exports 的**当前引用值**缓存到 require.cache。若 A require B,B 又 require A,而 A 尚未执行完 module.exports = {...},B 拿到的就是 A 初始化时的空对象 {}(因为 Node 默认 module.exports = {})。
例如:
A.jsconsole.log('A starts');
const b = require('./B');
console.log('A got b:', b); // { say: [Function] } —— 正常
module.exports = {
hello: () => 'from A',
};
B.js
console.log('B starts');
const a = require('./A');
console.log('B got a:', a); // {} —— ❌ 空对象!因为 A 还没执行到 module.exports 赋值
exports.say = () => 'from B';
方案一:延迟访问(最常用、零成本)
不直接在顶层读取对方模块的属性,而是把依赖使用推迟到函数执行时——此时对方模块通常已完成初始化。
- 把
require移到函数内部(如方法体、回调中) - 或只在需要时才解构/调用,避免顶层强依赖
改写 B.js:
详细的 Three.js 3D 图形参考,涵盖场景设置、相机、几何体、材质、光照、动画、控制器、加载器、数学工具和调试。
// ✅ 延迟 require,确保 A 已就绪
exports.say = () => {
const a = require('./A'); // 此时 A 已执行完 module.exports
return `from B, and A says: ${a.hello()}`;
};
方案二:导出可变对象 + 后续填充
让模块一开始就导出一个对象(非字面量),后续再往上面挂属性。这样即使被提前 require,拿到的也是同一个可变对象引用。
- A.js 开头就
module.exports = {}(显式声明) - 所有导出属性都通过
module.exports.xxx = ...动态添加 - B.js 在顶层
require('./A')也能安全读写该对象
A.js:
module.exports = {}; // 显式初始化空对象
console.log('A starts');
const b = require('./B');
console.log('A got b:', b);
// 后续填充
module.exports.hello = () => 'from A';
方案三:提取公共逻辑,打破循环
真正健壮的解法是重构——把 A 和 B 共享的数据或行为抽成第三个模块 C,让 A 和 B 都依赖 C,不再互相依赖。
- 适合共享配置、工具函数、状态管理器等场景
- 降低耦合,也便于测试和复用
例如:sharedState.js 存放状态,A.js 和 B.js 都 require('./sharedState') 读写,不再 require 彼此。
补充提醒
- ESM(
import)也有循环依赖,但行为不同(绑定实时绑定),不会得到空对象,但要注意undefined初始值 - Webpack/Rollup 等打包器对 CommonJS 循环依赖有额外处理,表现可能和 Node 原生不同,调试时以 Node 环境为准
- 用
console.log(require.cache)可查看各模块缓存状态,辅助定位问题
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南










