首页
/ webpack 多入口应用代码拆分实战:用 splitChunks 同时拆出 commons 公共包与 vendor 第三方包

webpack 多入口应用代码拆分实战:用 splitChunks 同时拆出 commons 公共包与 vendor 第三方包

2026-09-07 20:02:46作者:余洋婵Anita

多页(multi-page)应用或大型单页项目通常存在两类重复代码:多个入口之间共用的工具模块,以及被打进每个 bundle 的第三方库代码。webpack 的 optimization.splitChunks 允许我们把"被至少两个入口共享的模块"抽成一个独立的公共 chunk,同时把 node_modules 中的第三方库强制隔离进单独的 vendor chunk。本文将围绕仓库中 examples/common-chunk-and-vendor-chunk 这一官方示例,完整拆解它的配置、模块依赖关系、产物结构、JSONP 运行时机与两种构建模式的体积差异,帮助你掌握一套可直接迁移到真实多入口项目的拆分方案。

示例场景:三个页面入口与两个"不想重复"的模块集合

本示例建立在一个典型的"多入口 + 各自引用不同公共代码"的工程之上:存在 pageApageBpageC 三个入口,它们各自依赖了不同的工具模块(utility1.jsutility2.jsutility3.js),其中只有一部分是共享的;此外 pageApageB 还分别 require 了来自 node_modules 的第三方模块 vendor1vendor2(见 node_modules/vendor1.jsnode_modules/vendor2.js)。

从源码看,各入口的依赖关系非常清晰:

  • pageA.js 依赖 vendor1utility1utility2
  • pageB.js 依赖 vendor2utility2utility3
  • pageC.js 依赖 utility2utility3

用矩阵表示如下:

入口 utility1 utility2 utility3 vendor1 vendor2
pageA
pageB
pageC

如果不做任何拆分,utility2utility3 会被重复打进所有引用它们的页面 bundle;vendor1/vendor2 这类第三方库也会随页面代码一起进入产物,浏览器在页面之间跳转时无法利用缓存复用这些重复字节。这正是本示例要解决的问题。

最终产物形态:vendor、common、page 三组 bundle

按照示例的配置,webpack 最终会产出以下分组(高层次的 bundle 划分):

  • vendor:第三方库 vendor1 + vendor2(外加 webpack runtime 的整体编排思路);
  • commonutility2 + 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.jscommons-utility3_js.js 名称的原因(对应模块路径 ./utility2.js./utility3.js)。

从实现层面看,cacheGroups 的规则与全局 splitChunks 默认值之间存在继承与覆盖关系,这些逻辑集中在 lib/optimize/SplitChunksPlugin.js:当缓存组未显式声明 minChunksmaxInitialRequests 等字段时,会回退到顶层的 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 运行时)会挂起启动回调,直到参数中列出的 vendorcommons-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.jsexamples/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 包(如 vendorvendor2)的场景;
  • 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 的判定换成真实的第三方依赖清单,并把 maxInitialRequestsminSize 按产品形态调优,即可获得同样的缓存友好型产物结构。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388