先记住这个答案
会抛 ReferenceError: Cannot access 'x' before initialization。ESM 的导入是实时绑定,导入名指向的就是导出方的那个 let 变量。循环依赖里,如果导出方还没执行到 let x = ... 这一行,这个变量就处在暂时性死区,也就是「已经声明但还不能访问」的状态,导入方在顶层读它自然报错。等导出方执行完再读就正常。
- 对方的
let还没执行到声明,导入方读取抛ReferenceError - 暂时性死区跟着实时绑定延伸到了导入方
var读到undefined,函数声明可以正常调用- 修法:把读取挪进函数,或拆掉循环
复现这个报错
main.js 是入口,导入了 store.js;store.js 为了拿配置又导入了 main.js。
letJavaScript// main.js(入口)
import { store } from './store.js'
export let theme = 'dark'
// store.js
import { theme } from './main.js'
console.log(theme) // ReferenceError: Cannot access 'theme' before initialization
export const store = { theme }执行顺序是:main.js 依赖 store.js,所以 store.js 先执行。这时 main.js 一行代码都没跑,theme 已经被登记,但还没执行到 let theme = 'dark'。store.js 在顶层读它,就撞上了暂时性死区。
这个报错会中断 store.js 的执行,进而整个模块图加载失败,main.js 也不会执行。浏览器里的表现是控制台一条红色报错,页面没有内容。
为什么导入方也受暂时性死区影响
暂时性死区本来是单个作用域里的概念:let 声明之前的那段代码不能访问这个变量。放在模块里,同一个规则延伸到了导入方,因为导入方拿到的不是值的拷贝,而是指向那个变量的引用。变量还没初始化,从哪个模块去读都一样。
对比一下其他声明方式。var 在实例化阶段就被设成 undefined,所以提前读不报错,但读到的是 undefined,store 里的 theme 会一直是 undefined,比报错更难发现。函数声明在实例化阶段就创建好了,提前调用没问题。
两种修法
第一种是延迟读取。不在顶层读,而是放进函数里,调用时对方已经执行完了。比如把 export const store = { theme } 改成 export function getStore() { return { theme } },入口执行完再调用 getStore(),读到的就是 'dark'。
第二种是拆掉循环。store.js 真正需要的只是 theme 这个配置,它不该从入口文件拿。把 theme 挪到单独的 config.js,main.js 和 store.js 都从 config.js 导入,循环就没了。这是更推荐的做法,循环依赖本身就说明模块边界没划清。
容易答错的地方
- 说会读到
undefined - 那是 CommonJS 或者
var导出的表现。ESM 里let、const、class导出都不会给你undefined,而是直接抛错。 - 为了不报错把
let改成var - 报错是没了,但读到的是
undefined,业务逻辑拿着错误的值继续跑,问题被藏了起来。正确做法是调整读取时机或者拆掉循环。 - 以为是
import语句位置的问题 - 有人会把
import挪到文件底部试图修复。import声明不管写在哪儿都会先处理,挪位置对执行顺序没有任何影响。
面试官还会怎么问?
如果入口换成 store.js,还会报错吗?
不会。这时 main.js 先执行,theme 已经赋值,store.js 再执行就能读到 'dark'。循环依赖的代码能不能跑,取决于哪个模块先被加载,这正是它难排查的地方。
项目里怎么提前发现循环依赖?
可以用 madge --circular src 扫描依赖图,列出所有循环。Webpack 项目可以用 circular-dependency-plugin 在构建时报警。ESLint 插件 eslint-plugin-import 的 import/no-cycle 规则也能在写代码时提示。
导出方后来修改了 theme,导入方能看到吗?
能。只要读取发生在初始化之后,导出方执行 theme = 'light',导入方下一次读到的就是 'light'。但导入方自己不能给 theme 赋值,会抛 TypeError。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。