Vite拆包后Chunk级别的循环依赖分析及解决方案

当我们的源码中存在隐性的循环引用,在单文件打包(或 Dev 模式)时被 Rollup/Webpack 内部消化解决了;但当你配置了代码分割(Code Splitting),强制把它们拆成不同的 .js 文件(Chunk)时,浏览器在加载这两个相互依赖的文件时就会陷入死锁或引用 undefined

举一个例子: 假设有两个模块 a.tsb.ts,以及它们被打包成的两个 Chunk:ChunkA.jsChunkB.js

  • 场景a 引用了 bb 引用了 a
  • 不拆包时 :它们在一个文件里,Rollup可以通过变量提升(Hoisting)或函数包装来处理这种引用,通常能运行(只要不是在模块顶层立即执行代码)。
  • 拆包后 : 浏览器加载 ChunkA.jsChunkA 头部声明 import ... from 'ChunkB.js'。 浏览器暂停 ChunkA 的执行,去加载 ChunkBChunkB 头部声明 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 依赖 ButtonButton 依赖 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')

相关推荐
快乐星球喂4 小时前
子组件和父组件之间优雅通信---松耦合
前端·vue.js
风止何安啊4 小时前
Steam玩累了?那用 Node.js 写个小游戏:手把手玩懂 JS 运行环境
前端·javascript·node.js
不想秃头的程序员4 小时前
JS原型链详解
前端·面试
simon91244 小时前
ElementUI:表格如何展示超出单元格的内容且不影响单元格?
前端·vue.js·element
天外天-亮4 小时前
Vue 中常用的指令
前端·javascript·vue.js
清风细雨_林木木4 小时前
vite与vue的cli的区别
前端·javascript·vue.js
亚洲小炫风4 小时前
react 资源清单
前端·javascript·react.js
IT古董4 小时前
【前端】Headless UI 深度实战:构建可访问、可定制的现代前端组件
前端·ui
南囝coding4 小时前
Knip - 一键清理项目无用代码
前端·后端