文章

模块循环依赖的处理

模块循环依赖的处理

一句话概括

A 引 B、B 引 A 就是循环依赖。CommonJS 给你半成品对象,ES Module 用 Live Binding 保住引用但值可能还没填——两者都不完美,但理解行为差异是写出可靠模块的基础。

核心知识点

1. CommonJS 的循环依赖 — 半成品地狱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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 还没执行完,拿到缓存里的半成品
console.log(a.done);         // false
console.log('b 结束');

// 执行 main.js: require('./a')
// 输出顺序:
// a 开始 → b 开始 → a.done = false → b 结束 → a 结束

根因: require('./a') 第一次调用时就创建了 module.exports 的缓存对象,此时 done 还是初值 false。b.js 拿到的是这个还没执行完的缓存对象。

2. ES Module 的循环依赖 — 引用不丢,值未就绪

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.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log('b 中 a =', a); // undefined — 引用在,值没到

原理: ESM 在编译阶段就建立了变量绑定(分配内存地址),执行阶段只是往地址里填值。所以 b.mjs 里读到的 aundefined——地址对了,但值还没被 a.mjs 的赋值语句执行到。

3. 三种解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 方案一:延迟访问 —— 把依赖放到函数内部(CJS 和 ESM 通用)
// a.js
exports.getB = () => {
  const b = require('./b'); // 运行时再 require,此时双方都执行完了
  return b;
};

// 方案二:提取公共模块 —— 最干净
// shared.js
export const shared = '公共逻辑,谁用谁 import';

// a.js 和 b.js 都只依赖 shared,不再互引

// 方案三:TypeScript 用 import type
import type { B } from './b'; // 仅编译时类型,不产生运行时依赖

4. 检测循环依赖

1
2
3
4
5
6
7
8
# madge:画依赖图 + 标循环
npx madge --circular src/

# ESLint
# .eslintrc.js
rules: {
  'import/no-cycle': ['error', { maxDepth: Infinity }]
}

其实你每天都在用

  • Vuex / Pinia 模块互引store/user.tsstore/order.ts 里的 action 互相调用对方
  • React 父子组件互相引用Parent.tsx import Child.tsx,但 Child.tsx 需要 Parent 的类型定义 → 用 import type 解决
  • utils/tools 交叉引用format.ts 用了 validate.ts 的函数,validate.ts 又引用 format.ts 的格式化
  • Node.js 中间件链:多个中间件文件互引对方的处理结果
  • 类型声明文件之间types/user.tstypes/post.ts 互相引用,但 import type 不会产生循环

常见误解

  • ❌ 误区:「循环依赖会导致死循环」 不会。CJS 有模块缓存,ESM 有编译阶段绑定——两者都不会让模块代码重复执行。问题不是死循环,而是拿到的值不完整

  • ❌ 误区:「ESM 彻底解决了循环依赖」 没完全解决。ESM 保证引用不丢,但如果顶层代码直接访问循环依赖方的变量,拿到的还是 undefined——对方代码还没执行到赋值行。解决方案依然是延迟到函数内访问。

  • ❌ 误区:「Webpack 打包之后就不怕循环依赖了」 Webpack 只是把 CJS/ESM 统一成自己的模块系统,但执行顺序问题依然存在。打包工具能检测循环,不能消除循环导致的半成品问题。

  • ❌ 误区:「只要跑得动就没问题」 循环依赖的行为可能依赖于执行顺序——顺序换个打包工具、调整了 import 顺序,行为就可能变。这恰恰是最危险的 bug:不是必现,但会在特定条件下爆炸。

一句话总结

循环依赖不是 bug,是架构的预警信号——它在告诉你模块之间的职责边界已经模糊了,该把公共逻辑提出来了。

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