webpack 多入口应用代码拆分实战:用 splitChunks 同时拆出 commons 公共包与 vendor 第三方包
多页(multi-page)应用或大型单页项目通常存在两类重复代码:多个入口之间共用的工具模块,以及被打进每个 bundle 的第三方库代码。webpack 的 optimization.splitChunks 允许我们把"被至少两个入口共享的模块"抽成一个独立的公共 chunk,同时把 node_modules 中的第三方库强制隔离进单独的 vendor chunk。本文将围绕仓库中 examples/common-chunk-and-vendor-chunk 这一官方示例,完整拆解它的配置、模块依赖关系、产物结构、JSONP 运行时机与两种构建模式的体积差异,帮助你掌握一套可直接迁移到真实多入口项目的拆分方案。
示例场景:三个页面入口与两个"不想重复"的模块集合
本示例建立在一个典型的"多入口 + 各自引用不同公共代码"的工程之上:存在 pageA、pageB、pageC 三个入口,它们各自依赖了不同的工具模块(utility1.js、utility2.js、utility3.js),其中只有一部分是共享的;此外 pageA 与 pageB 还分别 require 了来自 node_modules 的第三方模块 vendor1、vendor2(见 node_modules/vendor1.js 与 node_modules/vendor2.js)。
从源码看,各入口的依赖关系非常清晰:
- pageA.js 依赖
vendor1、utility1、utility2; - pageB.js 依赖
vendor2、utility2、utility3; - pageC.js 依赖
utility2、utility3。
用矩阵表示如下:
| 入口 | utility1 | utility2 | utility3 | vendor1 | vendor2 |
|---|---|---|---|---|---|
| pageA | ✅ | ✅ | — | ✅ | — |
| pageB | — | ✅ | ✅ | — | ✅ |
| pageC | — | ✅ | ✅ | — | — |
如果不做任何拆分,utility2、utility3 会被重复打进所有引用它们的页面 bundle;vendor1/vendor2 这类第三方库也会随页面代码一起进入产物,浏览器在页面之间跳转时无法利用缓存复用这些重复字节。这正是本示例要解决的问题。
最终产物形态:vendor、common、page 三组 bundle
按照示例的配置,webpack 最终会产出以下分组(高层次的 bundle 划分):
- vendor:第三方库
vendor1+vendor2(外加 webpack runtime 的整体编排思路); - common:
utility2+utility3(被至少两个入口共享); - pageA:入口
pageA+ 仅它自己使用的utility1; - pageB:仅入口
pageB(其共享依赖已被全部抽走); - pageC:仅入口
pageC。
这样在页面加载时,推荐的引用顺序是:先第三方库 vendor,再公共应用代码 common,最后加载页面自身代码。缓存收益在于:用户访问 pageA 时下载并缓存了 vendor.js 与 commons 文件,跳转到 pageB、pageC 时这三部分可全部命中缓存,只有各自的 page 文件需要重新下载。
需要特别说明的是:这是示例文档从"产物逻辑构成"角度给出的分组描述。若精确到本次构建生成的源码(见下文产物拆解),webpack 运行时(bootstrap、JSONP 加载器等)实际内嵌在每一个入口文件(page*.js)中,vendor 与 commons 均是以 self["webpackChunk"] push 形式存在的可加载 chunk。真实的脚本加载顺序依然遵循"vendor 先于 common、common 先于 page"。
配置逐项解析:cacheGroups 如何"先拆公共、再锁 vendor"
本示例的完整配置位于 webpack.config.js,如下所示:
"use strict";
const path = require("path");
/** @type {import("webpack").Configuration} */
const config = {
// mode: "development" || "production",
entry: {
pageA: "./pageA",
pageB: "./pageB",
pageC: "./pageC"
},
optimization: {
chunkIds: "named",
splitChunks: {
cacheGroups: {
commons: {
chunks: "initial",
minChunks: 2,
maxInitialRequests: 5, // The default limit is too small to showcase the effect
minSize: 0 // This is example is too small to create commons chunks
},
vendor: {
test: /node_modules/,
chunks: "initial",
name: "vendor",
priority: 10,
enforce: true
}
}
}
},
output: {
path: path.join(__dirname, "dist"),
filename: "[name].js"
}
};
module.exports = config;
它由三部分共同作用:三个多入口、一个 commons 缓存组、一个 vendor 缓存组。逐项说明如下。
1. 多入口与输出命名
entry同时声明pageA/pageB/pageC三个入口,这构成了"多页面/多 bundle"的起点,webpack 会为每个入口生成一个对应的初始 chunk;optimization.chunkIds: "named"让 chunk 使用可读性强的命名 ID,这也是产物中能出现commons-utility2_js这类名字的原因;output.filename: "[name].js"让每个入口按自身名字产出(pageA.js等),split 出来的 chunk 也据此获得可识别的文件。
2. commons 缓存组:把共享模块抽成公共 chunk
commons: {
chunks: "initial",
minChunks: 2,
maxInitialRequests: 5, // The default limit is too small to showcase the effect
minSize: 0 // This is example is too small to create commons chunks
}
chunks: "initial"声明只考虑入口直接加载(initial)的 chunk,而非按需异步(async)加载的 chunk。示例中的页面都是同步初始依赖,因此取initial是正确的;minChunks: 2是抽取条件核心:模块被至少 2 个 chunk 引用时才值得抽出来。对照依赖矩阵,utility2被 3 个页面引用、utility3被 2 个页面引用,均满足条件进入 commons;utility1只被pageA引用,不满足条件,因而留在pageA内部;maxInitialRequests: 5表示一个入口最多允许拆分出的初始请求数上限。示例注释明确写道 "The default limit is too small to showcase the effect",即默认值过紧会妨碍效果展示,这里放宽到 5,使每个入口可以容纳 vendor + 多个 commons + 页面自身的多文件加载;minSize: 0:chunk 拆分的最小字节门槛,默认值较大,而示例的模块都是"一行的字符串导出"级别的极小模块(如module.exports = "utility2"),若不归零,任何 commons chunk 都达不到默认最小体积而无法被创建,因此注释说明 "This is example is too small to create commons chunks"。
3. vendor 缓存组:用正则锁定 node_modules
vendor: {
test: /node_modules/,
chunks: "initial",
name: "vendor",
priority: 10,
enforce: true
}
test: /node_modules/把所有来自node_modules的第三方模块划入该组,这正是"vendor 归集"的判定标准;name: "vendor"固定产出文件名为vendor.js(配合output.filename: "[name].js");priority: 10:当一个模块同时命中多个缓存组时,优先级更高的组获胜。这里的 vendor 组优先级高于 commons,保证第三方库不会先被 commons 的规则截走;enforce: true:跳过默认的体积/请求数等条件限制,强制拆出 vendor chunk。即使第三方代码量很小,也会单独成包。
配置中的 commons 组没有显式写 name,因此它的 chunk 名由内部命名的算法依据所含模块自动生成——这就是产物中出现 commons-utility2_js.js、commons-utility3_js.js 名称的原因(对应模块路径 ./utility2.js 与 ./utility3.js)。
从实现层面看,cacheGroups 的规则与全局 splitChunks 默认值之间存在继承与覆盖关系,这些逻辑集中在 lib/optimize/SplitChunksPlugin.js:当缓存组未显式声明 minChunks、maxInitialRequests 等字段时,会回退到顶层的 splitChunks 配置(可参考该文件中第 853–870 行附近的选项合并逻辑);拆分判定过程中,模块被引用的 chunk 数量若小于组的 minChunks 就会被跳过(对应文件第 1331 行附近的判定)。理解这一点有助于你日后推断"为什么某模块没被抽出来"。
产物拆解:JSONP push 的 vendor/commons 与带运行时的 page 入口
vendor.js 与 commons:纯粹的模块 push 文件
构建出的 vendor.js 对应产物 结构非常直观,本质是一次对全局 JSONP chunk 数组的 push:
(self["webpackChunk"] = self["webpackChunk"] || []).push([["vendor"],{
/***/ 1
/*!*********************************!*\
!*** ./node_modules/vendor1.js ***!
\*********************************/
/*! unknown exports (runtime-defined) */
/*! CommonJS bailout: module.exports is used directly at 1:0-14 */
(module) => {
module.exports = "vendor1";
/***/ },
/***/ 5
/*!*********************************!*\
!*** ./node_modules/vendor2.js ***!
\*********************************/
/*! unknown exports (runtime-defined) */
/*! CommonJS bailout: module.exports is used directly at 1:0-14 */
(module) => {
module.exports = "vendor2";
/***/ }
}]);
push 的第一个元素 ["vendor"] 是 chunk 名列表,第二参数是模块映射(数字模块 ID → 模块函数)。注意每个第三方模块注释中出现了 CommonJS bailout: module.exports is used directly——这是 webpack 对以 CJS 风格直接写 module.exports 模块的静态分析标记,说明它们无法被 ES 模块的静态化优化处理,按运行时定义导出处理。
commons 文件与 vendor 如出一辙,只是模块换成了业务工具:commons-utility2_js 对应产物 push 了模块 3(./utility2.js),commons-utility3_js 对应产物 push 了模块 6(./utility3.js)。
page 入口:自包含运行时 + 延迟启动逻辑
与 vendor/commons 不同,每个 page 文件是完整的入口 chunk,内部既有 webpack bootstrap 运行时,也有页面自身的模块代码。以 pageA 产物为例,其模块区包含模块 0(./pageA.js)与模块 2(./utility1.js,因为 utility1 只被 pageA 使用,直接内联在 pageA 里),以及被引用的 vendor/commons 模块留下的空槽位:
/*! ./pageA.js */
((module, __unused_webpack_exports, __webpack_require__) => {
var vendor1 = __webpack_require__(/*! vendor1 */ 1);
var utility1 = __webpack_require__(/*! ./utility1 */ 2);
var utility2 = __webpack_require__(/*! ./utility2 */ 3);
module.exports = "pageA";
/***/ }),
/* 1 */,
/* 2 */ /* ./utility1.js */
((module) => { module.exports = "utility1"; })
运行时部分由若干 IIFE 组成:模块缓存 __webpack_module_cache__、require 函数 __webpack_require__(负责缓存命中/创建并执行模块)、chunk 已加载状态表 installedChunks,以及最关键的一环——JSONP chunk 加载机制。它以 self["webpackChunk"](Web 环境下即全局 webpackChunk 数组)作为 chunk 加载全局对象,通过 webpackJsonpCallback 把后续 push 进来的模块并入模块表并触发各 chunk 的完成回调:
const chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
正是这种机制,让"先加载 vendor.js/commons 文件"成为可能:页面入口启动时并不会立即执行模块 0,而是先登记依赖、等待前置 chunk 就绪。pageA 产物末尾的启动逻辑把这一等待关系表达得清清楚楚:
// startup
// Load entry module and return exports
// This entry module depends on other loaded chunks and execution need to be delayed
let __webpack_exports__ = __webpack_require__.O(undefined, ["vendor","commons-utility2_js"], () => (__webpack_require__(0)))
__webpack_exports__ = __webpack_require__.O(__webpack_exports__);
__webpack_require__.O(chunk loaded 运行时)会挂起启动回调,直到参数中列出的 vendor、commons-utility2_js 两个 chunk 全部加载完成后,才真正执行入口模块。对照依赖关系可发现这与实际完全吻合:pageA 需要 vendor1(在 vendor 中)与 utility2(在 commons-utility2_js 中)。
pageB、pageC 的启动依赖列表则是很好的交叉验证:
- pageB 需要 vendor(vendor2)、commons-utility2_js(utility2)、commons-utility3_js(utility3),因此启动依赖为
["vendor","commons-utility2_js","commons-utility3_js"]; - pageC 不引用任何 node_modules 模块,所以其启动依赖中没有 vendor,只有
["commons-utility2_js","commons-utility3_js"]。
这从产物代码层面证明了"按需加载、各取所需"的拆分结果,而不是把全部公共代码无差别打包进每个页面。
运行与两种模式的构建统计(Info)
该示例文档由构建测试框架把 webpack 配置、dist 产物与两次构建的 stats 渲染进正文(框架脚本可见 examples/template-common.js 与 examples/buildAll.js,模板源文件即 template.md)。在示例目录下执行 webpack 构建,即可复现本文展示的 dist 产物。
Unoptimized(开发态)构建统计
开发态产物保留了完整可读的运行时与模块代码,chunk 划分信息一目了然:
| 产物 | 体积 | 说明 |
|---|---|---|
| vendor.js | 713 B | 含 vendor1、vendor2 |
| commons-utility2_js.js | 372 B | 含 utility2,被 pageA/pageB/pageC 共享 |
| commons-utility3_js.js | 372 B | 含 utility3,被 pageB/pageC 共享 |
| pageA.js | 6.05 KiB | 入口 + runtime + utility1 |
| pageB.js | 5.77 KiB | 入口 + runtime |
| pageC.js | 5.71 KiB | 入口 + runtime |
对应入口的整体体积(initial 请求合计):
- Entrypoint pageA 7.11 KiB = vendor.js + commons-utility2_js.js + pageA.js
- Entrypoint pageB 7.19 KiB = vendor.js + commons-utility2_js.js + commons-utility3_js.js + pageB.js
- Entrypoint pageC 6.44 KiB = commons-utility2_js.js + commons-utility3_js.js + pageC.js
stats 中还可看到每个 chunk 的归属关系:commons-utility2_js 标注为 (id hint: commons)、split chunk (cache group: commons),其中 utility2 的三处引用被逐一列出(pageA 3:15-36、pageB 2:15-36、pageC 1:15-36,均为 cjs require);vendor chunk 标注为 (cache group: vendor) (name: vendor),且 chunk (runtime: pageA, pageB) 表明它只服务于引用 vendor 的两个入口。
Production 模式构建统计
切到 production 模式(mode: "production")后,所有产物经过压缩,体积显著下降:
| 产物 | 开发态 | 生产态(minimized) |
|---|---|---|
| vendor.js | 713 B | 119 B |
| commons-utility2_js.js | 372 B | 105 B |
| commons-utility3_js.js | 372 B | 105 B |
| pageA.js | 6.05 KiB | 1.04 KiB |
| pageB.js | 5.77 KiB | 1.04 KiB |
| pageC.js | 5.71 KiB | 1.02 KiB |
入口合计随之缩小到 1.2–1.4 KiB 量级。两个模式下模块归属、cache group 标注与各入口的脚本引用顺序完全一致,唯一差异是压缩([minimized] 标记)。这说明拆分策略是模式无关的:开发态牺牲体积换取可读性与调试体验,生产态则在同样的 chunk 结构上做极致的字节压缩。
与其它示例及实现的关联
理解本示例后,你可以沿以下线索继续深入:
- examples/explicit-vendor-chunk:去掉"commons"这层,只演示如何显式拆分一个独立的 vendor chunk,适合先理解最小配置;
- examples/two-explicit-vendor-chunks:演示用两个 cache group 拆出两个 vendor 包(如
vendor与vendor2)的场景; - examples/common-chunk-grandchildren:展示把多入口公共模块抽成独立 chunk 后,如何通过
async的公共模块在页面与异步子块间共享; - 实现层面可深入阅读 lib/optimize/SplitChunksPlugin.js,理解模块到缓存组的匹配、条件判定与最终拆分的完整算法,以及 RuntimeGlobals.js 中与 chunk 加载运行时相关的定义;
- 需注意在 webpack 4 之前的
CommonsChunkPlugin时代,"minChunks: 2 抽公共、name 指定 vendor"是手写插件配置,而 webpack 4/5 中该能力已整体收敛到optimization.splitChunks之上,本示例即现代写法的标准范本。
综上,common-chunk-and-vendor-chunk 用最小的代码规模演示了多入口工程拆分的两条最关键规则:以 minChunks 阈值让共享业务模块自动归入 commons,以 test: /node_modules/ + priority + enforce 让第三方库强制独立成 vendor。在真实的多页面项目中,只需把入口换成你的页面,把 node_modules 的判定换成真实的第三方依赖清单,并把 maxInitialRequests、minSize 按产品形态调优,即可获得同样的缓存友好型产物结构。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00