当我们的源码中存在隐性的循环引用,在单文件打包(或 Dev 模式)时被 Rollup/Webpack 内部消化解决了;但当你配置了代码分割(Code Splitting),强制把它们拆成不同的 .js 文件(Chunk)时,浏览器在加载这两个相互依赖的文件时就会陷入死锁或引用 undefined。
举一个例子: 假设有两个模块 a.ts 和 b.ts,以及它们被打包成的两个 Chunk:ChunkA.js 和 ChunkB.js。
- 场景 :
a引用了b。b引用了a。 - 不拆包时 :它们在一个文件里,
Rollup可以通过变量提升(Hoisting)或函数包装来处理这种引用,通常能运行(只要不是在模块顶层立即执行代码)。 - 拆包后 : 浏览器加载
ChunkA.js。ChunkA头部声明import ... from 'ChunkB.js'。 浏览器暂停ChunkA的执行,去加载ChunkB。ChunkB头部声明import ... from 'ChunkA.js'。 死锁:ChunkA还没执行完(没导出东西),ChunkB就想要它的导出值。于是报错(通常是 ReferenceError 或 undefined)。
如何解决
解决方案一:修改分包策略
既然是因为拆开了才报错,最直接的办法就是强行把这两个冤家合并回同一个包里。
你需要修改 vite.config.ts 中的 build.rollupOptions.output.manualChunks。
ts
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// id 是文件的绝对路径
// 场景:假设你发现 utils/a.ts 和 utils/b.ts 发生了循环引用
if (id.includes('src/utils/a') || id.includes('src/utils/b')) {
return 'utils-merged'; // 强制把它们打包到同一个 utils-merged.js 中
}
// 场景:或者是某个特定的库拆分导致的问题
if (id.includes('node_modules/some-complex-lib')) {
return 'vendor-lib';
}
}
}
}
}
})
原理:只要它们在同一个 .js 文件里,Rollup 就能像以前一样处理它们的内部循环引用,执行就不会报错了
解决方案二:治理"全家桶"导出
这是拆包循环依赖最常见的罪魁祸首:index.ts。
- 场景:
src/components/index.ts 导出了所有组件:export * from './Button'; export * from './Modal';
Button.tsx 引用了 Modal:它不想写 ../Modal,而是图方便写了 import { Modal } from '.'; (引用了 index)。
-
结果 :
index依赖Button,Button依赖index。 -
拆包灾难: Vite 可能会把 index.ts 识别为一个公共 chunk,而把 Button 单独拆分。结果 CommonChunk 和 ButtonChunk 互相引用。
-
修复方法 : 在模块内部引用兄弟模块时,坚决不要通过
index.ts绕圈子,直接引用文件路径。
ts
// ❌ 错误:在 Button.tsx 中引用同级组件
import { Modal } from './index'; // 或者 import { Modal } from '.';
// ✅ 正确:直接引用文件
import { Modal } from './Modal';
解决方案三:提取公共依赖(根本解决)
如果是逻辑代码导致的循环(a用到b的常量,b用到a的函数):
做法:创建一个新的文件 c.ts。
把 a 和 b 共同需要的部分(类型、常量、纯函数)移动到 c.ts。 a 引用 c,b 引用 c。 循环链条被打断:A -> C <- B。 这样无论 Vite 怎么拆包,c 都会被单独拆出来(或者合并),a 和 b 都不再互相依赖,依赖树变成了有向无环图(DAG)。
如果是拆包导致node包之间的循环,则需要找到对应的包,让其放到公共的vendor中,就是方案一~
怎么分析、快速定位错误
其实这才是写这篇文章的初心,因为这种拆包导致的循环引用是很隐蔽的,打包时甚至不会报错或者显示警告,尤其当你去拆分node_module出现问题时,控制台报错的和真实循环引用可能完全不一样,所以分析起来会很难受,完全靠预览时控制台的报错和经验
通过控制台报错+rollup-plugin-visualizer
查看控制台报错定位具体的代码、然后通过rollup-plugin-visualizer查看生成的包结构,进而排查解决,这种方案可能会考验你对整体打包内容和代码的理解,很可能要靠半猜半试
通过vite插件
尝试过vite-plugin-circular-dependency,发现该插件是在打包前 扫描代码关系的,不能分析chunk的,所以自己写了一个vite-plugin-chunk-cycle-detector(这碟饺子的醋来了~),原理是通过打包模块获取每个chunk的信息,进而得知chunk的引入引出关系,最后通过使用 Kahn 算法检测是否有环。
- 使用
js
//vite.config.ts
import { defineConfig } from 'vite'
import chunkCycleDetectorPlugin from 'vite-plugin-chunk-cycle-detector'
export default defineConfig({
plugins: [
chunkCycleDetectorPlugin({
circleImportThrowError: true, // 检测到循环时抛出错误中断构建
showCircleImportModuleDetail: true // 显示具体模块间的依赖关系
})
]
})
假如有环则在打包时会输出:
bash
⚠️ 检测到 1 个 chunk 间的循环依赖:
🔄 循环1: chunkA → chunkB → chunkA
chunkA 中的模块 src/a.js 引用了 chunkB 中的模块 src/b.js
chunkB 中的模块 src/b.js 引用了 chunkA 中的模块 src/a.js
这样就很方便可以去分析了,(不过注意,该插件目前使用的是vite6版本)
为什么打包时没有错误或者警告
最后聊聊这个最坑的点,因为这是完全符合规范的(Vite/Rollup 的构建逻辑是基于 ESM (ECMAScript Modules) 标准的):
- 标准允许循环引用 :在 ESM 标准中,模块 A 引用模块 B,模块 B 引用模块 A,这在语法上是完全合法的。浏览器会建立一个引用链接,只要不在模块初始化的瞬间去调用还没准备好的值,就不会报错。
- 构建器的视角 :Rollup 在拆包时,看到生成了两个 Chunk(文件),它们头部互相
import对方。对于构建器来说,这是一个合法的 ESM 依赖图,它认为浏览器能搞定,所以它构建成功(Build Success) ,所以连警告都没有
只有当你发布生产包并用浏览器打开页面白屏了你才后知后觉,然后发现控制台的 Cannot access 'SomeComponent' before initialization或者是 Cannot read properties of undefined (reading 'xxx')