CommonJS循环引用返回部分初始化的模块对象而非报错;因同步执行与缓存机制,模块A被B require时若未执行完module.exports,则B获取空对象或旧快照,导致属性访问为undefined。

CommonJS 在遇到循环引用时,会返回一个尚未完全初始化的模块对象(空对象或部分初始化的对象),而不是报错。这容易导致“导出空对象陷阱”——即模块 A 引入模块 B,B 又引入 A,但 A 还没执行完 module.exports,B 拿到的就是一个空对象({}),后续即使 A 补充了属性,B 里引用的仍是原始空对象的快照,无法自动更新。
循环引用发生时的实际行为
CommonJS 模块加载是同步、按需、缓存的。首次 require 某模块时,Node.js 会:
- 立即创建一个
module.exports = {}并缓存; - 然后执行模块代码;
- 若执行中 require 了另一个也依赖当前模块的文件,就会拿到这个“半成品”的
exports对象。
关键点:这个对象是引用传递,但模块内部对 module.exports 的**重新赋值**(如 module.exports = class X{})会切断原有引用,而仅修改属性(如 exports.fn = ...)则能被外部观察到。
典型陷阱示例
假设 a.js 和 b.js 相互 require:
立即学习“Java免费学习笔记(深入)”;
// a.js
console.log('a: start');
const b = require('./b');
console.log('a: b.fn =', b.fn); // undefined!
exports.aFn = () => 'from a';
console.log('a: end');
// b.js
console.log('b: start');
const a = require('./a');
console.log('b: a.aFn =', a.aFn); // undefined!
exports.fn = () => 'from b';
console.log('b: end');
执行 node a.js 输出顺序为:
因为 a 在 b 中 require 时,a.js 才刚执行到 require('./b'),a.aFn 还未定义;同理 b.fn 在 a 中也是 undefined。
安全应对策略
- 避免顶层直接访问对方导出的属性:把使用逻辑推迟到函数执行时(即“懒加载”),此时模块通常已初始化完毕;
-
统一用
exports.xxx = ...增量导出,而非module.exports = {...}全量替换,确保引用不丢失; - 提取公共依赖:将相互依赖的逻辑抽到第三个模块中,打破循环;
-
用 getter 或函数封装访问:例如在
b.js中写exports.getA = () => require('./a');,调用时再取,避开初始化时机问题。
ESM 对比说明(辅助理解)
ES6 Module(ESM)在编译阶段就建立绑定(live binding),循环引用时会返回 未初始化的绑定占位符,待所有模块脚本解析完再统一求值。因此 ESM 中 import { x } from './a' 即使在循环中,只要 x 是顶层 export 的变量,就能正确反映最终值(前提是不重新赋值绑定)。而 CommonJS 没有这种绑定机制,纯靠对象引用和执行时序。


















