CJS 循环依赖:为什么会拿到半成品
本节目标
- 用可运行代码演示 CJS 循环依赖拿到「半成品」
- 理解根因:CJS 导出是「值的拷贝 + 缓存」
- 知道怎么规避(重构/延迟 require)
循环依赖: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 这个坑更隐蔽,因为错误只在运行时偶发暴露。记住铁律:模块顶层只该做「无依赖的初始化」和「导出」,跨模块的读取留给函数。