Webpack 多页面应用的自动去重实战:深入 many-pages 示例理解 optimization.splitChunks 拆分算法
【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack
本指南以本仓库 examples/many-pages 示例为骨架,围绕optimization.splitChunks讲解 webpack 在多页面(Multi-Page Application,MPA)场景下自动去重(deduplication)与代码拆分的决策机制。该示例用 7 个互相共享 vendor 模块与业务模块的页面构造了一个典型的多入口应用,读完本文你将掌握chunks、minSize、maxInitialRequests/maxAsyncRequests等关键参数的取舍逻辑,能够判断“什么时候该拆、什么时候故意重复更划算”,并看懂拆分产物的命名与归属。
示例概览:7 个页面如何构成一张共享矩阵
示例的模板说明文件 template.md 明确指出:这个演示应用包含7 个页面,每个页面从node_modules(vendor 库)导入 1–3 个模块,并从stuff目录(业务/应用模块)导入 0–3 个模块。真实应用通常复杂得多,但拆分机制完全一致。
在该目录下,页面源码位于 pages,业务模块位于 stuff。逐一阅读各页入口(a.js 等),可还原出完整的依赖矩阵:
| 入口 | 依赖的 vendor 模块(node_modules) | 依赖的业务模块(stuff) |
|---|---|---|
pageA | m1、m2、m3 | s2、s3、s4 |
pageB | m1、m2、m4 | s1、s7、s8 |
pageC | m1、m2、m5 | s4、s5、s6 |
pageD | m6、m7、m8 | s1、s2、s3 |
pageE | m6、m7、m8 | s7 |
pageF | m6、m7、m8 | s1、s2、s3 |
pageG | m6 | s1 |
vendor 模块(m1–m8)各约 43 字节,业务模块(如 stuff/s1.js 的内容console.log("some own module"))各约 31 字节——模块被刻意做得极小,是为了配合示例中调小的阈值,让读者肉眼即可验证拆与不拆的结果。
关键配置拆解:三行配置背后的取舍
示例 webpack.config.js 的核心配置如下:
"use strict"; /** @type {import("webpack").Configuration} */ const config = { // mode: "development" || "production", entry: { pageA: "./pages/a", pageB: "./pages/b", pageC: "./pages/c", pageD: "./pages/d", pageE: "./pages/e", pageF: "./pages/f", pageG: "./pages/g" }, optimization: { splitChunks: { chunks: "all", maxInitialRequests: 20, // for HTTP2 maxAsyncRequests: 20, // for HTTP2 minSize: 40 // for example only: chosen to match 2 modules // omit minSize in real use case to use the default of 30kb } } }; module.exports = config;chunks: "all":打开默认关闭的 initial chunk 自动拆分
模板文档强调,chunks: "all"是显式开启对初始 chunk 的自动拆分,该行为默认是关闭的。这一点可以直接在源码中得到印证:在 lib/config/defaults.js 中,splitChunks选项被启用时会补全默认值,其中chunks的默认值就是"async"——也就是说,默认情况下 webpack 只为按需加载(异步)的 chunk 做自动拆分,而入口对应的初始 chunk 不在其列。
chunks可取值包括:
"initial":只拆分初始(同步加载)chunk;"async":只拆分异步按需加载的 chunk(默认值);"all":两种都参与自动拆分,这也是多页面应用中推荐的做法——因为多页应用中的同步共享模块同样值得抽成公共文件交给浏览器缓存。
minSize:拆分的最小体积门槛
示例把minSize调到40 字节,注释专门说明“仅为演示,取 40 是为了匹配 2 个模块的量级”;业务模块每个约 31 字节,因此恰好 2 个业务模块(62 字节)才能越过门槛形成独立 chunk。真实项目中应省略该配置以使用默认值。
需要提醒的是:模板中“真实应用默认 30kb”的说法来自较早版本。以当前仓库 lib/config/defaults.js 的实现为准,默认值会随mode变化:
| 参数 | development | production |
|---|---|---|
minSize | 10000(约 10kB) | 20000(约 20kB) |
enforceSizeThreshold | 30000 | 50000 |
maxAsyncRequests/maxInitialRequests | Infinity | 30 |
其中enforceSizeThreshold表示“超过该体积的模块即使拆分带来的请求数超限也会强制拆出”。因此在实际项目中,请以webpack --mode production输出的默认值为准。
maxInitialRequests/maxAsyncRequests:用请求数上限换取缓存收益
示例将两项请求数上限都设为20,注释注明是为了HTTP/2。其背后的带宽权衡是:HTTP/1.1 下浏览器对同一域名通常只允许 6 个左右的并行连接,拆得太碎会导致排队;而 HTTP/2 支持多路复用,浏览器可以并行加载更多小文件,因此可以把请求数上限放宽,让“每个模块粒度更细、缓存命中率更高”的拆分方案变得可行。
从模板文档的提示可以总结出可操作的方向:
降低
maxInitialRequests/maxAsyncRequests会进一步增加重复(更多模块留在各自的入口 chunk 里),从而减少请求总数;降低上限适合 HTTP/1.1 环境,提高上限适合 HTTP/2 环境。
注意这里的关键判断:重复并不影响首屏加载(首屏仍只下载本页入口 + 必要的公共 chunk),它只影响用户切换到应用其他页面时的下载量——没有公共 chunk 可缓存时,跨页导航就得重新下载那份代码。这正是 MPA 场景下缓存组的价值所在。
产物解读:拆出了什么、为什么这么拆
模板的“Interpreting the result”一节演示了产物命名(早期版本的默认命名风格),完整的最新构建输出已由构建脚本渲染进 README.md。两个文件放在一起看,既能理解语义,又能看到当前版本的输出形态:
- 传统/语义化命名示意(见 template.md):
pageA.js:入口pageA自身的输出文件;vendors~pageD~pageE~pageF~pageG.js:被这些页面共享、且体积超过阈值的 vendor 库,抽成独立文件;vendors~pageA.js:只被单个页面使用、但体积超过阈值的 vendor 模块;pageA~pageD~pageF.js:被这些页面共享、且体积超过阈值的业务模块。
这种缓存组名 + entry 名用分隔符拼接的命名风格中,缓存组前缀vendors来自defaultVendors组的idHint(见 lib/config/defaults.js),automaticNameDelimiter默认值当前为"-"(同文件第 2627 行)。
- 当前仓库 production 下的真实输出(README.md):默认
chunkFilename为[id].js,因此共享 chunk 显示为数字文件名,并用(id hint: vendors)标明其归属缓存组,例如:
chunk (runtime: pageA) 122.js (id hint: vendors) 43 bytes [initial] [rendered] split chunk (cache group: defaultVendors) > ./pages/a pageA ./node_modules/m3.js 43 bytes [built] [code generated]将实际输出与依赖矩阵对照,可得到下表([initial]表示在入口初始加载时并行请求,[entry]为页面自身 chunk,runtime:列出加载该 chunk 的页面):
| 产物 | 归属缓存组 | 包含模块 | 服务页面 | 分析 |
|---|---|---|---|---|
454.js | defaultVendors | m1、m2 | A、B、C | 三页共享的 vendor |
301.js | defaultVendors | m6 | D、E、F、G | 四页共享的 vendor |
778.js | defaultVendors | m7、m8 | D、E、F | 三页共享的 vendor |
122.js | defaultVendors | m3 | A | 单页使用但超过阈值 |
811.js/876.js | defaultVendors | m4/m5 | B / C | 单页使用但超过阈值 |
554.js | default(业务) | s2、s3 | A、D、F | 三页共享的业务模块,62 字节 ≥ 40 门槛 |
pageA.js–pageG.js | — | 各页专属模块 + 运行时 | 各自 | 5 个运行时模块约 3.04 KiB |
这正好复现了模板对默认缓存组的描述:defaultVendors组通过test: NODE_MODULES_REGEXP捕获来自node_modules的模块(并带idHint: "vendors"、priority: -10),而default组(idHint: ""、minChunks: 2、priority: -20)负责业务共享模块(见 lib/config/defaults.js)。两个缓存组的拆分逻辑最终由 lib/optimize/SplitChunksPlugin.js 统一执行。
刻意重复:阈值与请求开销的平衡艺术
模板特意设计了一个“不该拆”的反例:./stuff/s4.js同时被pageA与pageC共享,但它是这两页之间唯一的共享模块——单独抽出时约 31 字节,低于 40 字节的minSize门槛,因此不生成独立文件,而是被重复打进两个页面各自的 chunk 中(对应输出中pageA/pageC的 “dependent modules” 行)。
结论是:为这么点字节增加一次请求并不划算——多一次请求意味着额外的连接/加载开销,而且会降低 gzip 压缩的收益(公共代码分得越碎,跨文件的重复字符串越多,压缩率越差)。
这个反例背后是splitChunks拆分的两个基本判据:
- 共享度:模块被多个 chunk 使用(
default组要求minChunks: 2),是“值得抽”的前提; - 体积门槛:抽出的文件(或其能合并到的现有公共文件)必须达到
minSize,否则重复反而更优。
以本示例的业务模块矩阵验证:s1虽被 B/D/F/G 四页共享,但单个仅 31 字节 < 40,不单独拆;s7被 B/E 共享同样因体积不足而留在页内;唯独s2+s3恰好被 A/D/F 三页同时共享且合计 62 字节 ≥ 40,才形成唯一的业务共享 chunk554.js。
从示例到生产:把这个模式应用到真实 MPA 项目
在多页面生产项目中复刻该模式,只需要保留以下骨架(把本示例的演示参数替换为默认值或按需调优):
/** @type {import("webpack").Configuration} */ module.exports = { entry: { pageA: "./src/pages/a", pageB: "./src/pages/b" // ... 每个页面一个入口 }, optimization: { splitChunks: { chunks: "all", // 关键:让初始 chunk 也参与自动拆分 // 以下省略时使用当前版本的默认值: // minSize: production 20kB / development 10kB // maxInitialRequests / maxAsyncRequests: production 30 / development Infinity // 仅当面向 HTTP/1.1 且入口很多时才需要显式调低请求数上限 } } };实操建议(均可由上述机制推导并验证):
- 服务端以 HTTP/1.1 为主、页面数量多:让
maxInitialRequests/maxAsyncRequests保持较低的默认约束(历史上默认更严格,当前 production 默认 30),接受一定重复; - 全面启用 HTTP/2:可像本示例一样显式放宽请求数上限,换取更细粒度的缓存拆分;
- 想一眼看清产物语义:临时开启 named 形态的 chunk 命名(例如
chunkIds: "named")后重新构建,就能看到vendors~pageA.js、pageA~pageD~pageF.js这类可读文件名,便于对照 template.md 中的命名讲解; - 排查疑问时优先看输出中的
split chunk (cache group: xxx)标记与id hint,它直接标明每个共享 chunk 由哪个缓存组产生。
如何在本仓库重现该示例
模板 template.md 是示例的“源”,其中用_{{webpack.config.js}}_、_{{production:stdout}}_占位符标记插入配置与构建输出;渲染产物就是当前目录下的 README.md(对比二者即可看到占位符被真实配置和 production 模式构建 stdout 替换)。examples 目录下的 buildAll.js 等脚本负责驱动这类示例的生成,完整的构建与运行说明可查阅 examples/README.md。在本仓库本地执行示例构建后,重新审视输出的每个[initial]split chunk 与各页的 dependent modules,即可亲手验证本文中的全部结论:共享度高且足够大的模块被抽走,共享度低或太小则宁可重复——这正是optimization.splitChunks在多页面场景下最核心的工程权衡。
【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考