JS 模块循环引用问题
循环引用不可怕,可怕的是「在模块初始化阶段就去读对方还没赋值的导出」。理解求值顺序就能解释一切。
1. 问题出在求值顺序
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,问题没解决、只是藏起来了。
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 一节讲得最清楚,比反复试错快。