首页
/ webpack AggressiveMergingPlugin 实战:自动合并碎片 Chunk 的源码级解析

webpack AggressiveMergingPlugin 实战:自动合并碎片 Chunk 的源码级解析

2026-09-06 10:12:26作者:董斯意

本文基于 webpack 官方仓库中的 aggressive-merging 示例,讲解 AggressiveMergingPlugin 的完整用法与底层原理。读完本文,你将掌握如何配置该插件自动合并冗余的小型 chunk、理解其 minSizeReduce 阈值算法,并能从源码层面解释 webpack 是如何在 optimizeChunks 阶段逐对挑选并合并 chunk 的。

问题背景:碎片化 Chunk 与重复内容

当一个应用被拆分成多个入口(多页面)且存在大量动态加载(AMD 风格 require([...]) 或 ESM 动态 import)时,webpack 会为每组异步依赖生成独立的 chunk。此时常见问题是:

  • 多个入口的异步 chunk 中携带了重复的模块代码;
  • 某些 chunk 体积极小,却各自触发一次独立请求。

示例工程模拟了这样的场景:三个入口 pageApageBpageC,其中 pageApageB 都会异步加载同一大文件 common.jspageC 则异步加载 a.jsb.js

// pageA.js
require(["./common"], function(common) {
	common(require("./a"));
});

// pageB.js
require(["./common"], function(common) {
	common(require("./b"));
});

// pageC.js
require(["./a"], function(a) {
	console.log(a + require("./b"));
});

对应文件见 pageA.jspageB.jspageC.jscommon.js 故意放入了一个大数组占位内容(justToBeABigFile),确保它在构建统计中体积显著。

完整配置:webpack.config.js 逐行解析

示例的构建配置完整定义在 webpack.config.js

"use strict";

const path = require("path");
const { AggressiveMergingPlugin } = require("../..").optimize;

/** @type {import("webpack").Configuration} */
const config = {
	// mode: "development" || "production",
	entry: {
		pageA: "./pageA",
		pageB: "./pageB",
		pageC: "./pageC"
	},
	output: {
		path: path.join(__dirname, "dist"),
		filename: "[name].bundle.js",
		chunkFilename: "[id].chunk.js"
	},
	plugins: [
		new AggressiveMergingPlugin({
			minSizeReduce: 1.5
		})
	],
	optimization: {
		chunkIds: "deterministic" // To keep filename consistent between different modes (for example building only)
	}
};

module.exports = config;

各配置项的作用:

配置项 说明
entry 定义三个页面入口,分别产出 pageA.bundle.jspageB.bundle.jspageC.bundle.js
output.chunkFilename 异步 chunk 使用 [id].chunk.js 命名,构建结果中的 531.chunk.js78.chunk.js 即来源于此
new AggressiveMergingPlugin({ minSizeReduce: 1.5 }) 启用激进合并插件,1.5 表示合并后必须至少带来 50% 的体积压缩比才执行合并
optimization.chunkIds: "deterministic" 使用确定性 chunk id,保证不同构建模式(如仅 production 构建)之间 chunk 文件名保持一致

关于 minSizeReduce 的取值逻辑,可以直接从插件源码确认,见 AggressiveMergingPlugin.js

apply(compiler) {
	const options = this.options;
	const minSizeReduce = options.minSizeReduce || 1.5;

也就是说:

  • minSizeReduce 缺省值就是 1.5(不传任何参数时);
  • 其语义是一个体积比率阈值合并前两 chunk 体积之和 / 合并后体积 必须大于该值才合并。1.5 意味着合并后体积至少缩减到原来的 2/3 以下;
  • 若想让合并更保守(只合并重复度极高的 chunk),可以调高该值,例如 2(合并后体积需小于原体积之和的一半)。

插件通过 webpack 主入口的 optimize 命名空间导出,可在 lib/index.js 中确认:

get AggressiveMergingPlugin() {
	return require("./optimize/AggressiveMergingPlugin");
}

底层原理:optimizeChunks 循环中的贪心合并

AggressiveMergingPlugin 的完整实现见 lib/optimize/AggressiveMergingPlugin.js。它挂载在 compilation.hooks.optimizeChunks 钩子上,并在 STAGE_ADVANCED 阶段执行:

compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
	compilation.hooks.optimizeChunks.tap(
		{
			name: PLUGIN_NAME,
			stage: STAGE_ADVANCED
		},
		(chunks) => { /* 合并逻辑 */ }
	);
});

STAGE_ADVANCED 的数值定义在 OptimizationStages.js 中(10)。webpack 按阶段数值从小到大依次调用同一钩子上的多个 tap,把高级优化放在基础优化之后执行,避免基础阶段产生的 chunk 结构变动使本插件的决策失效。

关键约束:只合并异步 chunk

for (const a of chunks) {
	if (a.canBeInitial()) continue;
	for (const b of chunks) {
		if (b.canBeInitial()) continue;
		...

canBeInitial()true 的 chunk 是初始 chunk(如入口 bundle)。示例中 pageA.bundle.js 等三个入口文件永远不会被改动,插件只处理 531.chunk.js78.chunk.js 这类异步 chunk。

可合并性判断:canChunksBeIntegrated

并非任意两个 chunk 都能合并。插件调用 chunkGraph.canChunksBeIntegrated(a, b) 校验:两个 chunk 必须可以被同一次加载请求覆盖(即它们出现在可兼容的 chunk group 上),合并后异步加载的时序才不会出现“某个 chunk 被提前加载却包含尚未被请求的模块”之类的语义破坏。

体积收益计算:improvement 公式

通过可合并性校验后,插件用 chunkGraph 计算三组体积(均不计 chunk 自身运行时开销 chunkOverhead: 0):

const aSize = chunkGraph.getChunkSize(b, { chunkOverhead: 0 });
const bSize = chunkGraph.getChunkSize(a, { chunkOverhead: 0 });
const abSize = chunkGraph.getIntegratedChunksSize(b, a, { chunkOverhead: 0 });
const improvement = (aSize + bSize) / abSize;
  • aSizebSize 分别为 a、b 单独存在的体积;
  • abSize 是两者合并去重后的实际体积(共享模块只计一次);
  • improvement 即压缩比:两 chunk 各自独立加载共传 aSize + bSize,合并后只传 abSize

插件采用贪心策略,一轮只选收益最大的一对合并:

if (improvement > bestImprovement) {
	bestImprovement = improvement;
	bestA = a;
	bestB = b;
}

源码注释说明这是一个有意的优化:与其构建完整的 O(chunks²) 配对列表再排序,不如直接遍历记录最大值,且用严格的 > 保持“先遇到者胜”的稳定顺序,与旧版排序实现的 [0] 行为一致。

执行合并并驱动重跑循环

if (bestA === undefined) return;
if (bestImprovement < minSizeReduce) return;

chunkGraph.integrateChunks(bestB, bestA);
compilation.chunks.delete(bestA);
return true;

三个动作:

  1. integrateChunks(bestB, bestA) 把 bestA 的模块并入 bestB;
  2. compilation.chunks 中删除已被吸收的 bestA;
  3. return true 返回给钩子调用方,触发再次优化。

之所以返回 true 就能“再跑一轮”,是因为 Compilation.jsoptimizeChunksSyncBailHook,被包在一个 while 循环里:

while (this.hooks.optimizeChunks.call(this.chunks, this.chunkGroups)) {
	/* empty */
}

只要任何 tap 返回 true,webpack 就会重新调用该钩子,直到所有插件都返回 falsy。因此 AggressiveMergingPlugin 每一轮合并一对,下一轮再基于新的 chunk 集合重新评估——直到不存在任何 improvement >= minSizeReduce 的配对为止。minSizeReduce < 1 时理论上永远不合并(压缩比不可能小于 1),这是隐式的安全边界。

构建结果对照

README 记录了该示例开启/未开启优化时的构建统计(摘自 examples/aggressive-merging/README.md)。

Unoptimized(development 统计):

asset pageA.bundle.js 8.55 KiB [emitted] (name: pageA)
asset pageB.bundle.js 8.55 KiB [emitted] (name: pageB)
asset pageC.bundle.js 8.54 KiB [emitted] (name: pageC)
asset 531.chunk.js 6.28 KiB [emitted]
asset 78.chunk.js 581 bytes [emitted]

Production mode(压缩后):

asset pageC.bundle.js 1.76 KiB [emitted] [minimized] (name: pageC)
asset pageA.bundle.js 1.75 KiB [emitted] [minimized] (name: pageA)
asset pageB.bundle.js 1.75 KiB [emitted] [minimized] (name: pageB)
asset 531.chunk.js 151 bytes [emitted] [minimized]
asset 78.chunk.js 101 bytes [emitted] [minimized]

从 chunk 归属关系可以看出合并前的依赖结构(统计输出中的 origin 行):

  • 531.chunk.js 同时被 ./common ./pageA.js./common ./pageB.js 引用,其中包含 5.41 KiB 的 common.js 以及 a.jsb.js
  • 78.chunk.js(runtime: pageC)包含 a.jsb.js

由于 531.chunk.js78.chunk.js 共享 a.jsb.js,且两者的加载请求可以共存于同一组入口的异步加载中,它们的压缩比超过 1.5,会被插件合并;合并后 common.js 只保留在被真正请求它的 chunk 中,重复模块被去重,这正是“aggressive merging”名称的由来。

使用建议与适用边界

结合源码行为,可以给出以下使用建议:

  1. 只对异步 chunk 生效canBeInitial() 检查保证了入口 bundle 的初始加载体积不会被该插件进一步改动,不会破坏首屏加载的确定性和并行度。
  2. 阈值即策略minSizeReduce 是唯一选项。默认 1.5 偏激进;对请求数敏感的移动端场景可以调高(如 23),只在重复内容极多时才合并。
  3. 配合确定性 chunk id:示例中显式设置 optimization.chunkIds: "deterministic",保证不同 mode 构建时文件名稳定,方便 CDN 缓存与预发布验证。
  4. 与 manualChunks 的关系:该插件运行在 chunk 图构建之后的优化阶段,是兜底手段。能提前通过 optimization.splitChunks 规划好共享 chunk 的结构,是更可控的做法;AggressiveMergingPlugin 适合处理 splitChunks 无法预见的碎片重复。
  5. 多入口 + 多请求场景收益最明显:单入口应用中不存在“多个 chunk 可被同一请求合并”的空间,插件基本无操作。

小结

AggressiveMergingPlugin 以极小的 API 面(仅 minSizeReduce 一个选项)实现了“逐对评估、贪心合并、循环收敛”的 chunk 去重策略:

  • 挂载点:compilation.hooks.optimizeChunks,阶段 STAGE_ADVANCED(见 lib/OptimizationStages.js);
  • 合并资格:chunkGraph.canChunksBeIntegrated 且双方均非初始 chunk;
  • 决策指标:(aSize + bSize) / abSize >= minSizeReduce
  • 收敛机制:每轮合并一对后返回 true,由 Compilation.js 中的 while 循环驱动重跑,直至无收益合并为止。

完整可运行示例位于 examples/aggressive-merging,可作为在自己项目中启用该插件前的最小验证工程。

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