「译」解析 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。

相关推荐
xiaofeichaichai3 小时前
Webpack
前端·webpack·node.js
Thecozzy4 小时前
线上 Bug 排查与修复实录
架构
鹏大师运维4 小时前
为什么信创电脑装软件总提示“软件包架构不匹配”?
linux·运维·架构·国产化·麒麟·deb·统信uos
问心无愧05134 小时前
ctf show web入门111
android·前端·笔记
唐某人丶4 小时前
模型越来越强,我们还需要 Agent 工程吗?—— 从价值重估到 Harness 实践
前端·agent·ai编程
智码看视界4 小时前
现代Web开发基础:全栈工程师的起航点
前端·后端·c5全栈
JS菌4 小时前
手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现
前端·人工智能·后端
excel6 小时前
HLS TS 文件损坏的元凶:Git 提交与拉取
前端
Aphasia3116 小时前
https连接传输流程
前端·面试