Webpack 实战精讲:利用 splitChunks cacheGroups 生成两个显式 Vendor Chunk(two-explicit-vendor-chunks)
本文以仓库内 examples/two-explicit-vendor-chunks 示例文档为骨架,讲解如何在 Webpack 中通过 optimization.splitChunks.cacheGroups 的显式命名能力,把多个入口(entry)项目按"业务页 + 独立 vendor"的方式打包出确定性的 vendor1.js、vendor2.js 产物。你将完整掌握该配置中 name、test、enforce 三个核心参数的作用,看懂产物的 Webpack runtime 结构,并学会解读开发/生产两种模式下的构建统计信息。
示例概览:它解决什么问题
在真实的多页面项目里,我们常希望把某些"第三方/公共代码"显式拆成独立文件,例如 vendor1.js、vendor2.js,便于长期缓存与单独部署。本示例演示的正是这种"两个显式 vendor chunk"的确定性拆分思路:不仅页面对 vendor 的依赖被稳定切分,vendor 之间的依赖也能被正确处理。
该示例文档由 template.md 配合构建脚本(见 examples/buildAll.js)自动生成,README 中展示的 dist/*.js 与编译统计是构建该示例后产出的真实结果。示例目录的核心文件如下:
examples/two-explicit-vendor-chunks/
├── webpack.config.js # 唯一的构建配置(entry + splitChunks)
├── vendor1.js # "Vendor1",会被 pageA 与 vendor2 引用
├── vendor2.js # "Vendor2",内部又 require vendor1
├── pageA.js # 同时依赖 vendor1、vendor2
├── pageB.js / pageC.js # 不依赖任何 vendor 的独立页面
├── pageA.html # 演示三个脚本的加载顺序
├── template.md # README 的生成模板
└── README.md # 生成的示例文档(含完整配置与产物)
在 examples/README.md 的索引中,本示例与 aggressive-merging、explicit-vendor-chunk、named-chunks 等一起归类在 Chunk 分组下,用于演示不同形态的 chunk 切分与控制。
从源码看依赖拓扑
先看四个入口业务模块与两个 vendor 模块的实际内容:
module.exports = "Vendor1";
module.exports = "Vendor2";
require("./vendor1");
module.exports = "pageA";
require("./vendor1");
require("./vendor2");
pageB.js 与 pageC.js 都只是 module.exports = "pageB" / "pageC",不依赖任何 vendor。
由此得到清晰的依赖图:
vendor1.js ← 被 vendor2.js、pageA.js 引用(无依赖)
vendor2.js → require("./vendor1")
pageA.js → require("./vendor1") + require("./vendor2")
pageB.js → 无依赖
pageC.js → 无依赖
即:vendor2 自身又依赖 vendor1,这是本文产物分析里最值得注意的链条——最终的 vendor2.js 产物会内联 vendor1 模块,而不会产生跨文件的 require。
配合页面 pageA.html 可以直观看到设计者预期的加载顺序——先 vendor 后页面:
<script src="js/vendor1.js" charset="utf-8"></script>
<script src="js/vendor2.js" charset="utf-8"></script>
<script src="js/pageA.js" charset="utf-8"></script>
配置全解:两个 vendor entry + 两个同名 cacheGroup
示例文档的核心是下面的配置文件,这里完整给出(内容与 webpack.config.js 一致):
"use strict";
const path = require("path");
/** @type {import("webpack").Configuration} */
const config = {
// mode: "development" || "production",
entry: {
vendor1: ["./vendor1"],
vendor2: ["./vendor2"],
pageA: "./pageA",
pageB: "./pageB",
pageC: "./pageC"
},
output: {
path: path.join(__dirname, "dist"),
filename: "[name].js"
},
optimization: {
splitChunks: {
cacheGroups: {
vendor1: {
name: "vendor1",
test: "vendor1",
enforce: true
},
vendor2: {
name: "vendor2",
test: "vendor2",
enforce: true
}
}
}
}
};
module.exports = config;
entry:vendor 也作为入口
entry 对象同时声明了 5 个入口:vendor1、vendor2、pageA、pageB、pageC。注意两种写法的差异:
vendor1: ["./vendor1"]—— 数组形式,明确"该入口由这些模块组成";pageA: "./pageA"—— 字符串形式,等价于单元素数组的简写。
把 vendor 本身声明为入口,意味着每次构建必然产出名为 vendor1.js、vendor2.js 的独立文件(这正是"显式"的体现)。而下面的 cacheGroups 通过同名合并把符合条件的公共模块归并进这两个入口 chunk,从而让它们成为真正的 vendor 包。
cacheGroups:vendor1 / vendor2 三个关键字段
optimization.splitChunks.cacheGroups 用于声明"哪些模块需要被拆分、拆到哪里"。本示例定义了两个缓存组,字段含义如下:
| 字段 | 示例值 | 作用 |
|---|---|---|
name |
"vendor1" / "vendor2" |
拆分后目标 chunk 的名字。因为与入口名相同,被选中的模块会并入对应的入口 chunk,最终产出以 [name] 命名的文件 |
test |
"vendor1" / "vendor2" |
模块筛选条件,决定哪些模块属于该缓存组 |
enforce |
true |
忽略 splitChunks 全局的 minSize / minSizeReduction 等阈值,强制进行拆分 |
test 字符串是"前缀匹配",不是精确匹配
在 lib/optimize/SplitChunksPlugin.js 中,test 的筛选逻辑由 checkTest 实现:
const checkTest = (test, module, context) => {
if (test === undefined) return true;
if (typeof test === "function") {
return test(module, context);
}
if (typeof test === "boolean") return test;
if (typeof test === "string") {
const name = module.nameForCondition();
return name ? name.startsWith(test) : false;
}
if (test instanceof RegExp) {
const name = module.nameForCondition();
return name ? test.test(name) : false;
}
return false;
};
可见 test 支持多种类型:
- undefined:全部模块都命中(默认缓存组行为);
- function:以
(module, context)为参做自定义判定,最灵活; - boolean:直接决定是否命中;
- string:取模块的
nameForCondition()(条件路径),做startsWith前缀匹配。因此test: "vendor1"会命中./vendor1.js,同时也会命中任何以vendor1开头的路径(例如vendor1/sub/x.js),选用时应留意目录结构; - RegExp:对条件路径做正则匹配。
enforce: true:绕过大小的默认门槛
Webpack 的 splitChunks 默认有 minSize、minSizeReduction 等阈值,模块"不够大"时不会进入拆分。本例的模块每个只有 25~77 字节,必然低于默认阈值。在 lib/optimize/SplitChunksPlugin.js 中可以看到缓存组归一化逻辑:
cacheGroupSource.enforce ? undefined : this.options.minSize,
cacheGroupSource.enforce ? undefined : this.options.minSizeReduction,
也就是说 enforce: true 时这两个阈值被显式置空,模块无论多小都会被强制拆分。这正是示例里几个 20~80 字节的小模块仍能各自成包的原因,也是该配置能"无视体积门槛、完全由命名决定产物"的关键。
构建产物逐文件解读
运行构建后输出到 dist/,output.filename: "[name].js" 决定了每个入口一个文件。下面结合文档中展示的产物源码,逐文件看懂 Webpack 的 runtime 结构。
dist/vendor1.js:单模块、最小自包含形态
vendor1.js 的产物是最简单的形态,只有模块 id 0(./vendor1.js):
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ([
/* 0 */
/*!********************!*\
!*** ./vendor1.js ***!
\********************/
/*! unknown exports (runtime-defined) */
/*! runtime requirements: module */
/*! CommonJS bailout: module.exports is used directly at 1:0-14 */
/***/ ((module) => {
module.exports = "Vendor1";
/***/ })
/******/ ]);
__webpack_modules__ 数组的注释块(!*** ./vendor1.js ***!)标识每个模块的来源路径,注释中的 CommonJS bailout: module.exports is used directly 说明该模块直接改写 module.exports,属于 CommonJS 直写导出。紧接其后的 Webpack runtime 负责模块加载,核心是 模块缓存 + require 函数:
/******/ // The module cache
/******/ const __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ const cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ const module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__moduleId;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
__webpack_require__ 先查缓存 __webpack_module_cache__,未命中则创建 exports 对象、执行模块函数并写回缓存——这是 Webpack 打包产物通用的模块加载机制,下面几个文件中的 runtime 完全相同。文件末尾是启动段,加载入口模块并取出导出:
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module is referenced by other modules so it can't be inlined
/******/ let __webpack_exports__ = __webpack_require__(0);
/******/
/******/ })()
;
dist/vendor2.js:证明"vendor 依赖 vendor"能被正确内联
vendor2.js 的产物包含两个模块:模块 0(./vendor1.js)与模块 1(./vendor2.js),其中 vendor2 模块函数体内出现了对模块 0 的调用:
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
module.exports = "Vendor2";
__webpack_require__(/*! ./vendor1 */ 0);
/***/ })
因为 vendor2.js 源码 require("./vendor1"),而 vendor1 被 test: "vendor1" 命中并入同名入口 chunk,最终 vendor2 产物里的依赖解析退化为同文件内的模块 id 调用 __webpack_require__(0),没有跨文件依赖。文件启动段因此加载模块 1:
/******/ let __webpack_exports__ = __webpack_require__(1);
dist/pageA.js:页面 + 两个 vendor 全部内联
pageA.js 的产物共有 3 个模块:模块 0(vendor1)、模块 1(vendor2)、模块 2(pageA)。pageA 的模块函数体内完整保留了业务调用:
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
module.exports = "pageA";
__webpack_require__(/*! ./vendor1 */ 0);
__webpack_require__(/*! ./vendor2 */ 1);
/***/ })
启动段加载模块 2:
/******/ let __webpack_exports__ = __webpack_require__(2);
这里能直接观察到本示例方案的重要形态特征(基于产物源码可确认的事实):
- 每个输出文件都是自包含的:
vendor1.js内含 1 个模块,vendor2.js内含 2 个模块,pageA.js内含 3 个模块,且各自内嵌完整 runtime; - vendor1 模块代码出现了三次(vendor1.js、vendor2.js、pageA.js 中各有副本)。也就是说"页面引用 vendor 文件"与"vendor 模块被并回同名入口 chunk"这两件事同时成立——
pageA.js不依赖先加载 vendor 脚本也能独立运行。
换言之,本示例的目标不是"页面从 vendor 文件按需取模块"(那是异步 import() 拆分 + 共享 chunk 的职责),而是以独立入口文件的形式稳定产出 vendor1、vendor2 两个显式包。若希望进一步避免重复并共享 vendor 代码,可以从 runtime 共享(如 optimization.runtimeChunk)等方向继续扩展,读者可对照仓库中 common-chunk-and-vendor-chunk 等示例体会不同拆分策略的取舍。
Info:开发(Unoptimized)与生产(Production)统计对照
文档末尾的 Info 部分给出了同一配置在两种 mode 下的完整编译统计,这也是验证配置效果最直接的手段。以下完整保留原文档输出。
Unoptimized(未压缩模式)
asset pageA.js 2.43 KiB [emitted] (name: pageA)
asset vendor2.js 2.01 KiB [emitted] (name: vendor2)
asset vendor1.js 1.62 KiB [emitted] (name: vendor1)
asset pageB.js 1.6 KiB [emitted] (name: pageB)
asset pageC.js 1.6 KiB [emitted] (name: pageC)
chunk (runtime: pageA) pageA.js (pageA) 147 bytes [entry] [rendered]
> ./pageA pageA
dependent modules 77 bytes [dependent] 2 modules
./pageA.js 70 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageA.js 1:0-14
entry ./pageA pageA
chunk (runtime: pageB) pageB.js (pageB) 25 bytes [entry] [rendered]
> ./pageB pageB
./pageB.js 25 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageB.js 1:0-14
entry ./pageB pageB
chunk (runtime: pageC) pageC.js (pageC) 25 bytes [entry] [rendered]
> ./pageC pageC
./pageC.js 25 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageC.js 1:0-14
entry ./pageC pageC
chunk (runtime: vendor1) vendor1.js (vendor1) 27 bytes [entry] [rendered]
> ./vendor1 vendor1
./vendor1.js 27 bytes [built] [code generated]
[used exports unknown]
cjs require ./vendor1 ./pageA.js 2:0-20
cjs self exports reference ./vendor1.js 1:0-14
cjs require ./vendor1 ./vendor2.js 2:0-20
entry ./vendor1 vendor1
chunk (runtime: vendor2) vendor2.js (vendor2) 77 bytes [entry] [rendered]
> ./vendor2 vendor2
dependent modules 27 bytes [dependent] 1 module
./vendor2.js 50 bytes [built] [code generated]
[used exports unknown]
cjs require ./vendor2 ./pageA.js 3:0-20
cjs self exports reference ./vendor2.js 1:0-14
entry ./vendor2 vendor2
webpack X.X.X compiled successfully
Production mode(压缩模式)
asset pageA.js 266 bytes [emitted] [minimized] (name: pageA)
asset vendor2.js 224 bytes [emitted] [minimized] (name: vendor2)
asset vendor1.js 185 bytes [emitted] [minimized] (name: vendor1)
asset pageB.js 183 bytes [emitted] [minimized] (name: pageB)
asset pageC.js 183 bytes [emitted] [minimized] (name: pageC)
chunk (runtime: vendor2) vendor2.js (vendor2) 77 bytes [entry] [rendered]
> ./vendor2 vendor2
dependent modules 27 bytes [dependent] 1 module
./vendor2.js 50 bytes [built] [code generated]
[used exports unknown]
cjs require ./vendor2 ./pageA.js 3:0-20
cjs self exports reference ./vendor2.js 1:0-14
entry ./vendor2 vendor2
chunk (runtime: pageB) pageB.js (pageB) 25 bytes [entry] [rendered]
> ./pageB pageB
./pageB.js 25 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageB.js 1:0-14
entry ./pageB pageB
chunk (runtime: pageA) pageA.js (pageA) 147 bytes [entry] [rendered]
> ./pageA pageA
dependent modules 77 bytes [dependent] 2 modules
./pageA.js 70 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageA.js 1:0-14
entry ./pageA pageA
chunk (runtime: vendor1) vendor1.js (vendor1) 27 bytes [entry] [rendered]
> ./vendor1 vendor1
./vendor1.js 27 bytes [built] [code generated]
[used exports unknown]
cjs require ./vendor1 ./pageA.js 2:0-20
cjs self exports reference ./vendor1.js 1:0-14
cjs require ./vendor1 ./vendor2.js 2:0-20
entry ./vendor1 vendor1
chunk (runtime: pageC) pageC.js (pageC) 25 bytes [entry] [rendered]
> ./pageC pageC
./pageC.js 25 bytes [built] [code generated]
[used exports unknown]
cjs self exports reference ./pageC.js 1:0-14
entry ./pageC pageC
webpack X.X.X compiled successfully
统计信息里值得关注的字段
结合上面的输出,可以从编译统计中验证代码层面的结论:
asset ... [emitted] (name: X):每个入口各产出一个文件,共 5 个 asset;开发模式下pageA.js为 2.43 KiB,生产压缩后仅 266 bytes(约 11%),pageB.js/pageC.js因只有单个 25 字节模块,压缩后稳定在 183 bytes;chunk (runtime: pageA) pageA.js (pageA) 147 bytes [entry] [rendered]:声明 chunk 名称、所属 runtime、chunk 原始字节数以及"作为入口、已完成渲染"状态;dependent modules 77 bytes [dependent] 2 modules:这是 vendor 模块被打包进pageAchunk 的直接证据——pageA.js产物体积主要由这两个"被依赖模块"贡献,与前面产物分析中"pageA 内联了 vendor1、vendor2"的结论完全吻合;> ./vendor1 vendor1:表示该 chunk 由入口./vendor1生成;cjs require ./vendor1 ./pageA.js 2:0-20:记录"引用来源"——pageA.js第 2 行第 0-20 列通过 CommonJSrequire引用了./vendor1。这与源码 pageA.js 第 2 行require("./vendor1")一一对应,是验证依赖解析的绝佳线索;cjs self exports reference ./vendor1.js 1:0-14:即前文模块注释中的 "CommonJS bailout",说明该模块直接在module.exports上赋值;- 输出中的
webpack X.X.X是构建模板里的版本占位符,实际运行时会被替换为仓库当前 webpack 版本。
注意两种模式下 chunk 的字节数与模块归属完全一致,仅压缩与否不同——说明"拆分决策"不依赖 mode,完全由入口与 cacheGroups 决定,这正是"显式拆分"的确定性优势。
在本地复现该示例
仓库内文档为构建后快照,若要本地重现,需要先基于本仓库源码构建可用的 webpack。参考 examples/README.md 末尾的 "Building an Example" 一节,在仓库根目录依次执行:
yarn
yarn setup
yarn add --dev webpack-cli
随后进入示例目录并运行该目录的构建脚本:
cd examples/two-explicit-vendor-chunks && node build.js
若需一次性构建全部示例,可在根目录执行 npm run build:examples。构建后即可在 examples/two-explicit-vendor-chunks/dist/ 下看到 vendor1.js、vendor2.js、pageA.js 等产物,并可自行切换 mode: "development" 与 mode: "production" 观察两种模式的体积差异。
相关示例对比:DllPlugin 路线与 splitChunks 路线
在仓库的 Chunk 分类下,explicit-vendor-chunk 与本示例主题最接近,但它走的是另一条技术路线:用 两个独立编译(vendor 编译 + app 编译)+ DllPlugin/DllReferencePlugin + manifest.json 来预先产出 vendor 库,app 编译只引用 manifest 而不重复打包 vendor 模块。
而本示例 two-explicit-vendor-chunks 展示了更贴近现代 Webpack 的单次编译方案:不需要分离的 vendor 编译与 manifest 文件,仅靠 entry 声明 + optimization.splitChunks.cacheGroups 的同名归并即可产出两个显式 vendor 文件。二者对照可以看出:
- DllPlugin 路线的核心资产是"预先编译 + manifest 引用",适合构建隔离与复用预编译包;
- cacheGroups 路线的核心资产是"在完整依赖图内确定性切分",无需跨编译协调,配置直观、产物可预测,本示例即是其最小可运行的教学形态。
如需进一步了解依赖图与切分背后更底层的 chunk 构建机制,可继续研读仓库核心源码 lib/optimize/SplitChunksPlugin.js 与 lib/buildChunkGraph.js。本示例则从配置到产物、再到统计输出,为你提供了一条从"想拆 vendor"到"看懂拆出来的文件"的完整闭环。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00