The Bug That Passes Every Toolchain Check: Circular Dependencies in JavaScript

The Bug That Passes Every Toolchain Check: Circular Dependencies in JavaScript

能够绕过所有工具链检查的 Bug:JavaScript 中的循环依赖

A circular dependency is one of the few bugs that passes every check your toolchain runs. TypeScript compiles it cleanly. The tests pass. The build succeeds. The app ships. And somewhere deep in your import graph, a developer is staring at a TypeError: X is not a constructor that disappears the moment they add a console.log. Here are the three patterns that create them, what Node.js, webpack, Rollup, and esbuild actually do with them — they don’t solve the problem, they each make a different tradeoff — and how to stop them from forming.

循环依赖是极少数能够绕过工具链所有检查的 Bug 之一。TypeScript 可以顺利编译它,测试可以通过,构建也能成功,应用也能发布。然而,在你的导入依赖图深处,某个开发者正盯着一个 TypeError: X is not a constructor 错误,而这个错误只要加一个 console.log 就会消失。本文将介绍导致循环依赖的三种模式,分析 Node.js、webpack、Rollup 和 esbuild 对此的处理方式(它们并没有解决问题,而是各自做出了不同的权衡),以及如何防止它们的产生。

What a circular dependency actually is

什么是循环依赖

A circular dependency exists when module A imports from module B, which imports — directly or transitively — from module A.

当模块 A 导入模块 B,而模块 B 又(直接或间接地)导入模块 A 时,就存在循环依赖。

// user.service.ts
import { formatUser } from './user.utils';

// user.utils.ts
import { UserService } from './user.service'; // ← closes the loop

Neither developer planned this. user.service.ts needed a formatter. user.utils.ts needed the service type for a helper added three sprints later. Nobody saw the cycle form — they just saw two reasonable imports. This is how every circular dependency is born: through incremental, individually sensible decisions.

开发者并非有意为之。user.service.ts 需要一个格式化工具,而 user.utils.ts 在三个迭代后需要用到该服务的类型。没有人意识到循环的形成——他们只是各自添加了两个合理的导入。这就是所有循环依赖的诞生方式:通过一系列渐进的、单独看起来很合理的决策。

The 3 patterns that create them

导致循环依赖的三种模式

1. Barrel files (index.ts re-exports)

1. 桶文件 (index.ts 重新导出)

Barrel files are the biggest source of accidental cycles in TypeScript projects.

桶文件是 TypeScript 项目中意外产生循环依赖的最大来源。

// features/user/index.ts — re-exports everything in the feature
export { UserService } from './user.service';
export { UserRepository } from './user.repository';
export { UserController } from './user.controller';
export { formatUser, validateUser } from './user.utils';

Now every file in the user feature imports from ../user (the barrel) for cleaner paths. And any utility the barrel re-exports cannot safely import anything else from the barrel without creating a cycle.

现在,用户功能模块中的每个文件都从 ../user(桶文件)导入,以获得更简洁的路径。而桶文件重新导出的任何工具,如果再从桶文件导入其他内容,就会产生循环依赖。

// user.utils.ts
import { UserService } from '../user'; // ← imports the barrel
// The barrel re-exports user.utils → user.utils imports the barrel → cycle

Teams adopt barrel files for developer convenience. They inadvertently create import graphs where everything is connected to everything else.

团队为了开发方便而采用桶文件,却无意中创建了一个“万物互联”的导入依赖图。

2. Shared types modules

2. 共享类型模块

A types.ts file that both sides of a feature boundary import looks harmless — it only contains type definitions, after all.

一个被功能边界两侧同时导入的 types.ts 文件看起来无害——毕竟它只包含类型定义。

// types.ts
import { UserService } from './user.service'; // needed for a return type

// user.service.ts
import { User, UserOptions } from './types'; // cycle

TypeScript makes this worse. import type declarations are erased at compile time, but the module graph your bundler or Node.js sees is determined at load time. Many cycle detectors skip import type edges entirely — reporting 0 cycles on a graph that has real structural problems. The risk: the moment someone adds a value export to that same file, the type-only cycle becomes a value cycle, and that transition is invisible in code review.

TypeScript 让情况变得更糟。import type 声明在编译时会被擦除,但你的打包工具或 Node.js 所看到的模块依赖图是在加载时确定的。许多循环检测工具会完全跳过 import type 边,导致在存在实际结构问题的依赖图上报告“0 个循环”。风险在于:一旦有人在该文件中添加了一个值导出,纯类型循环就会变成值循环,而这种转变在代码审查中是不可见的。

3. Cross-feature imports

3. 跨功能导入

As a codebase grows, features start borrowing from each other directly.

随着代码库的增长,功能模块开始直接相互借用。

// orders/order.service.ts
import { UserProfile } from '../users/user.service'; // orders imports users

// users/user.service.ts
import { OrderHistory } from '../orders/order.service'; // users imports orders → cycle

Neither import looks wrong in isolation. OrderService needs the user’s profile. UserService needs to surface order history. Both make sense individually. Together they create an architectural cycle that neither domain should own.

单独看这两个导入都没有错。OrderService 需要用户资料,UserService 需要展示订单历史。两者单独看都很合理,但合在一起就产生了一个架构上的循环,而这本不该属于任何一个领域。

What Node.js, webpack, Rollup, and esbuild do with them

Node.js、webpack、Rollup 和 esbuild 是如何处理它们的

Runtimes and bundlers don’t solve circular dependencies — they each make a different tradeoff, each with its own failure mode.

运行时和打包工具并不能解决循环依赖——它们各自做出了不同的权衡,且都有各自的失败模式。

  • Node.js (CommonJS): Returns a partially-constructed module.exports for the module still being loaded. If module A is still evaluating when module B requires it, B gets an empty object {} — the exports haven’t been assigned yet. Node.js (CommonJS): 为正在加载的模块返回一个部分构建的 module.exports。如果模块 A 还在执行中,模块 B 就去 require 它,那么 B 会得到一个空对象 {},因为导出内容尚未赋值。

  • Node.js (ESM): Uses live bindings and the Temporal Dead Zone. ESM hoists imports and evaluates modules in post-order — child before parent. When a cycle exists, the module being waited on hasn’t finished evaluating yet. Reading a binding that hasn’t been initialized throws ReferenceError. Node.js (ESM): 使用实时绑定(live bindings)和暂时性死区(TDZ)。ESM 会提升导入并在后序遍历中评估模块(先子后父)。当存在循环时,被等待的模块尚未完成评估。读取尚未初始化的绑定会抛出 ReferenceError

  • Rollup / esbuild: Bundle all modules into a single file and try to reorder them to resolve the cycle. This works for many cycles, but when a true circular dependency exists — where A genuinely needs B to be initialized before A finishes — no linear ordering can satisfy both. The result: the bundle may produce different initialization order than Node.js, meaning a bug that appeared in Node.js may not appear in the Rollup bundle, and vice versa. Rollup / esbuild: 将所有模块打包成单个文件,并尝试重新排序以解决循环。这对于许多循环有效,但当真正的循环依赖存在时(即 A 确实需要在 A 完成前初始化 B),没有任何线性排序能同时满足两者。结果是:打包后的初始化顺序可能与 Node.js 不同,这意味着在 Node.js 中出现的 Bug 可能不会在 Rollup 打包中出现,反之亦然。

  • webpack: Behaves similarly to Node.js CJS semantics for CommonJS modules. For ESM modules bundled by webpack, live bindings are preserved, and the same TDZ behavior applies. webpack: 对于 CommonJS 模块,其行为类似于 Node.js 的 CJS 语义。对于 webpack 打包的 ESM 模块,它保留了实时绑定,并应用相同的 TDZ 行为。

The diagnostic is the same in every runtime: console.log changes execution timing just enough to alter the evaluation order — the bug appears or disappears based on load order, which changes when any import is added anywhere in the graph. 在所有运行时中,诊断方法都是一样的:console.log 改变了执行时机,足以改变评估顺序——Bug 会根据加载顺序出现或消失,而加载顺序会随着依赖图中任何位置的导入变动而改变。