循环依赖与依赖治理

本节目标

Monorepo 让跨包引用太方便了,方便到很容易造出循环依赖:utils 引用了 ui 的一个函数,ui 又引用了 utils 的一个函数,形成 utils ⇄ ui 的环。

循环依赖的代价:

// 问题示意:a 依赖 b,b 又依赖 a
// packages/a/src/index.ts
import { doB } from '@my/b'
export const doA = () => doB()

// packages/b/src/index.ts
import { doA } from '@my/a'      // 循环!a 还没初始化完,doA 可能是 undefined
export const doB = () => doA()

在 ESM 下,这种环常常导致「导入到的是 undefined」或「初始化顺序错乱」,定位极难。更隐蔽的是类型层面的环:A 的 interface 引用 B 的 interface,B 又反过来——虽然运行时不崩,但 tsc 的类型推导会陷入迷宫。

第一步:检测。 别靠肉眼,用工具扫:

# 用 madge 扫描整个仓库的依赖环
pnpm add -Dw madge
npx madge --circular --extensions ts packages/

# 或用 dependency-cruiser 做更严格的「依赖方向守卫」
pnpm add -Dw dependency-cruiser

第二步:破解。 最稳妥的招数是「抽共享层」——把 a 和 b 都依赖的那部分逻辑,单独抽成一个新包 @my/shared,让 a、b 都只依赖 shared,shared 不依赖任何人:

// packages/shared/src/index.ts  ← 只被依赖,不依赖别人
export const helper = () => { /* a、b 共用 */ }

// packages/a、packages/b 都改成 import { helper } from '@my/shared'

这样「环」被打破,依赖方向变成 a/b → shared 的树状结构,清晰可维护。

名词解释

循环依赖(Circular Dependency):包 A 依赖 B、B 又依赖 A 的闭环。运行时常导致「未初始化值」,类型推导也会变慢变乱。

分层架构(Layered Architecture):把依赖方向规定成「上层依赖下层、下层不反向依赖」的单向树。共享逻辑沉到最底层,是破除循环依赖的设计原则。

依赖守卫(Dependency Guard):用 dependency-cruiser 这类工具写规则(如「ui 不得依赖 web」),CI 里自动拦截违规的跨包引用,把架构约束固化成代码。

Madge:一款静态分析工具,能画出模块依赖图并检测出循环依赖,是 Monorepo 体检的常用手段。

课后练习

练习 1:类型层面的循环引用(A 接口引用 B,B 引用 A)会像运行时的循环依赖那样直接崩吗?

答案:通常不会让程序运行时崩,但会让 tsc 的类型推导变慢、出现诡异报错。属于「不崩但难维护」的隐患,发现就该拆。

练习 2:「抽共享层」为什么能破环?

答案:环的本质是 A、B 互相需要对方的一部分。把这部分共性抽成第三包 shared,让 A、B 都只依赖 shared 且 shared 不依赖任何人,依赖图就从「环」变成「树」,方向单一清晰。

总结

Monorepo 把「跨包引用」变得太容易,反而让循环依赖成为高发坑。我的应对法则:先用 madge/dependency-cruiser 把环查出来,再用「抽共享层」把环破成树。更进一步,把依赖方向用 dependency-cruiser 写成 CI 守卫,让「禁止 ui 反向依赖 web」这类规则自动拦截——架构不该靠人自觉,要靠工具兜底。下一节进入工程化核心:用 Turborepo 编排所有包的构建与测试。