
前几天在给一个 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-vendorB 模块也因为引用关系被分到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.0和vue 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-vendor和lib-vendor这两个 chunk 被正确生成首页入口 chunk 明显变小。再用vite preview跑本地预览控制台没有出现任何模块加载失败。动态 import 的页面文件也被单独拆成了独立 chunk说明默认的动态分包策略没有被破坏。这也验证了一个观点当你无法完全掌控manualChunks和动态 import 的关系时少干预反而更安全。4. 一套可以照抄的“安全分包”配置模板4.1 明确你的分包目标在动手加manualChunks之前先问自己三个问题你的项目里哪些库是首屏必然会用到的比如vue、vue-router、pinia这些可以合并成一个框架层 chunk。哪些库是体积大但更新频率低的比如axios、dayjs、lodash-es可以合并成lib-vendor。哪些库只在某几个页面用到比如echarts、pdfjs建议不要放进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_CHILDCannot read properties of undefined (reading url)可能和动态 import 的 chunk 名冲突相关确认动态 import 的模块没有被manualChunks捞走这个表很实用我实际排查时就是照着这个思路逐步收窄范围的。当然每个项目的情况可能有差异但大的排查方向基本一致先确认是不是manualChunks导致再确认是哪个 chunk 名冲突最后调整规则。5.2 搜索报错时容易遇到的“噪音”在网上搜SHOW_CHILD这个报错的时候你大概率会看到一大堆完全无关的内容。比如undefined reference to winmain、call to undefined method ...、undefined symbol这些本质上都是 C/PHP 等场景下的链接错误或方法调用错误跟 Vite 没有半毛钱关系。别被这些热词带偏搜索时尽量带上vite、rollup、manualChunks这三个关键词一起搜命中率会高很多。我个人的习惯是先搜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.include或resolve.alias里也做了类似的依赖合并处理某些依赖的缓存 key 可能发生变化间接导致改 vue 文件不热更新、改 js 文件才更新这类怪现象。如果你遇到“Vite 改 vue 文件不热更新了”先看终端里 HMR 日志有没有报错再检查是不是某个依赖被意外放进了optimizeDeps.include。这个问题和 SHOW_CHILD 本质上不是同一个坑但都属于“构建配置影响开发体验”的范畴放在一起排查有助于打开思路。6.2 最后再分享一个小技巧如果你要频繁调整分包策略建议写一个小的构建脚本只打包不预览并且把构建耗时输出出来npm run build 21 | tee build.log然后把build.log保存下来。当你改完manualChunks再跑一次对比两次日志里的 chunk 数量、文件大小和有没有新增 warning能非常快地发现分包变化带来的副作用。这个习惯帮我省了很多次“凭感觉调配置”的时间。我个人在实际操作中的体会是manualChunks虽然给出的自由度很大但真正适合手工干预的场景并没有想象中那么多。大部分项目把框架层和公共库层分别合并成一个 chunk剩下的交给 Vite 默认策略就已经能拿到很不错的缓存命中率和加载性能。越是复杂的分包规则越容易在某次依赖更新或页面结构调整后触发SHOW_CHILD这类难以定位的异常到时排查的成本会远超过那点体积优化带来的收益。希望这篇踩坑记录能帮你少走一段弯路。