Skip to main content

JS 模块循环引用问题

循环引用不可怕,可怕的是「在模块初始化阶段就去读对方还没赋值的导出」。理解求值顺序就能解释一切。

1. 问题出在求值顺序​

a.jsimport { b } from …b.jsimport { a } from …求值顺序a 先开始① 进入 a② 进入 bb 读 a 的导出:此时还没绑上③ 拿到 undefinedESM 的绑定是「实时」的:b 里 later 读到的是最终值,危险只发生在初始化那一刻。CommonJS 的 exports 是快照,拿到的是当时的值(可能是空对象),表现和 ESM 不同但同样危险。
图:循环引用的危险发生在求值顺序上,而不是「循环」本身

ESM 的执行分三步:解析(扫描 import 建依赖图)、实例化(为每个模块分配空间、建立绑定)、求值(真正执行模块代码)。循环引用不会卡在前两步,卡在第三步——A 求值时 import 了 B,B 又回头要 A 的导出,而 A 的代码还没执行到赋值那一行。用 const/let 声明的导出处于暂时性死区,读到就抛 ReferenceError;var 与函数声明有提升缓冲,所以有时又能侥幸跑通,这也是同一个 bug 时好时坏的原因。

// a.mjs
import { b } from './b.mjs'
console.log(b) // 若 b 在求值阶段尚未赋值,这里抛 ReferenceError(TDZ)
export const a = 1

// b.mjs
import { a } from './a.mjs'
console.log(a)
export const b = 2

活绑定之外还要注意声明类型的差异。function 声明会被提升、在实例化阶段就已可用,跨环调用函数通常安全;const/let 在赋值前处于 TDZ,提前读才抛错。这正是同一个环「有时能跑、有时炸」的根源:入口不同、求值顺序不同,读到的变量有没有初始化也不同。注意 var 虽能 export,但它不存在 TDZ、会被提升到 undefined,靠它「躲」报错只是让对方在环里读到一个静默的 undefined,问题没解决、只是藏起来了。

a.jsimport bb.jsimport cc.jsimport a求值 b求值 c回到 a:尚未求值完ESM:实时绑定,拿到的是「还没初始化的绑定」,访问即抛 ReferenceError 或得到 undefined。CommonJS:拿到的是执行到那一行的 module.exports 快照,是个半成品对象。两种情况都不一定报错 —— 这正是它难查的原因。
图:依赖成环时,总有一个模块会在「还没执行完」的时候被别人读到

2. ESM 的提升与实时绑定​

ESM 导出的是活绑定而不是值的快照。这意味着只要在真正调用时才去读对方的导出,多数循环都能绕过去——把引用写进函数体、延迟到运行时读取,通常已经就绪。这条特性是 ESM 比 CommonJS 更抗循环的根本原因,也是「为什么本地能跑、打包后炸」的来源:打包器可能改变模块求值顺序,把侥幸变成必然。

// 把读取延迟到函数调用时,循环引用不再报错
import { getB } from './b.mjs'
export function useA() {
return getB() + 1 // 调用时才读,此时 b 已就绪
}

3. CommonJS 的导出快照​

CJS 导出的是 module.exports 对象的引用快照——注意是引用,不是深拷贝。循环时,被引入方拿到的是「执行到那一行的半成品」:如果对面还没给 exports.x 赋值,你读到的就是 undefined。更严重的是 module.exports = {...} 整体替换写法——循环方手里还是旧对象引用,新对象永远不会到手。所以「CJS 和 ESM 行为一样」是错的。

// a.js(CommonJS)
const b = require('./b.js')
console.log(b.value) // undefined:b.js 还没执行到赋值那行
module.exports = { value: 1 }

// b.js
const a = require('./a.js')
module.exports = { value: a.value }

4. 从 undefined 反查依赖环​

识别特征有两个:报错是 TDZ(ReferenceError)或 undefined is not a function(拿到的导出是 undefined 被当函数调)。确认方法:画依赖图找环,或者用依赖图工具检测循环依赖。先确认是不是环,再谈怎么解——很多「undefined」其实另有原因。

定位依赖环不用靠猜,工具能直接把环列出来:

# 列出所有循环依赖环(两个文件互相引用时会打印出来)
npx madge --circular src/

# 只看某个入口牵扯到的环,输出更聚焦
npx madge --circular --extensions ts,tsx src/main.tsx

5. 拆环的三种手法​

三种解法,各有适用条件:

5.1 下沉公共模块​

把互相引用的部分抽到一个第三方模块,让环变成树。最稳,适合共享的是纯数据/纯函数。

5.2 动态 import​

把循环方改成运行时 import(),延迟到需要时才加载,绕过初始化顺序。适合低频、非启动必需的依赖。

5.3 依赖倒置​

把「值」改成「函数调用」——A 不直接读 B 的导出,而是调用 B 传进来的回调。适合无法拆分的耦合。

最稳的永远是第一种:把环拆成树。

还有一个隐蔽来源是 barrel 文件(index.js 把同目录所有模块 re-export)。在包内部从 ./index.js 引入,会把每个兄弟模块都拉进来,很容易在不知情的情况下形成环——你以为只引了一个工具函数,其实把整棵子树都拽进了依赖图。配合 ESLint 的 import/no-cycle 主动拦截能挡住大部分;但要小心类型循环:import type 在编译期被擦除、运行时无环,一旦某天在同一文件加了值导出,类型环就瞬间变成值环,而代码评审里往往看不出来,属于「埋雷」型。

CommonJS 下的循环会给出明确警告,加上这个开关就能看到完整链路:

# 输出形如:Accessing non-existent property 'x' of module exports inside circular dependency
node --trace-warnings app.js 2>&1 | grep -A 6 "circular dependency"

从 undefined 往回看是谁还没求值完

报错是 undefined is not a function,调用点在 b.js 使用 a.js 的导出。先别改业务判断。把两个文件的顶层 import 画出来:a 在顶层用了 b,b 在顶层用了 a。执行到 b 的那一行时,a 的导出赋值还没跑到,读到的是绑定已经建立、值还是空。

ESM 里把使用挪进函数,等两边都求值完再调用,多数情况就有值了。这是活绑定,不是把导出拷了一份。若是 CommonJS,module.exports = {...} 整对象替换之后,循环的那一方手里还是替换前的对象,挪进函数也拿不到新对象。这种要拆开循环,把共用的常量放到第三个文件,或者让其中一方改为函数参数传入,不要在顶层互相引用。

打包之后顺序可能和本地 dev 不一样。本地能跑、构建后报 undefined,先怀疑求值顺序被重新排过,不要只在本地的模块图上结论。

ESM 循环依赖的求值顺序属于规范行为,Node.js ESM 文档 的 Cycles 一节讲得最清楚,比反复试错快。