CJS 循环依赖:为什么会拿到半成品

本节目标

循环依赖:A 依赖 B,B 又依赖 A。CJS 下容易拿到「还没初始化完」的半成品。

// 运行环境:Node.js 16+(保存三个 .cjs 文件后运行 main.cjs)
// a.cjs
exports.foo = 'foo from a'
const b = require('./b.cjs')   // 此时 b 正在初始化中
exports.bar = 'bar from a, b.hello=' + (b && b.hello)
// b.cjs
exports.hello = 'hello from b'
const a = require('./a.cjs')   // a 此时只执行了一半,a.bar 还没赋值
exports.afoo = a.foo           // 'foo from a'(a.foo 已设)
exports.abar = a.bar           // undefined!因为 a 还没执行到设置 bar 那行
// main.cjs
const a = require('./a.cjs')
const b = require('./b.cjs')
console.log('b.abar =', b.abar) // undefined —— 拿到了半成品

根因:CJS 是「执行到 require 才加载」,且导出是「值拷贝」。当 A 执行到 require(B),B 又回头 require(A) 时,A 还没跑完,B 拿到的是 A 当前已导出的部分(缺 bar),于是 undefined。

规避:① 重构让依赖单向;② 把「需要对方导出」的逻辑放进函数,延迟到调用时再 require(此时双方都初始化完了)。

名词解释

循环依赖(Circular Dependency):两个模块互相 require(A 引 B,B 引 A)。它不一定是错误,但会让「初始化顺序」变得微妙。CJS 下因为「运行时执行 + 值拷贝」,容易出现「一方读到另一方未完成状态」。

半成品(Partial Export):循环依赖时,被回头 require 的模块只执行了一部分,已 exports.xxx 的属性有、没执行到的没有。调用方拿到一个「残缺对象」。这是 CJS 循环依赖最经典的坑,表现为「明明导出了却 undefined」。

课后练习

练习 1:上面例子里为什么 b.afoo 有值而 b.abar 是 undefined?

答案:执行顺序:main → require(a) → a 设了 foo → a require(b) → b 设了 hello → b require(a)(命中缓存,此时 a 只设了 foo,bar 还没设)→ b 读 a.foo 有值、a.bar 还没赋值所以 undefined。顺序决定了一切。

练习 2:怎么改能避免拿到 undefined?

答案:把「读 a.bar」的逻辑延后。比如 b 里不立即 a.bar,而是导出一个函数 getAbar(){ return a.bar },等所有模块初始化完、真正调用时 a.bar 已就绪。或者重构去掉循环(把共享逻辑抽到第三个模块 c)。

总结

CJS 循环依赖的坑,本质是「运行时执行顺序」和「值拷贝」叠加的副产品。我的观点:循环依赖本身是代码坏味道,能重构去掉就去掉。如果实在去不掉(比如两个模块都要用对方的一个工具函数),最稳的解法是「延迟 require」——把跨模块的读取放进函数体内,调用时才取,那时双方都初始化完毕。千万别在模块顶层就急着用对方的导出。另外对比 ESM 的 live binding(下节),CJS 这个坑更隐蔽,因为错误只在运行时偶发暴露。记住铁律:模块顶层只该做「无依赖的初始化」和「导出」,跨模块的读取留给函数。