Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >「译」解析 JavaScript 中的循环依赖

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

作者头像
泯泷、
发布于 2025-05-09 04:47:42
发布于 2025-05-09 04:47:42
730
举报
文章被收录于专栏:前端工具前端工具

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

什么是循环依赖?

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

Diagram showing a two-way dependency loop and a three-way dependency loop.
Diagram showing a two-way dependency loop and a three-way dependency loop.

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

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

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

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

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

  • Python: ImportError
  • Go: import cycle not allowed

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

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

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

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

逐步解析 JavaScript 中的循环依赖

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

A diagram showing step-by-step execution of code leading to a circular dependency error.
A diagram showing step-by-step execution of code leading to a circular dependency error.

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

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

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

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

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

A diagram showing step-by-step execution circular dependency code without any errors.
A diagram showing step-by-step execution circular dependency code without any errors.

步骤 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
AI代码解释
复制
// 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
AI代码解释
复制
$ 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。

本文系外文翻译,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系外文翻译,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
OpenHarmony内核源码分析(调度队列篇) | 内核有多少个调度队列
鸿蒙内核代码中有两个源文件是关于队列的,一个是用于调度的队列,另一个是用于线程间通讯的IPC队列。
小帅聊鸿蒙
2025/03/07
730
OpenHarmony内核源码分析(调度队列篇) | 内核有多少个调度队列
OpenHarmony 轻内核M核源码分析系列三 数据结构-任务排序链表
在鸿蒙轻内核源码分析系列一和系列二,我们分析了双向循环链表、优先级就绪队列的源码。本文会继续给读者介绍鸿蒙轻内核源码中重要的数据结构:任务排序链表TaskSortLinkAttr。鸿蒙轻内核的任务排序链表,用于任务延迟到期/超时唤醒等业务场景,是一个非常重要、非常基础的数据结构。本文中所涉及的源码,以OpenHarmony LiteOS-M内核为例。
小帅聊鸿蒙
2025/05/20
770
OpenHarmony 轻内核M核源码分析系列三 数据结构-任务排序链表
OpenHarmony 轻内核M核源码分析系列九 互斥锁Mutex
多任务环境下会存在多个任务访问同一公共资源的场景,而有些公共资源是非共享的临界资源,只能被独占使用。鸿蒙轻内核使用互斥锁来避免这种冲突,互斥锁是一种特殊的二值性信号量,用于实现对临界资源的独占式处理。另外,互斥锁可以解决信号量存在的优先级翻转问题。用互斥锁处理临界资源的同步访问时,如果有任务访问该资源,则互斥锁为加锁状态。此时其他任务如果想访问这个临界资源则会被阻塞,直到互斥锁被持有该锁的任务释放后,其他任务才能重新访问该公共资源,此时互斥锁再次上锁,如此确保同一时刻只有一个任务正在访问这个临界资源,保证了临界资源操作的完整性。
小帅聊鸿蒙
2025/05/23
750
OpenHarmony 轻内核M核源码分析系列一 数据结构-双向循环链表
在学习OpenHarmony鸿蒙轻内核源代码的时候,常常会遇到一些数据结构的使用。如果没有掌握它们的用法,会导致阅读源代码时很费解、很吃力。本文会给读者介绍源码中重要的数据结构,双向循环链表Doubly Linked List。在讲解时,会结合数据结构相关绘图,培养读者们的数据结构的平面想象能力,帮助更好的学习和理解这些数据结构的用法。
小帅聊鸿蒙
2025/05/19
730
OpenHarmony 轻内核M核源码分析系列一 数据结构-双向循环链表
OpenHarmony 轻内核M核源码分析系列六 任务及任务调度(2)任务模块
任务是操作系统一个重要的概念,是竞争系统资源的最小运行单元。任务可以使用或等待CPU、使用内存空间等系统资源,并独立于其它任务运行。鸿蒙轻内核的任务模块可以给用户提供多个任务,实现任务间的切换,帮助用户管理业务程序流程。本文我们来一起学习下任务模块的源代码,所涉及的源码,以OpenHarmony LiteOS-M内核为例,均可以在开源站点 https://gitee.com/openharmony/kernel_liteos_m 获取。
小帅聊鸿蒙
2025/05/21
940
OpenHarmony 轻内核M核源码分析系列十三 消息队列Queue
队列(Queue)是一种常用于任务间通信的数据结构。任务能够从队列里面读取消息,当队列中的消息为空时,挂起读取任务;当队列中有新消息时,挂起的读取任务被唤醒并处理新消息。任务也能够往队列里写入消息,当队列已经写满消息时,挂起写入任务;当队列中有空闲消息节点时,挂起的写入任务被唤醒并写入消息。如果将读队列和写队列的超时时间设置为0,则不会挂起任务,接口会直接返回,这就是非阻塞模式。消息队列提供了异步处理机制,允许将一个消息放入队列,但不立即处理。同时队列还有缓冲消息的作用。
小帅聊鸿蒙
2025/05/26
790
OpenHarmony 轻内核M核源码分析系列十一(1) 信号量Semaphore
信号量(Semaphore)是一种实现任务间通信的机制,可以实现任务间同步或共享资源的互斥访问。一个信号量的数据结构中,通常有一个计数值,用于对有效资源数的计数,表示剩下的可被使用的共享资源数。以同步为目的的信号量和以互斥为目的的信号量在使用上存在差异。本文通过分析鸿蒙轻内核信号量模块的源码,掌握信号量使用上的差异。
小帅聊鸿蒙
2025/05/23
390
OpenHarmony内核源码分析(任务调度篇) | 任务是内核调度的单元
从系统的角度看,线程是竞争系统资源的最小运行单元。线程可以使用或等待CPU、使用内存空间等系统资源,并独立于其它线程运行。
小帅聊鸿蒙
2025/03/07
1060
谁是鸿蒙内核最重要的结构体?
结构体够简单了吧,只有前后两个指向自己的指针,但恰恰是因为太简单,所以才太不简单. 就像氢原子一样,宇宙中无处不在,占比最高,原因是因为它最简单,最稳定!
小帅聊鸿蒙
2025/03/06
560
谁是鸿蒙内核最重要的结构体?
OpenHarmony内核源码分析(进程概念篇) | 进程在管理哪些资源
官方文档最重要的一句话是进程是资源管理单元,注意是管理资源的, 资源是什么? 内存,任务,文件,信号量等等都是资源.故事篇中对进程做了一个形象的比喻(导演),负责节目(任务)的演出,负责协调节目运行时所需的各种资源.让节目能高效顺利的完成.
小帅聊鸿蒙
2025/03/12
1090
OpenHarmony 轻内核M核源码分析系列十二 事件Event
事件(Event)是一种任务间通信的机制,可用于任务间的同步。多任务环境下,任务之间往往需要同步操作,一个等待即是一个同步。事件可以提供一对多、多对多的同步操作。本文通过分析鸿蒙轻内核事件模块的源码,深入掌握事件的使用。
小帅聊鸿蒙
2025/05/26
930
OpenHarmony 轻内核A核源码分析系列七 进程管理 (1)
本文开始继续分析OpenHarmony LiteOS-A内核的源代码,接下来会分析进程和任务管理模块。本文中所涉及的源码,以OpenHarmony LiteOS-A内核为例。如果涉及开发板,则默认以hispark_taurus为例。
小帅聊鸿蒙
2025/06/06
1120
OpenHarmony 轻内核A核源码分析系列七 进程管理 (1)
鸿蒙内核源码分析(进程管理篇) | 谁在管理内核资源?
进程创建或fork时,拿到该进程控制块后进入Init状态,处于进程初始化阶段,当进程初始化完成将进程插入调度队列,此时进程进入就绪状态。
小帅聊鸿蒙
2025/03/06
770
鸿蒙内核源码分析(进程管理篇) | 谁在管理内核资源?
OpenHarmony内核源码分析(互斥锁篇) | 互斥锁比自旋锁丰满多了
图中是内核有关模块对互斥锁初始化,有文件,有内存,用消息队列等等,使用面非常的广.其实在给内核源码加注的过程中,会看到大量的自旋锁和互斥锁,它们的存在有序的保证了内核和应用程序的正常运行.是非常基础和重要的功能.
小帅聊鸿蒙
2025/03/13
1260
OpenHarmony内核源码分析(互斥锁篇) | 互斥锁比自旋锁丰满多了
鸿蒙轻内核M核源码分析系列十三(续) 消息队列QueueMail接口
之前分析过队列(Queue)的源代码,了解了队列初始化、队列创建、删除、队列读取写入等操作。队列还提供了两个接口OsQueueMailAlloc和OsQueueMailFree。队列可以和一个静态内存池关联起来,一个任务从静态内存池申请内存块时,如果申请不到,会把该任务插入到队列的内存阻塞链表中,等有其他任务释放内存时,该任务会被分配内存块。
玖柒的小窝
2021/09/17
2830
OpenHarmony内核源码分析(线程概念篇) | 是谁在不断的折腾CPU
在鸿蒙内核线程(thread)就是任务(task),也可以叫作业.线程是对外的说法,对内就叫任务.跟王二毛一样, 在公司叫你王董,回到家里还有领导,就叫二毛啊.这多亲切.在鸿蒙内核是大量的task,很少看到thread,只出现在posix层.当一个东西理解就行.
小帅聊鸿蒙
2025/03/11
1230
OpenHarmony 内核源码分析(读写锁) | 内核如何实现多读单写
读写锁 :是计算机程序的并发控制的一种同步机制,也称“共享-互斥锁”、多读者-单写者锁。读操作可并发重入,写操作是互斥的。
小帅聊鸿蒙
2025/04/02
420
OpenHarmony 轻内核A核源码分析系列三 物理内存(1)
从本篇开始,我们分析下鸿蒙轻内核A核的内存管理部分,包括物理内存、虚拟内存、虚拟映射等部分。物理内存(Physical memory)是指通过物理内存条而获得的内存空间,相对应的概念是虚拟内存(Virtual memory)。虚拟内存使得应用进程认为它拥有一个连续完整的内存地址空间,而通常是通过虚拟内存和物理内存的映射对应着多个物理内存页。本文我们先来熟悉下OpenHarmony鸿蒙轻内核提供的物理内存(Physical memory)管理模块。
小帅聊鸿蒙
2025/05/30
750
OpenHarmony 轻内核A核源码分析系列三 物理内存(1)
OpenHarmony 轻内核A核源码分析系列七 进程管理 (2)
本文先熟悉下进程管理的文件kernel\base\core\los_process.c中的内部接口,读读代码,做些记录。
小帅聊鸿蒙
2025/06/06
530
OpenHarmony 轻内核M核源码分析系列六 任务及任务调度(3)任务调度模块
调度,Schedule也称为Dispatch,是操作系统的一个重要模块,它负责选择系统要处理的下一个任务。调度模块需要协调处于就绪状态的任务对资源的竞争,按优先级策略从就绪队列中获取高优先级的任务,给予资源使用权。
小帅聊鸿蒙
2025/05/22
910
推荐阅读
OpenHarmony内核源码分析(调度队列篇) | 内核有多少个调度队列
730
OpenHarmony 轻内核M核源码分析系列三 数据结构-任务排序链表
770
OpenHarmony 轻内核M核源码分析系列九 互斥锁Mutex
750
OpenHarmony 轻内核M核源码分析系列一 数据结构-双向循环链表
730
OpenHarmony 轻内核M核源码分析系列六 任务及任务调度(2)任务模块
940
OpenHarmony 轻内核M核源码分析系列十三 消息队列Queue
790
OpenHarmony 轻内核M核源码分析系列十一(1) 信号量Semaphore
390
OpenHarmony内核源码分析(任务调度篇) | 任务是内核调度的单元
1060
谁是鸿蒙内核最重要的结构体?
560
OpenHarmony内核源码分析(进程概念篇) | 进程在管理哪些资源
1090
OpenHarmony 轻内核M核源码分析系列十二 事件Event
930
OpenHarmony 轻内核A核源码分析系列七 进程管理 (1)
1120
鸿蒙内核源码分析(进程管理篇) | 谁在管理内核资源?
770
OpenHarmony内核源码分析(互斥锁篇) | 互斥锁比自旋锁丰满多了
1260
鸿蒙轻内核M核源码分析系列十三(续) 消息队列QueueMail接口
2830
OpenHarmony内核源码分析(线程概念篇) | 是谁在不断的折腾CPU
1230
OpenHarmony 内核源码分析(读写锁) | 内核如何实现多读单写
420
OpenHarmony 轻内核A核源码分析系列三 物理内存(1)
750
OpenHarmony 轻内核A核源码分析系列七 进程管理 (2)
530
OpenHarmony 轻内核M核源码分析系列六 任务及任务调度(3)任务调度模块
910
相关推荐
OpenHarmony内核源码分析(调度队列篇) | 内核有多少个调度队列
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档