webpack AggressiveMergingPlugin 实战:自动合并碎片 Chunk 的源码级解析
本文基于 webpack 官方仓库中的 aggressive-merging 示例,讲解 AggressiveMergingPlugin 的完整用法与底层原理。读完本文,你将掌握如何配置该插件自动合并冗余的小型 chunk、理解其 minSizeReduce 阈值算法,并能从源码层面解释 webpack 是如何在 optimizeChunks 阶段逐对挑选并合并 chunk 的。
问题背景:碎片化 Chunk 与重复内容
当一个应用被拆分成多个入口(多页面)且存在大量动态加载(AMD 风格 require([...]) 或 ESM 动态 import)时,webpack 会为每组异步依赖生成独立的 chunk。此时常见问题是:
- 多个入口的异步 chunk 中携带了重复的模块代码;
- 某些 chunk 体积极小,却各自触发一次独立请求。
示例工程模拟了这样的场景:三个入口 pageA、pageB、pageC,其中 pageA 和 pageB 都会异步加载同一大文件 common.js,pageC 则异步加载 a.js 和 b.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.js、pageB.js、pageC.js。common.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.js、pageB.bundle.js、pageC.bundle.js |
output.chunkFilename |
异步 chunk 使用 [id].chunk.js 命名,构建结果中的 531.chunk.js、78.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.js、78.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;
aSize、bSize分别为 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;
三个动作:
integrateChunks(bestB, bestA)把 bestA 的模块并入 bestB;- 从
compilation.chunks中删除已被吸收的 bestA; return true返回给钩子调用方,触发再次优化。
之所以返回 true 就能“再跑一轮”,是因为 Compilation.js 中 optimizeChunks 是 SyncBailHook,被包在一个 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.js、b.js;78.chunk.js(runtime: pageC)包含a.js与b.js。
由于 531.chunk.js 与 78.chunk.js 共享 a.js、b.js,且两者的加载请求可以共存于同一组入口的异步加载中,它们的压缩比超过 1.5,会被插件合并;合并后 common.js 只保留在被真正请求它的 chunk 中,重复模块被去重,这正是“aggressive merging”名称的由来。
使用建议与适用边界
结合源码行为,可以给出以下使用建议:
- 只对异步 chunk 生效:
canBeInitial()检查保证了入口 bundle 的初始加载体积不会被该插件进一步改动,不会破坏首屏加载的确定性和并行度。 - 阈值即策略:
minSizeReduce是唯一选项。默认1.5偏激进;对请求数敏感的移动端场景可以调高(如2、3),只在重复内容极多时才合并。 - 配合确定性 chunk id:示例中显式设置
optimization.chunkIds: "deterministic",保证不同 mode 构建时文件名稳定,方便 CDN 缓存与预发布验证。 - 与 manualChunks 的关系:该插件运行在 chunk 图构建之后的优化阶段,是兜底手段。能提前通过
optimization.splitChunks规划好共享 chunk 的结构,是更可控的做法;AggressiveMergingPlugin适合处理 splitChunks 无法预见的碎片重复。 - 多入口 + 多请求场景收益最明显:单入口应用中不存在“多个 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,可作为在自己项目中启用该插件前的最小验证工程。
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 StartedRust0623
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