「译」解析 JavaScript 中的循环依赖

链接:www.bryanbraun.com/2025/03/29/...

作者:Bryan

标题:Breaking down circular dependencies in JavaScript

我写了很多 JavaScript 代码,但循环依赖对我来说一直是个谜。错误信息总是显得随机且难以理解,有时甚至根本没有错误信息!我想更好地理解这个话题,所以进行了一系列实验,并想分享我学到的东西。让我们来解析 JavaScript 中的循环依赖。

什么是循环依赖?

当你的 JavaScript import 语句导致循环时,就会发生循环依赖:

循环可以由两个文件、三个文件或更多文件组成。

每当你的 import 语句创建这样的循环时,你的代码就有可能无法按预期工作。

如何知道何时存在循环依赖?

语言本身没有内置的简单方法!

在 JavaScript 中,循环依赖通常表现为一个看似无关的错误(如 ReferenceErrorTypeError )。这与许多其他编程语言不同,这些语言通常会直接告诉你导入有问题:

  • Python: ImportError
  • Go: import cycle not allowed

那么为什么 JavaScript 不能直接说⚠️ CircularDependencyError 呢?

这是因为 JavaScript 模块设计为按需加载和执行。

当你的浏览器加载一个网页并开始执行第一个 JavaScript 文件时,它并不知道还有多少文件即将到来。这些文件可能还存放在世界另一端的服务器上。

这与 Go 或 Python 程序的情况非常不同,后者的导入系统可以在执行任何一行代码之前分析整个依赖树。

逐步解析 JavaScript 中的循环依赖

解释 JavaScript 给出的错误的最佳方法是逐步解析一个循环依赖的场景:

这是我们在每一步中看到的内容:

步骤 1:在 index.js 的第 1 行,执行暂停以下载 a.js ,以便可以导入其值 a

步骤 2:下载 a.js 后,执行在 a.js 中继续,但在第 1 行暂停以下载 b.js ,以便可以导入其值 b

步骤 3:下载 b.js 后,执行在 b.js 中继续,并在第 1 行找到一个指向 a.js 的导入(循环导入)。

步骤 4: a.js 已经下载,但由于此时我们尚未执行 a.js 中第 1 行之后的内容,因此它没有定义任何导出。因此,我们无法满足 b.js 中的导入。

第 5 步:执行在 b.js 中继续, a 仍未初始化。当在第 3 行调用 a 时,程序报错: ReferenceError: Cannot access 'a' before initialization

总结一下,循环依赖导致代码在未初始化的值下执行。这可能会导致各种错误,比如上面的 ReferenceError

为什么循环依赖有时不会导致错误?

JavaScript 的导入被描述为"实时绑定"。这意味着导入的值可能一开始是未初始化的(由于循环依赖),但在代码的其余部分被评估后变得完全可用。换句话说,一些循环依赖是无错误的,因为它们在你调用受影响的代码之前"自行解决"。

我曾经在一个充满循环导入的代码库中工作,但它们从未引起任何问题。为什么?

这是因为所有代码都定义在函数中,这些函数在所有内容加载完毕之前都不会被调用。

为了演示,我们可以更新最后一个场景,使其以类似的方式工作:

步骤 1-4 与上述相同,但从步骤 5 开始有所变化:

步骤 5: a 仍然未初始化,但不是直接调用,而是被放入函数定义中(无错误)。

步骤 6: b.js 完成后, a.js 中的执行继续到第 3 行,该行定义了 a 的导出。从这一点开始,任何调用 a 的代码都将获得一个初始化值,这是由于实时绑定的结果。

步骤 7:我们成功调用了 a() ,它又调用了 b() 。最终,所有代码都被调用且没有错误。

总结来说,当我们实际调用那个"未初始化的 a"时,实时绑定已经更新了它的值,它不再是未初始化的。我们之所以安全,是因为 a 的值只在变量实际使用时才会被获取。

现在,我不会推荐用这种方式来解决依赖问题。我更倾向于彻底移除循环依赖。不过,我打赌有很多生产应用目前依赖于这种行为来处理循环依赖。

防止循环依赖

虽然 JavaScript 可能没有内置的循环依赖检查,但我们仍有办法防止这些问题。

像 madge 和 eslint-plugin-import 这样的第三方工具可以对你的 JavaScript 代码库进行静态分析,并在循环依赖变得难以处理之前检测到它们。一些 monorepo 工具如 NX 和 Rush 在其工作流程中内置了类似的功能。

当然,最好的预防措施是一个组织良好的代码库,具有清晰的共享代码层次结构。

Node / Bun / Webpack / 等呢?

我上面分享的例子主要关注"浏览器中的 ES 模块"这一使用场景,但 JavaScript 运行在许多不同的环境中。服务器端的 JavaScript 不需要通过网络下载其源代码(使其更像 Python),而像 Webpack 这样的打包工具可以将所有代码合并到一个文件中。在这些场景中,循环依赖是否是一个问题?

简而言之,是的。在我的实验中,我惊讶地发现浏览器、服务器和打包器的错误结果基本一致。

例如,使用 Webpack 时, import 语句被移除,但合并后的代码仍然产生相同的错误:

js 复制代码
// b.js
console.log('b.js:', a); // ReferenceError: Cannot access 'a' before initialization
const b = 'B';

// a.js
console.log('a.js:', b);
const a = 'A';

我还应该提到,虽然 Node.js 在使用 import 语法(ESM)时产生了相同的错误,但在使用 require 语法(CommonJS)时表现不同:

js 复制代码
$ node node-entry.cjs

(node:13010) Warning: Accessing non-existent property 'Symbol(nodejs.util.inspect.custom)' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
(node:13010) Warning: Accessing non-existent property 'constructor' of module exports inside circular dependency
(node:13010) Warning: Accessing non-existent property 'Symbol(Symbol.toStringTag)' of module exports inside circular dependency

考虑到 CommonJS 是一个完全不同的导入系统,它并不符合 ECMAScript Modules 规范,这就说得通了。将两者进行比较就像是在比较苹果和橘子!

结论

循环依赖可能会让人困惑,但当你逐步分析场景时,它会变得更加清晰。一如既往,没有什么比通过实验来清晰理解这类问题更有效的方法了。

如果你想更详细地查看我的测试结果,请随时查看 repo。

相关推荐
不爱学英文的码字机器3 小时前
零信任架构:重塑网络安全的IT新范式
安全·web安全·架构
Freedom风间5 小时前
前端优秀编码技巧
前端·javascript·代码规范
萌萌哒草头将军5 小时前
🚀🚀🚀 Openapi:全栈开发神器,0代码写后端!
前端·javascript·next.js
萌萌哒草头将军5 小时前
🚀🚀🚀 Prisma 爱之初体验:一款非常棒的 ORM 工具库
前端·javascript·orm
拉不动的猪5 小时前
SDK与API简单对比
前端·javascript·面试
runnerdancer6 小时前
微信小程序蓝牙通信开发之分包传输通信协议开发
前端
BillKu6 小时前
Vue3后代组件多祖先通讯设计方案
开发语言·javascript·ecmascript
山海上的风6 小时前
Vue里面elementUi-aside 和el-main不垂直排列
前端·vue.js·elementui
电商api接口开发6 小时前
ASP.NET MVC 入门指南二
前端·c#·html·mvc