模块循环依赖的处理深度解析
A 引 B、B 引 A 即循环依赖——CJS 给半截空对象,ESM 保证引用不丢但顶层值可能仍是 undefined。 面试借此考察对模块加载时序的深入理解。
一句话概括
A 引 B、B 引 A 就是循环依赖——CJS 给你一个填了半截的空对象,ESM 保证引用不丢但顶层访问的值可能还是 undefined。两种方案都不完美,但它们恰好是你理解模块加载机制的窗口。
核心知识点
1. CJS 循环依赖——先缓存后执行,拿到半成品
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// a.js
console.log('a 开始');
exports.done = false;
const b = require('./b'); // ← 跳到 b.js 执行
exports.done = true;
console.log('a 结束');
// b.js
console.log('b 开始');
const a = require('./a'); // ← 拿到 a 的缓存,但 a 还没跑完!
console.log('a.done =', a.done); // false —— 半成品!
console.log('b 结束');
// $ node a.js 输出:
// a 开始 → b 开始 → a.done = false → b 结束 → a 结束
根因: require 的流程是”先建缓存占位 → 再执行代码”。b.js 里 require('./a') 拿到的缓存上 done 还是初始 false,因为 exports.done = true 还没跑到。Node 官方文档专门有一节讲这个——不是 bug,是设计取舍。
2. ESM 循环依赖——引用在,值未必在
1
2
3
4
5
6
7
8
9
// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log('a 里 b =', b); // 'B' —— b 的值已经就位
// b.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log('b 里 a =', a); // undefined —— 引用对了,值还没赋值
原理: ESM 分两步——编译阶段扫描所有 import/export,为每个变量分配内存地址(建立绑定);执行阶段才逐行赋值。b.mjs 执行 console.log(a) 时,a 的内存地址已存在(所以不报错),但 a.mjs 的 export const a = 'A' 还没执行到(所以值是 undefined)。这跟 JS 的 Temporal Dead Zone 是同一套原理。
3. 三种解法
1
2
3
4
5
6
7
8
9
10
11
12
// 方案一:延迟访问(最实用,CJS & ESM 通用)
// 不在顶层立即取值,放在函数里按需访问
exports.getB = () => require('./b'); // 调用时 b 肯定跑完了
// 方案二:提取公共模块(根治方案)
// shared.js
export const sharedUtil = () => {};
// a.js 和 b.js 都只 import shared,互不引用 → 循环消除
// 方案三:import type(TypeScript,零运行时开销)
import type { B } from './b'; // 编译后直接抹掉,运行时无依赖
const bModule = await import('./b'); // 需要运行时值才动态导入
4. 检测工具
1
2
npx madge --circular src/ # 可视化依赖图 + 标出所有循环
# ESLint 规则:import/no-cycle: ['error', { maxDepth: Infinity }]
其实你每天都在用
- Pinia / Vuex Store 互调:
userStore的 action 调用orderStore的方法,两个 store 互相 import - React 父子组件类型交叉引用:
Parent.tsximportChild,Child又需要Parent的 Props 类型 →import type完美解决 - 工具函数互相依赖:
format.ts用validate.ts校验,validate.ts又用format.ts格式化错误信息 → 该提取公共模块了 - Node.js 中间件链:
authMiddleware.ts↔loggerMiddleware.ts互相引用对方逻辑 - TypeScript 类型定义交叉:
User有posts: Post[],Post有author: User→import type是标配
常见误解(FAQ)
❌ 误区:「循环依赖会导致死循环 / 爆栈」 不会。CJS 有缓存机制——第一次
require就建了占位缓存,同一路径再次遇到直接返回。ESM 有编译期绑定——同一个模块只执行一次。问题不是死循环,是值不完整。❌ 误区:「ESM 彻底解决了循环依赖」 没彻底解决。Live Binding 让引用不丢,但顶层代码直接访问循环依赖方的变量时值依然是
undefined。ESM 改善的是”对方模块跑完后值会自动更新”这种异步取值场景——但顶层同步访问,该 undefined 还是 undefined。❌ 误区:「Webpack 打包后循环依赖不是问题」 Webpack 把 CJS/ESM 统一成
__webpack_require__,循环依赖的执行顺序问题照样存在。打包工具能检测循环,不能消除半成品——它不知道你的模块内部何时才把真实值写入 exports。❌ 误区:「能跑通就是没问题」 最危险的误解。循环依赖的行为依赖 import 顺序、打包工具的实现细节——换一套构建工具或改个 import 顺序,行为就可能变了。不是必现 bug,但排查极难。
一句话总结
循环依赖不是代码 bug,是架构的预警信号——它在告诉你两个模块的职责边界已经模糊到互相需要对方才能工作。提取公共逻辑,比依赖 CJS 缓存或 ESM Live Binding 的半吊子保护靠谱得多。