文章

模块循环依赖的处理深度解析

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.tsx import Child,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 的半吊子保护靠谱得多。

本文由作者按照 CC BY 4.0 进行授权