同一个库被三四个页面重复打包,splitChunks 的 minChunks 和 chunks 组合怎么设才不翻车
webpack 的 splitChunks 不是「设了就好」的开关,而是一组互相拉扯的杠杆。三四个页面重复打包同一个库,本质上是 chunks、minChunks、cacheGroups 三者的组合没对准你的路由结构。先说结论:如果你的页面入口是动态导入的异步 chunk,chunks: 'all' + minChunks: 2 是最稳的起点;如果入口全是传统多页(MPA)的同步 script,你需要的是 chunks: 'initial' + 针对 vendor 的显式 cacheGroup,而不是靠全局 minChunks 硬猜。
下面按真实场景拆开说。
先确认你的页面是怎么进来的
这个问题看起来无关,但直接决定 chunks 该选什么。很多人配置翻车,是因为没意识到 webpack 里的「chunk」分三种:入口 chunk(initial)、异步 chunk(async)、以及被两者共享的 chunk。chunks: 'all' 字面意思是「所有 chunk 都参与提取」,但它对 initial 和 async 的处理逻辑并不一样。
如果你的三四个页面是 SPA 里的路由组件,用 import() 动态加载,那每个页面本身就是一个 async chunk。此时设 chunks: 'all' 或 chunks: 'async' 都能覆盖到这些页面 chunk,区别在于 'all' 还会把入口文件(比如 main.js)里同步引用的库也拉进提取范围。对于「三四个页面重复打包同一个库」这个诉求,'all' 是对的,因为你要的是「不管同步异步,只要被多个 chunk 用到就提出来」。
但如果是传统 MPA,每个 HTML 对应一个 entry,页面之间没有动态导入关系,那这些页面 chunk 全是 initial。此时 chunks: 'async' 完全失效——它只处理异步 chunk,你的页面根本不在范围内。必须用 'initial' 或 'all'。
所以第一步不是调参数,是打开 webpack 的 stats 或打包分析图,确认那些重复打包的库到底出现在哪类 chunk 里。这一步跳过,后面全是盲调。
minChunks 的真实含义比你想的窄
minChunks: 2 表示「一个模块至少被 2 个 chunk 引用,才会被提取到公共 chunk」。注意这里的计数单位是 chunk,不是页面,也不是 import 次数。一个页面 chunk 里 import 了 10 次 lodash,计数只加 1。
三四个页面重复打包同一个库,minChunks: 2 理论上已经能触发提取。但如果你的页面 chunk 里,库的引用方式不一致——比如 A 页面 import { debounce } from 'lodash',B 页面 import _ from 'lodash'——webpack 对 tree shaking 后的模块边界判断可能把这两个「lodash」视为不同的模块集合,导致计数达不到预期。这种情况建议在 cacheGroup 里用 test: /[\\/]node_modules[\\/]lodash[\\/]/ 直接锁定包路径,绕过 minChunks 的计数逻辑。
另一个翻车点是 minChunks 设太高。比如设成 3 或 4,而某个库只被 2 个页面用到,它就不会被提取,继续重复打包。反过来设成 1 会把所有库都提出来,包括每个页面独有的依赖,结果就是首屏加载一个巨大的 vendor chunk,里面一半代码当前页面用不上。对于「三四个页面共享」这个场景,minChunks: 2 是安全线,但前提是配合合理的 cacheGroups 分组,不是全局一把梭。
cacheGroups 才是真正干活的地方
全局 splitChunks 的 chunks 和 minChunks 只是默认规则,真正决定「哪些库提取到哪个文件」的是 cacheGroups。没有 cacheGroup 的 splitChunks 配置,等于只开了默认分组,webpack 会自动把 node_modules 里的东西塞进一个叫 vendors 的 chunk,但这个默认行为经常不是你想要的产物粒度。
典型翻车场景:三个页面都用了 antd 和 moment,两个页面用了 lodash。默认配置下,webpack 可能把 antd、moment、lodash 全塞进一个 vendors chunk,因为它们的引用次数都达到了 minChunks。结果就是页面 A 只需要 antd,却被迫加载了 lodash。这不是重复打包,但同样糟糕——从一个坑跳进另一个坑。
正确做法是给高频、大体量的库单独建 cacheGroup:
splitChunks: {
chunks: 'all',
minChunks: 2,
cacheGroups: {
antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'vendor-antd',
priority: 20,
minChunks: 2,
},
lodash: {
test: /[\\/]node_modules[\\/]lodash[\\/]/,
name: 'vendor-lodash',
priority: 10,
minChunks: 2,
},
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-common',
priority: 0,
minChunks: 2,
},
},
}
priority 决定一个模块同时匹配多个 cacheGroup 时归谁管。antd 里可能内嵌了 lodash 的部分代码,priority 高的 antd 组会先匹配,避免 lodash 被拆散到两个 chunk 里。
这里有个细节:name: 'vendor-antd' 这种固定名字在多入口场景下要小心。如果 antd 只被部分页面引用,而你在 cacheGroup 里设了 minChunks: 2,那提取出来的 vendor-antd chunk 只被引用了它的页面加载。这没问题。但如果你把 minChunks 设成 1,vendor-antd 会被所有入口加载,即使某个页面根本不用 antd——因为 initial chunk 的依赖关系是静态的,webpack 会把所有 initial 共享 chunk 都塞进每个 HTML。这就解释了为什么有些人配完 splitChunks 后,首屏反而更慢了。
网络请求数 vs 重复代码的权衡
三四个页面重复打包同一个库,最坏情况是每个页面各带一份 200KB 的依赖,总共多出 600KB 冗余。提取成公共 chunk 后,这些冗余消失,但新增了 1 个网络请求。如果这个库本身只有 20KB,gzip 后 7KB,那为了省 60KB 冗余去增加一个请求,在 HTTP/2 下可能不划算,在 HTTP/1.1 下更不划算(连接数限制)。
这里给出一个可操作的判断线:单个库压缩后超过 30KB,且被至少 3 个页面共享,就值得提取。低于这个体量,重复打包的代价小于额外请求的代价。这个数字不是拍脑袋,是基于浏览器并发连接数和 TCP 慢启动的开销估算——30KB 以下的小文件,单独请求的 RTT 开销占比过高,塞进页面 chunk 里跟着一起下载反而更快。
如果你的部署环境已经全量 HTTP/2,请求数量不再是大问题,可以更激进地拆分。但 HTTP/2 下依然要控制「瀑布深度」——一个页面加载时,如果入口 chunk 依赖了 5 层异步 chunk,每层都有一个新的 vendor 依赖,那解析和加载的串行延迟会累积。这就是为什么 chunks: 'all' 比 'async' 更安全:它把 initial 和 async 的共享依赖都提取到入口层,减少了异步 chunk 内部的依赖嵌套。
实战配置模板
直接给一套我验证过的配置,适用于「SPA + 动态路由 + 三四个页面共享部分库」的场景:
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000, // 小于 20KB 的不提取,避免碎片请求
minChunks: 2,
maxAsyncRequests: 6, // 控制异步 chunk 的并发请求上限
maxInitialRequests: 4, // 控制入口 chunk 的并发请求上限
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: 'vendor-react',
priority: 30,
minChunks: 2,
reuseExistingChunk: true,
},
antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design|rc-)[\\/]/,
name: 'vendor-antd',
priority: 20,
minChunks: 2,
reuseExistingChunk: true,
},
charts: {
test: /[\\/]node_modules[\\/](echarts|zrender|d3)[\\/]/,
name: 'vendor-charts',
priority: 15,
minChunks: 2,
reuseExistingChunk: true,
},
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-common',
priority: 0,
minChunks: 2,
reuseExistingChunk: true,
},
},
},
}
三个注意点:
minSize: 20000和各个 cacheGroup 的minChunks: 2是叠加生效的。一个库要同时满足「压缩后大于 20KB」和「被至少 2 个 chunk 引用」才会被提取。如果你发现某个库明明被共享却还在重复打包,先查它压缩后是否真的超过了 20KB。maxInitialRequests: 4意味着入口 HTML 最多并行加载 4 个 chunk。如果共享库太多,webpack 会合并一些 chunk 来满足这个上限。这是好事,防止首屏请求爆炸。reuseExistingChunk: true的作用是:如果某个模块已经被其他 cacheGroup 提取过了,就不再重复提取到当前 chunk。这个选项默认是 false,但在多 cacheGroup 场景下强烈建议打开,否则可能出现「vendor-react 里有一份 react,vendor-antd 里又有一份 react 的依赖子集」这种诡异情况。
验证方式
配置完不是终点,必须验证。两个快速检查:
- 打包后看产物体积:
ls -lh dist/js/*.js | sort -k5 -h,确认那个共享库只出现一次,且体积合理。 - 用
webpack-bundle-analyzer打开分析图,搜索那个库的名字,确认它只在一个 chunk 里出现,且这个 chunk 被所有需要的页面引用。
如果发现共享库被提取了,但页面加载时依然请求了重复代码,多半是 chunks 设成了 'initial',而你的页面是 async chunk——提取出来的公共 chunk 没有被异步页面正确引用。反过来,如果设了 'all' 但 initial 入口和 async 页面加载了同一份 vendor 的重复模块,检查 reuseExistingChunk 是否打开。
常见问题
minChunks 设成 2 还是 3?
取决于你的页面数量。三四个页面共享,minChunks: 2 就能提取。如果设成 3,意味着至少 3 个页面引用才会提取,4 个页面里只有 2 个用到的库会继续重复打包。除非你有明确的「只有被大多数页面使用的才提取」策略,否则 minChunks: 2 是更安全的选择。
为什么配了 splitChunks 后首屏反而更慢?
大概率是 chunks: 'initial' 配合多个 cacheGroup,导致入口 HTML 一次性加载了所有 initial 共享 chunk,包括当前页面根本用不到的库。解决办法是把 chunks 改成 'all',或者给非全局共享的库单独设 minChunks 更高的阈值,或者干脆把这些库放进异步 chunk 里按需加载。
HTTP/2 下还需要控制请求数量吗?
需要,但控制的是「关键路径上的请求深度」,不是绝对数量。HTTP/2 的多路复用解决了连接数上限问题,但一个页面从入口 chunk 到最终渲染,如果中间串行了 5 层异步依赖,每层都有独立的 vendor chunk,总延迟依然会累积。maxAsyncRequests 和 maxInitialRequests 依然要设,防止 webpack 拆出几十个碎片 chunk。
能不能不拆分,直接每个页面打包一份完整依赖?
如果共享库总体积 gzip 后小于 50KB,且页面数量不超过 4 个,重复打包的冗余成本低于拆分带来的缓存失效风险和请求开销。但一旦共享库超过这个体量,或者页面数量增加,提取公共 chunk 的收益会迅速放大。具体阈值可以根据你的部署环境(HTTP 版本、CDN 缓存策略、用户网络分布)调整。