同一个库被三四个页面重复打包,splitChunks 的 minChunks 和 chunks 组合怎么设才不翻车

webpack 的 splitChunks 不是「设了就好」的开关,而是一组互相拉扯的杠杆。三四个页面重复打包同一个库,本质上是 chunksminChunkscacheGroups 三者的组合没对准你的路由结构。先说结论:如果你的页面入口是动态导入的异步 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 才是真正干活的地方

全局 splitChunkschunksminChunks 只是默认规则,真正决定「哪些库提取到哪个文件」的是 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 的依赖子集」这种诡异情况。

验证方式

配置完不是终点,必须验证。两个快速检查:

  1. 打包后看产物体积:ls -lh dist/js/*.js | sort -k5 -h,确认那个共享库只出现一次,且体积合理。
  2. 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,总延迟依然会累积。maxAsyncRequestsmaxInitialRequests 依然要设,防止 webpack 拆出几十个碎片 chunk。

能不能不拆分,直接每个页面打包一份完整依赖?

如果共享库总体积 gzip 后小于 50KB,且页面数量不超过 4 个,重复打包的冗余成本低于拆分带来的缓存失效风险和请求开销。但一旦共享库超过这个体量,或者页面数量增加,提取公共 chunk 的收益会迅速放大。具体阈值可以根据你的部署环境(HTTP 版本、CDN 缓存策略、用户网络分布)调整。