本节目标:把「循环依赖」从一个模糊的坏味道变成可解释、可检测、可修复的具体问题。读完后你能说清 CJS 的「部分导出对象」与 ESM 的「暂时性死区」为何表现不同、为什么有些循环依赖能跑而有些必崩,能写出
import type/export type/ 内联type三种写法的正确用法,知道verbatimModuleSyntax与isolatedModules各自的约束,并会用工具把循环找出来、用抽取共享类型的套路把它拆掉。
9.3 循环依赖与类型-only 导入
前两节解决了模块「从哪来」的问题。但还有一类故障不来自解析规则,而来自模块之间的引用结构本身:两个模块互相导入时,其中一方必然先执行,另一方拿到的可能是一个尚未初始化完成的值。
TypeScript 在这里有一个独特的机会:类型在运行时不存在,所以纯粹的类型引用本不该产生任何运行时依赖。 只要能保证「只用于类型的导入」在产物中真的被删掉,大量循环依赖会自动消失。这一节就围绕这个杠杆展开。
9.3.1 循环依赖为什么难查
循环依赖的棘手之处在于:它通常不报错。 编译器不会拒绝一个循环引用,打包器会照常打包,很多循环甚至能长期稳定运行。只有当某个模块的初始化顺序恰好让一方读取了尚未赋值的绑定时,才会在特定路径上崩溃。
更麻烦的是它的触发条件与代码结构无关,而与入口有关:
// a.ts 与 b.ts 互相导入
// 从 a 作为入口启动 → 正常
// 从 b 作为入口启动 → 崩溃
同一个代码库,换个入口就换个结果。这类问题往往在单元测试里测不出来(测试各自从被测文件进入),只在真实应用启动时暴露。所以应对循环依赖的正确姿态不是「等它报错再修」,而是用工具持续扫描 + 用类型导入从结构上消除一部分。
9.3.2 CJS 下的循环依赖:部分导出对象
CommonJS 的加载模型是「执行即填充」。require 返回的是 module.exports 这个对象引用,而不是一份快照。若模块 A 尚未执行完就被 B require,B 拿到的是一个只填了一半的对象。
// a.cjs
exports.name = "a";
const b = require("./b.cjs");
exports.getName = () => "a";
console.log("a 看到 b.name =", b.name);
// b.cjs
const a = require("./a.cjs");
console.log("b 看到 a.name =", a.name);
console.log("b 看到 a.getName =", typeof a.getName);
exports.name = "b";
从 a.cjs 启动,输出是:
b 看到 a.name = a
b 看到 a.getName = undefined
a 看到 b.name = b
关键在于 a.getName 是 undefined——因为 require("./a.cjs") 发生时,a.cjs 只执行到第 2 行,exports.getName 还没被赋值。而 name 之所以可见,是因为它写在 require 之前。
这解释了一条实用经验:在 CJS 里,把「供他人使用的导出」尽量写在文件顶部、把 require 调用推迟到函数体内(惰性 require),能显著降低循环依赖的杀伤力。
9.3.3 ESM 下的循环依赖:暂时性死区
ESM 的模型完全不同:绑定是实时的(live binding),但声明有暂时性死区(TDZ)。 导入方拿到的是「指向导出方变量的引用」,而不是值的拷贝——这本该让循环依赖更安全,但 const / let 的 TDZ 让情况反转:
// a.mjs
import { b } from "./b.mjs";
export const a = "a";
console.log(b);
// b.mjs
import { a } from "./a.mjs";
export const b = "b";
从 a.mjs 启动会抛出:
ReferenceError: Cannot access 'b' before initialization
原因是模块图先完成「实例化」(建立所有绑定),再按深度优先执行。a.mjs 执行时 b.mjs 尚未执行,b 处于 TDZ,任何读取都会抛错。而在 CJS 下同样的结构只会得到 undefined,不会抛错——ESM 把「静默的错误值」升级成了「显式的异常」,这是好事,但也意味着原先能跑的代码在迁移到 ESM 后会立刻崩掉。
有一个例外:函数声明会被提升,所以「只互相调用函数、不在顶层读取对方变量」的循环在 ESM 下通常能正常工作。
// a.mjs
import { formatB } from "./b.mjs";
export function formatA() { return formatB(); }
// b.mjs
import { formatA } from "./a.mjs";
export function formatB() { return "b"; }
这段代码不会抛错。正是因为「有时能跑」,循环依赖才会长期潜伏:开发者据此认为它无害,直到某天有人在顶层加了一行 const x = formatB(),问题才爆发。
9.3.4 类型导入为什么会制造循环
现在把 TypeScript 加进来。考虑一个非常典型的双向结构:
// order.ts
import { Customer } from "./customer";
export interface Order {
id: string;
customer: Customer;
}
// customer.ts
import { Order } from "./order";
export interface Customer {
id: string;
orders: Order[];
}
这两个文件在类型层面互相引用,在运行时层面却完全不需要对方。 但代码里写的是普通 import,于是产生了两个问题:
- 阅读代码的人无法一眼判断这是类型依赖还是值依赖;
- 逐文件转译器(esbuild / swc / tsx)无法判断
Customer是类型还是值,只能保守地保留这行导入。
第 2 点正是循环进入产物的通道。tsc 本身有一个「导入省略」优化:如果它确认某个导入只被用作类型,就会在输出中删掉它。但这个优化需要跨语句的语义分析,而逐文件转译器做不到——它只看得到单个文件。
9.3.5 import type 的三重收益
修复方式极其简单:把上面两行改成 import type。
// order.ts
import type { Customer } from "./customer";
export interface Order {
id: string;
customer: Customer;
}
// customer.ts
import type { Order } from "./order";
export interface Customer {
id: string;
orders: Order[];
}
收益有三重:
| 收益 | 说明 |
|---|---|
| 产物中彻底消失 | import type 在任何转译器下都保证被删除,不产生运行时依赖 |
| 结构上切断循环 | 运行时模块图里这两个文件不再互相引用,循环直接不存在 |
| 意图显式 | 读者一眼知道这是纯类型依赖,不会误以为存在运行时耦合 |
第一重收益的原理在 1.2 类型擦除与运行时边界
里已经讲过:类型标注在编译后整体消失。import type 只是把这个事实显式地写出来,让编译器与转译器都不必再猜。
一个必须澄清的误区:import type 不是「把导入变成惰性」或「延迟到运行时再解析」,而是「这个导入根本不出现在产物中」。产物里那一行是被删掉的,不是被替换成别的形式。可以用一个最小实验验证:
npx tsc order.ts --module esnext --declaration false --outDir out
打开 out/order.js,你会发现文件里只剩下空的 export {}——那行 import type 连痕迹都没有。
9.3.6 三种写法对照
TypeScript 提供了三种表达「这是类型」的语法,用途各不相同:
| 写法 | 语义 | 产物 |
|---|---|---|
import type { User } from "./u" | 整个导入仅用于类型 | 完全删除 |
import { type User, createUser } from "./u" | 混用,User 是类型、createUser 是值 | 只保留 createUser |
export type { User } from "./u" | 仅类型的再导出 | 完全删除 |
import { User } from "./u"(仅用于类型) | 依赖编译器的导入省略 | tsc 会删,逐文件转译器可能保留 |
第二种是内联类型修饰符,TypeScript 4.5 引入。它解决了一个很实际的场景:从一个模块同时导入类型和值时,不必写两行。
import { type Config, loadConfig } from "./config";
const config: Config = loadConfig();
第三种在 barrel 文件(统一再导出的 index.ts)里尤其有用:
export type { User } from "./user";
export type { Order } from "./order";
export { createOrder } from "./order";
barrel 文件是循环依赖的高发地:它把所有子模块聚到一个入口,任何两个子模块只要互相引用,路径上就会绕经 barrel 形成环。在 barrel 里把类型导出与值导出分开写,能消掉相当一部分环。
9.3.7 verbatimModuleSyntax 与 isolatedModules
这两个选项与类型导入的关系必须理清,否则会出现「本地能编译、CI 里报错」的分裂。
isolatedModules: true 要求每个文件能被独立转译。它禁止的正是那些「需要跨语句信息才能处理」的写法:
// 错误:在 isolatedModules 下,仅类型的再导出必须写 export type
export { User } from "./types";
报错是 TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'. 这条规则的价值在于:它把「转译器无法判断」的模糊写法直接禁掉,逼你把意图写清楚。
verbatimModuleSyntax: true 更进一步,要求导入语句原样保留,不做任何省略:
import { User } from "./types";
// 在 verbatimModuleSyntax 下,这行会被原样输出到产物中
// 若 User 只是类型,运行时就会去找一个不存在的导出
开启它之后,唯一的正确写法就是显式标注:
import type { User } from "./types";
import { createUser } from "./types";
代价是啰嗦,收益是产物完全可预测。对库作者与工具链开发者来说,这个交换是值得的。它与第 9.1 节的 moduleResolution 选择也有联动:NodeNext + verbatimModuleSyntax 是目前最不容易出现「产物与源码不一致」的组合。
想更系统地理解类型擦除后代码的运行时形态,可延伸阅读 8.2 类型擦除后的运行时形态 。
9.3.8 typeof import():不引入依赖的类型查询
有时你需要的不是「某个模块导出的类型」,而是「某个模块整体的类型」。import() 类型查询可以做到,且不产生任何运行时导入:
type UserModule = typeof import("./user");
type User = UserModule["User"];
type CreateUser = UserModule["createUser"];
它最常见的用途有两个:
其一,延迟加载的类型标注。
async function loadChart() {
const mod: typeof import("./chart") = await import("./chart");
return new mod.Chart();
}
这里 await import() 是真实的运行时动态导入(会单独分包),而 typeof import("./chart") 只提供类型。两者写法相似但语义完全不同,读代码时要分清有没有 typeof。
其二,绕开循环。 当 A 需要 B 的类型、B 需要 A 的值时,可以用 typeof import() 让 A 在不引入运行时依赖的前提下拿到 B 的类型——效果与 import type 类似,但适用于「需要整个模块类型」的场合。
9.3.9 检测与重构
检测。 不要靠肉眼,用工具持续扫描:
npx madge --circular --extensions ts,tsx src
madge 会打印出所有环,例如:
✖ Found 2 circular dependencies!
1) order.ts > customer.ts
2) index.ts > user.ts > index.ts
配合 ESLint 可以在编辑时就拦住:
{
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}
maxDepth 限制追溯深度,避免大项目里检查过慢。若你的项目用 dependency-cruiser,它还能按规则区分「允许的环」与「禁止的环」,适合在存量代码里做渐进治理。
重构。 找到环之后,按下面的顺序处理,成本从低到高:
| 手段 | 适用场景 | 改动量 |
|---|---|---|
改用 import type | 环上只有类型引用 | 极小 |
抽出 types.ts | 双方共用的类型可独立成文件 | 小 |
惰性 import() | 环上确实需要值,但可推迟到函数内 | 中 |
| 依赖倒置 | 一方只依赖抽象接口,实现由外部注入 | 大 |
最常用的是前两条。以 9.3.4 的例子为例,抽出共享类型是最干净的解法:
// types.ts —— 只放类型,不含任何值
export interface Order {
id: string;
customer: Customer;
}
export interface Customer {
id: string;
orders: Order[];
}
// order.ts
import type { Order, Customer } from "./types";
export function createOrder(customer: Customer): Order {
return { id: "o-1", customer };
}
types.ts 不导入任何业务模块,环被彻底拆掉,而且这个文件天然是「零运行时依赖」的——它可以被任何模块安全引用。这类「只含类型的模块」应当成为你项目里的常规结构,它与 11.3 类型驱动架构与团队规范
里讲的类型边界是同一个思路。
9.3.10 常见报错与排查
ReferenceError: Cannot access 'x' before initialization(ESM 运行时)。
典型的 ESM 循环依赖症状:某模块在顶层读取了尚未执行的另一模块的 const / let 绑定。解法是把顶层读取改成函数内读取,或改用 import type 切断。
TypeError: Cannot read properties of undefined (reading 'x')(CJS 运行时)。
CJS 的循环依赖症状:拿到的是部分填充的 exports 对象。解法是把 require 推迟到函数体内,或调整导出赋值顺序。
TS2448: Block-scoped variable 'x' used before its declaration.
同一个文件内的使用顺序问题,与跨模块循环无关,但排查思路相同。
TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.
把 export { X } from "..." 改成 export type { X } from "..."。
测试通过但应用启动崩溃。
这是循环依赖最典型的指纹。排查手段是在入口文件顶部打印模块加载顺序,或直接用 madge 扫描。如果环上全是类型引用,改用 import type 后问题会立即消失——这也是最快的一条验证路径。
小结
本节把循环依赖拆成了三个层次。第一层是运行时表现:CJS 用「部分填充的 exports 对象」给出静默的 undefined,ESM 用 TDZ 给出显式的 ReferenceError,而函数提升让一部分循环能正常跑,这正是它长期潜伏的原因。第二层是类型导入的杠杆:纯类型引用本不该产生运行时依赖,写成 import type 就能让它在产物中彻底消失,从而在结构上切断环;verbatimModuleSyntax 与 isolatedModules 则保证逐文件转译器不会把类型导入误留成值导入。第三层是工程手段:用 madge / import/no-cycle 持续扫描,按「改 import type → 抽 types.ts → 惰性 import() → 依赖倒置」的成本梯度逐级处理。
至此第 9 章关于「模块」的讨论完整闭环:9.1 决定用哪套解析算法,9.2 决定导入落在 exports 的哪个分支,9.3 决定模块之间该不该存在运行时引用。三者共同构成了「代码如何被组装成程序」的完整视图。下一章我们转向另一条边界——数据进入程序时如何被验证:类型只在编译期存在,而来自网络与文件的输入没有编译期,必须靠运行时的守卫来兜底,这正是 10.1 类型守卫与验证库原理
要解决的问题。想先了解类型优先的工程组织方式,可延伸阅读 TypeScript 类型优先开发
与 TypeScript 工程化进阶
。
阅读导航:上一节:9.2 条件导出与 bundler 语义 · 下一节:10.1 类型守卫与验证库原理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。