Vite构建报错解析:manualChunks引发SHOW_CHILD异常与修复
2026/9/9 18:50:12 网站建设 项目流程

前几天在给一个 Vue3 项目调 Vite 构建配置,打算把node_modules里的常用库单独拆包,减少首屏加载体积。结果刚加上build.rollupOptions.output.manualChunks没几分钟,打包命令就报了一行很奇怪的运行时异常:SHOW_CHILD of 'vue' is undefined

注意,这不是那种带文件路径和行列号的编译错误,更像是打包过程中的内部逻辑崩了。日志里能看到完整的npm run build执行过程,紧接着就是ERROR输出这个信息,再往下没有任何 JS/CSS 产物。我当时第一反应:是不是manualChunks的写法有问题?

这个问题适合所有在 Vite 项目里手动配置代码分割的开发者参考,尤其是那些一上来就抄网上“花式分包”配置、把整个node_modules按包名拆分的同学——你们大概率也会在某一天撞上它。这篇文章我会从根因、复现、定位到修复完整过一遍,最后给你一套可以直接抄的“安全分包”配置。

1. SHOW_CHILD 不是 Vite 的锅,是 Rollup 内部的一致性检查

1.1 这个报错到底从哪来

Vite 在生产构建时,实际是调用了 Rollup 来完成最后的打包和代码分割。manualChunks这个配置最终也会透传给 Rollup 的output.manualChunks。所以当你看到SHOW_CHILD of 'xxx' is undefined时,首先要意识到:这是 Rollup 在生成 chunk 依赖关系时抛出的校验信息,而不是 Vite 某个插件报的业务错误。

Rollup 源码里有一个showChild方法,专门用来在生成 chunk 时检查一个 chunk 是否在内部依赖图中被正确引用。如果某个 chunk 名字已经在另一个 chunk 的引用关系中被记录下来,但在实际生成的 chunk 集合中找不到对应模块,就会触发SHOW_CHILD,并给出 undefined 的提示。通俗点讲:A 说“我依赖 B”,结果跑到 B 门口发现 B 根本不存在。

这么说有点抽象,我换个生活化的比喻。你可以把每个 chunk 想象成快递包裹,打包的过程就是分拣中心按地址归类。manualChunks的作用是提前告诉分拣员“哪些商品必须装进同一个箱子”。SHOW_CHILD这个报错就相当于:分拣员把“vue 库”写在了某个箱子的面单上,但真的去取货的时候,仓库里没有一个叫 vue 的箱子。这时候系统只能报一个SHOW_CHILD of 'vue' is undefined来告诉你:内部单据上引用的子包裹不存在。

1.2 为什么日志里不带文件路径

很多同学第一次看到这个报错都很困惑:报错信息里没有具体的文件路径,也没有调用堆栈,甚至不知道是哪个模块引发的。原因是它发生在 Rollup 的 generate 阶段,这个阶段早于代码写入磁盘,错误对象本身并不是一个带定位信息的编译错误,而是一个内部逻辑校验失败。

这导致它特别难排查,尤其是当你对 Rollup 的 chunk 生成机制不熟悉时,光看报错根本无从下手。我见过一些项目因为这个报错直接把manualChunks整段删掉,回到默认分包策略,虽然构建恢复了,但代价是首屏加载的文件数量又回去了,缓存利用率和并行加载效率都明显下降。

所以这个坑值得从根本上搞清楚。你不需要成为 Rollup 源码专家,但至少要知道manualChunks的哪些写法会破坏 chunk 依赖图的一致性。

2. 常见写法里,哪几种最容易触发这个异常

2.1 用模块完整路径当 chunk 名

这个是我见过最多的踩法。有些同学为了“精确分包”,直接把模块的绝对路径拼到 chunk 名里:

manualChunks(id) { if (id.includes('node_modules')) { return id.replace(process.cwd(), ''); } }

这种写法有两个问题。

第一,返回的 chunk 名里很可能带上/\.这类字符,Rollup 虽然允许 chunk 名里包含路径分隔符,但一旦某个 chunk 名生成的实际文件路径,恰好与另一个 chunk 名对应的目录结构产生重叠,就会在依赖图里出现同名歧义。第二,同一个模块可能被多个 importer 引用,如果manualChunks函数对同一个模块在不同上下文里返回了不同的 chunk 名,比如依赖了某个外部状态,Rollup 就可能在合并 chunk 关系时发现引用断裂。

实际项目中,我建议 chunk 名只用字符串字面量,或者从模块 id 中提取稳定的包名,并且只保留一层目录名。

2.2 在 manualChunks 里调用 getModuleInfo 做递归遍历

Rollup 的manualChunks函数可以接收第二个参数{ getModuleInfo },有些博客会教你用它来实现“把某个公共依赖提取到上级 chunk”这种高级玩法:

manualChunks(id, { getModuleInfo }) { if (id.includes('node_modules')) { const info = getModuleInfo(id); for (const importer of info.importers) { if (importer.includes('src/layout')) { return 'layout-vendor'; } } } }

问题在于:manualChunks本身是在模块图遍历的过程中执行的,当你在它内部再去递归读取importers/importedIds的时候,很容易在某个环节形成环。比如 A 模块被分到layout-vendor,B 模块也因为引用关系被分到layout-vendor,但 A 的 importer 同时也是 B 的 importer,这种交叉引用会让 Rollup 在生成 chunk 关系时出现“一个 chunk 引用了自己还没定义的子 chunk”。

我并不是说getModuleInfo完全不能用,而是说在你对 Rollup 内部机制不够了解时,这种“根据父子关系动态命名 chunk”的做法非常容易踩雷。我在自己的项目里最终选择了一种更保守的方案,后面 4.2 会贴出来。

2.3 对象形式和函数形式混用,或同一个 chunk 名在不同目录下重复

有些项目既有manualChunks: { vue: ['vue'] }这种对象写法,又在某个插件里用函数方式返回了同样叫vue的 chunk。不要小看这种冲突,Rollup 内部对 chunk 名的处理有一套去重逻辑,当同一个名字被两种不同来源同时声明,但指向的模块集合并不完全一致时,就可能出现生成期引用不一致。

还有一种情况:项目通过 pnpm 管理依赖,node_modules下存在多个版本的同一个库,比如vue 3.4.0vue 3.5.0分别嵌套在不同目录里。如果你用id.split('/node_modules/')[1].split('/')[0]来提取包名,那么两个版本的 vue 都会返回vue这个 chunk 名。Rollup 会尝试把它们合并到同一个 chunk,但合并时如果遇到某个模块只在其中一个版本的依赖图中存在,就可能触发showChild引用缺失。

2.4 动态 import 的模块被强行拉到某个 chunk 里

这是比较隐蔽的一种。Vite 项目里我们经常用import('@/pages/Home.vue')做路由懒加载。如果手动分包时,把某个已经被动态 import 的模块也声明到了manualChunks的某个 chunk 中,那么该模块就会同时拥有“动态入口”和“静态 chunk 成员”两个身份。Rollup 处理这种双重身份时,需要额外生成一个入口 chunk 去承接动态加载,如果这段逻辑和manualChunks的名称产生冲突,就可能报SHOW_CHILD of 'xxx' is undefined

我遇到的实际案例里,报错信息中的xxx恰好就是我们项目的业务模块路径片段,而不是vue这种库名。所以当你看到SHOW_CHILD of 'src/xxx/index' is undefined时,别急着怀疑业务代码,回头看看是不是这个业务模块被手动分包规则命中了。

3. 我这次踩坑的完整复盘:从复现到修复

3.1 最小复现:先把它稳定触发

我这次的项目结构大概是这样的:

src/ pages/ Home/index.vue About/index.vue components/ Button.vue utils/ request.ts node_modules/ vue/ vue-router/ pinia/ axios/

vite.config.ts里我一开始的manualChunks写法:

manualChunks(id) { if (id.includes('node_modules')) { return id.split('/node_modules/')[1].split('/')[0]; } if (id.includes('/src/utils/')) { return 'app-utils'; } }

执行npm run build,第一次构建竟然成功通过了。我当时还以为没问题。然后我随手新增了一个页面,并让这个页面通过动态 import 引入@/components/Button.vue,再次构建,SHOW_CHILD of 'src/components/Button.vue' is undefined就出现了。

这个细节很有意思,也解释了为什么这类问题在开发阶段很难暴露:它往往要等到动态 import 和manualChunks的规则交织在一起时才会触发。如果你每次改动后都构建一次,可能会发现它时好时坏——这不完全是玄学,而是取决于模块图是否触发了某个特定的引用环。

3.2 定位方法:二分法关闭分包

复现之后,我把manualChunks里的规则逐步注释,看哪一条规则会导致报错。先把if (id.includes('/src/utils/'))这段注释掉,构建恢复;恢复之后再单独保留它,构建也恢复;但把两段规则同时打开,报错再次出现。

这就锁定了一个规律:问题不在某一条规则本身,而在于某条业务模块的规则和 node_modules 分包规则产生交叉引用时,Rollup 生成的 chunk 依赖图出现了不一致。

于是我把重点放在 node_modules 分包上,进一步细分后发现:当vue-router相关模块被拆到vue-router这个 chunk,而pages/Home.vue又通过动态 import 引用了vue-router内部的某个组件模块时,Rollup 在处理动态 chunk 的引用关系时找不到对应的子 chunk。说白了,我的业务分包规则把动态加载模块也划了进去,破坏了动态 import 生成的边界。

3.3 最终修复方案

最终我改成了保守但稳定的写法:

manualChunks(id) { if (!id.includes('node_modules')) return; if (id.includes('/vue/') || id.includes('/@vue/')) return 'vue-vendor'; if (id.includes('/vue-router/') || id.includes('/pinia/')) return 'vue-vendor'; if (id.includes('/axios/') || id.includes('/lodash-es/')) return 'lib-vendor'; }

同时确保manualChunks函数里绝不处理业务源码文件,也就是id.includes('src/')的情况直接返回 undefined,让 Vite 和 Rollup 默认处理动态 import 的边界。这样改完之后,连续构建十多次,没有再出现SHOW_CHILD报错。

这个修复方案不是唯一的,但思路很明确:manualChunks只负责“把node_modules里的库按大类合并”,业务代码的分包完全交给 Vite 默认策略。你可能会觉得这不够极致,但在稳定性和产物拆分粒度之间,这个平衡点对于绝大多数中大型项目是够用的。

3.4 验证构建产物

修复后我检查了dist/assets下的产物:

ls -la dist/assets | head -20

能看到vue-vendorlib-vendor这两个 chunk 被正确生成,首页入口 chunk 明显变小。再用vite preview跑本地预览,控制台没有出现任何模块加载失败。

动态 import 的页面文件也被单独拆成了独立 chunk,说明默认的动态分包策略没有被破坏。这也验证了一个观点:当你无法完全掌控manualChunks和动态 import 的关系时,少干预反而更安全。

4. 一套可以照抄的“安全分包”配置模板

4.1 明确你的分包目标

在动手加manualChunks之前,先问自己三个问题:

  • 你的项目里哪些库是首屏必然会用到的?比如vuevue-routerpinia,这些可以合并成一个框架层 chunk。
  • 哪些库是体积大但更新频率低的?比如axiosdayjslodash-es,可以合并成lib-vendor
  • 哪些库只在某几个页面用到?比如echartspdfjs,建议不要放进manualChunks,让动态 import 自动拆分。

这看起来是常识,但很多人一上来就抄网上的“node_modules 按首段目录全拆”配置,最后拆出几十个 chunk,不但没有优化加载速度,反而增加了 HTTP 请求数和路由切换时的加载延迟。

4.2 稳定的 Vite 配置

下面是我目前常用的模板,直接复制到vite.config.ts就能用:

export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (!id.includes('node_modules')) return; const match = id.split('/node_modules/')[1]?.match(/^(@[^/]+\/[^/]+|[^/]+)/); const pkgName = match?.[1] ?? ''; if (!pkgName) return; if ( pkgName.startsWith('@vue') || pkgName.startsWith('vue') || pkgName.startsWith('pinia') || pkgName.startsWith('vue-router') ) { return 'vue-vendor'; } if ( pkgName.startsWith('axios') || pkgName.startsWith('lodash') || pkgName.startsWith('dayjs') ) { return 'lib-vendor'; } }, }, }, }, });

注意这段代码里的正则,它用^(@[^/]+\/[^/]+|[^/]+)去匹配node_modules后面的第一段路径。对于 scoped 包,比如@vue/shared,它能匹配出完整的@vue/shared,而不是只剩一个@vue。这个细节非常重要,很多踩坑的人就是栽在 scoped 包的分包粒度上。

4.3 为什么我会保留一个return而不是用花括号 return

你可能注意到 4.2 的代码里用了if (...) return;manualChunks函数返回undefined时,Rollup 会走默认分包逻辑,这完全合法。但有些同学会在这种判断里写return undefined;,效果一样,但可读性不如直接 return。

在团队协作的场景里,我习惯让这个函数只做一件事:命中规则就返回一个稳定的字符串 chunk 名,没命中就什么都不返回。职责单一的函数最不容易在后期被改出幺蛾子。比如有人后来想加一条业务模块分包规则,看到这个模板也清楚应该往哪里加,而不是把判断逻辑和返回值混成一团。

5. 常见问题速查:遇到类似报错时先看这张表

5.1 快速排查对照表

现象可能原因优先排查方向
SHOW_CHILD of 'vue' is undefinedchunk 名冲突,引用了不存在的 chunk检查manualChunks是否有同名 chunk,且指向不同模块集合
SHOW_CHILD of 'src/xx' is undefined业务模块被手动分包,且被动态 import 双重引用把业务源码从manualChunks中剔除
构建时好时坏,代码没改却偶尔报错模块图顺序影响 chunk 合并结果清缓存node_modules/.vite后重新构建
构建产物突然少了某个 JS 文件chunk 生成失败但被静默跳过检查构建日志里是否出现 warning 级别的 SHOW_CHILD
Cannot read properties of undefined (reading 'url')可能和动态 import 的 chunk 名冲突相关确认动态 import 的模块没有被manualChunks捞走

这个表很实用,我实际排查时就是照着这个思路逐步收窄范围的。当然,每个项目的情况可能有差异,但大的排查方向基本一致:先确认是不是manualChunks导致,再确认是哪个 chunk 名冲突,最后调整规则。

5.2 搜索报错时容易遇到的“噪音”

在网上搜SHOW_CHILD这个报错的时候,你大概率会看到一大堆完全无关的内容。比如undefined reference to winmaincall to undefined method ...undefined symbol这些,本质上都是 C++/PHP 等场景下的链接错误或方法调用错误,跟 Vite 没有半毛钱关系。别被这些热词带偏,搜索时尽量带上viterollupmanualChunks这三个关键词一起搜,命中率会高很多。

我个人的习惯是:先搜vite manualChunks SHOW_CHILD,如果找不到,再搜rollup SHOW_CHILD,因为这个问题在原生 Rollup 项目中同样会出现,Vite 只是把 Rollup 包了一层。

5.3 清缓存这个“万能药”什么时候有效

我见过不少人在遇到这个报错后,第一反应是删除node_modules/.vite重新构建。这个操作在一些偶发性的 chunk 合并问题上是有效的,因为它清掉了 Vite 的预构建缓存和 transform 缓存,让模块图重新生成。但对于manualChunks写法本身就有问题的场景,清缓存只能让报错晚出现一次,不能根治。

所以我的建议是:先清缓存试一次,如果第二次构建仍然稳定复现,就不要再重复清缓存了,把精力放到manualChunks的规则本身上。

6. 关于 HMR 失效和这个坑的关联,顺便提一嘴

6.1 manualChunks 是否影响开发环境

很多人会问:manualChunks不是生产构建才生效吗?为什么我开发环境热更新也出问题?

答案是:manualChunks本身不影响开发环境,因为 Vite 开发服务器用的是原生 ESM,并没有走 Rollup 打包。但如果你在optimizeDeps.includeresolve.alias里也做了类似的依赖合并处理,某些依赖的缓存 key 可能发生变化,间接导致改 vue 文件不热更新、改 js 文件才更新这类怪现象。

如果你遇到“Vite 改 vue 文件不热更新了”,先看终端里 HMR 日志有没有报错,再检查是不是某个依赖被意外放进了optimizeDeps.include。这个问题和 SHOW_CHILD 本质上不是同一个坑,但都属于“构建配置影响开发体验”的范畴,放在一起排查有助于打开思路。

6.2 最后再分享一个小技巧

如果你要频繁调整分包策略,建议写一个小的构建脚本,只打包不预览,并且把构建耗时输出出来:

npm run build 2>&1 | tee build.log

然后把build.log保存下来。当你改完manualChunks再跑一次,对比两次日志里的 chunk 数量、文件大小和有没有新增 warning,能非常快地发现分包变化带来的副作用。这个习惯帮我省了很多次“凭感觉调配置”的时间。

我个人在实际操作中的体会是:manualChunks虽然给出的自由度很大,但真正适合手工干预的场景并没有想象中那么多。大部分项目,把框架层和公共库层分别合并成一个 chunk,剩下的交给 Vite 默认策略,就已经能拿到很不错的缓存命中率和加载性能。越是复杂的分包规则,越容易在某次依赖更新或页面结构调整后触发SHOW_CHILD这类难以定位的异常,到时排查的成本会远超过那点体积优化带来的收益。希望这篇踩坑记录能帮你少走一段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询