一个让构建直接挂掉的上线事故
2024年3月,周五下午16:47,我们团队的CI构建在部署前最后一步挂了。日志里只有一行:
ERROR in main.js from TerserPlugin
TypeError: Cannot read properties of undefined (reading 'minify')
排查了40分钟,最后定位到原因:某个同事在 webpack.config.js 里写了一个自定义插件,在 processAssets 阶段把 compilation.assets 里的一个JS文件内容替换成了空字符串。TerserPlugin在压缩时找不到代码内容,直接抛异常。
这个插件长这样:
// 一个把自己同事坑了的插件
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
compilation.hooks.processAssets.tap({
name: 'SomePlugin',
stage: 1400 // 传了一个完全不理解的stage
}, (assets) => {
// 这里的逻辑是想删掉某个bundle,但是写错了判断条件
assets['main.js'].source = () => '';
});
});
}
}
问题的本质不是这个插件逻辑写错了,而是写这个插件的人根本不知道:Webpack的插件系统是怎么运作的。他不知道在哪个阶段改assets是安全的,不知道stage参数会影响执行顺序,更不知道钩子之间还有依赖关系。
这篇文章就是来解决这件事的。我会用源码把Webpack5的插件机制拆开,让你看完之后能写出不会坑同事的插件。
两条路:调试源码 vs 黑盒测试
我评估过两种方式去搞清楚Webpack5的插件机制。
方案A:直接读源码
先决条件:把 webpack@5.90.0 的源码拉下来,从 lib/index.js 入口开始一行行读。
优点:信息最准确,没有二手的理解偏差。
缺点:Webpack5源码超过10万行,光 lib/compiler.js 就有1200多行,lib/compilation.js 更是有5000多行。你大概率在读到 Compilation.js 第1000行的时候就放弃了。
时间成本:从我的实际经验看,全职投入需要2-3周才能把主线逻辑读通,期间还要同时打开Tapable、webpack-sources、enhanced-resolve等依赖包的源码交叉对比。
这个方案的问题不是「不正确」,而是「对绝大多数工程师不可执行」。你需要一个更快的切入点。
方案B:Tapable + 关键钩子 + 手写插件验证
核心思路:Webpack5的插件机制本质上只有三件事——Tapable事件系统、Compiler生命周期、Compilation模块处理流程。把这三件事搞明白,就能理解整个插件机制。
具体做法:
- 先读Tapable源码(只有500行,一晚上能读完)
- 再通过写一个真实插件,在关键钩子上打日志,观察构建流程的完整节奏
- 最后对照源码验证自己的理解
时间成本:我的实测数据是4小时22分钟。是的,我给你一个确切的时间。
结论:方案B更适合大多数工程师。
先说结论:Webpack5的插件机制到底是什么
一句话:Webpack5用Tapable定义了一系列生命周期钩子,Compiler和Compilation在不同阶段触发这些钩子,插件通过tap方法注册回调,在合适的时机介入构建过程。
展开讲有三层:
| 层级 | 角色 | 职责 | 对应源码 |
|---|---|---|---|
| 事件系统 | Tapable | 定义钩子类型、管理注册/触发逻辑 | tapable/lib/index.js |
| 顶层调度 | Compiler | 一个构建只存在一个Compiler,负责启动构建流程、触发全局钩子、调度Compilation的创建 | webpack/lib/Compiler.js |
| 模块处理 | Compilation | 每次构建创建一个Compilation,负责模块解析、依赖收集、chunk生成、资源输出 | webpack/lib/Compilation.js |
apply(compiler) 是所有插件的入口,Webpack启动后做的第一件事就是遍历所有插件执行apply方法。之后的事情,就是插件挂在各种钩子上等事件触发。
Tapable:插件系统的底层机制
Tapable是Webpack作者sokra单独发的npm包,版本号 2.2.1。它的核心价值是提供多种类型的钩子,让事件处理有明确的控制流。
源码在 node_modules/tapable/lib/ 目录下,核心文件是 Hook.js、HookCodeFactory.js 和 SyncHook.js、AsyncSeriesHook.js 等。
钩子的9种类型
Tapable定义了9种钩子,分成三大类:
| 类型 | 钩子类名 | 特点 |
|---|---|---|
| 同步 | SyncHook | 串行执行所有tap回调,不关心返回值 |
| 同步 | SyncBailHook | 串行执行,任一回调返回非undefined则中断后续 |
| 同步 | SyncWaterfallHook | 串行执行,上一个回调的返回值作为下一个的入参 |
| 同步 | SyncLoopHook | 循环执行,直到所有回调都返回undefined |
| 异步 | AsyncParallelHook | 并行执行所有异步回调,全部完成后触发finally |
| 异步 | AsyncParallelBailHook | 并行执行,任一回调出错则整体失败 |
| 异步 | AsyncSeriesHook | 串行执行异步回调(可await),一个完成后再执行下一个 |
| 异步 | AsyncSeriesBailHook | 串行执行,任一回调返回非undefined则跳过后面的 |
| 异步 | AsyncSeriesWaterfallHook | 串行执行,上一个回调的返回值传递给下一个 |
Webpack5的Compiler和Compilation中,全局钩子基本是 SyncHook 和 AsyncSeriesHook,模块级钩子用的是 AsyncSeriesHook 和 AsyncParallelHook。
SyncHook源码拆解
先看最简单的 SyncHook,代码总共60多行:
// node_modules/tapable/lib/SyncHook.js
const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {
content({ onError, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
rethrowIfPossible,
onDone
});
}
}
const factory = new SyncHookCodeFactory();
class SyncHook extends Hook {
compile(options) {
factory.setup(this, options);
return factory.create(options);
}
}
module.exports = SyncHook;
核心不是 SyncHook.js,而是它的父类 Hook.js。这个文件里做了三件关键的事:
// node_modules/tapable/lib/Hook.js(核心代码摘录)
class Hook {
constructor(args = []) {
this._args = args; // 钩子的参数名列表
this.taps = []; // 注册的回调集合
this._x = undefined; // 实际回调函数列表(编译后使用)
}
tap(options, fn) {
this._tap("sync", options, fn);
}
_tap(type, options, fn) {
// 标准化options,补全name、stage、before等字段
options = Object.assign({ type, fn }, options);
options = this._runRegisterInterceptors(options);
this._insert(options); // 按stage和before排序插入
}
_insert(item) {
let i = this.taps.length;
// stage数值小的排前面
if (item.before) {
// 处理before逻辑
} else if (item.stage) {
// 处理stage排序逻辑
while (i > 0) {
const before = this.taps[i - 1];
if (before.stage === undefined || before.stage <= item.stage) break;
i--;
}
}
this.taps.splice(i, 0, item);
}
// 这是核心中的核心:动态生成执行函数
compile(options) {
throw new Error("Abstract: should be overridden");
}
// 创建一个新的执行函数
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type
});
}
call(...args) {
// 第一次call时动态编译生成执行函数,后续直接复用
if (this._call === undefined) {
this._call = this._createCall("sync");
}
return this._call(...args);
}
}
关键在 call() 方法:第一次调用时,compile() 会根据当前已注册的taps动态生成一个函数(new Function),后续调用直接复用这个编译结果。这保证了性能——Webpack构建要触发上百次钩子调用,如果每次遍历数组找回调,性能会有损耗。
如果你想看编译出来的函数长什么样,可以在 call() 里打日志:
// 自己写个demo看编译结果
const { SyncHook } = require("tapable");
const hook = new SyncHook(["name", "age"]);
hook.tap({ name: "logName" }, (name, age) => {
console.log(`name: ${name}, age: ${age}`);
});
hook.tap({ name: "logAge" }, (name, age) => {
console.log(`age only: ${age}`);
});
// 关键:直接调用hook.compile看生成的函数
const fn = hook.compile();
console.log("编译结果:\n", fn.toString());
// 执行
hook.call("张三", 28);
执行上面的代码,你会看到控制台打印出如下编译结果:
function anonymous(name, age) {
"use strict";
var _context;
var _x = this._x;
var _taps = this.taps;
var _interceptors = this.interceptors;
// 按顺序执行所有tap回调
var _fn0 = _x[0];
_fn0(name, age);
var _fn1 = _x[1];
_fn1(name, age);
}
这就是Webpack5插件机制的底层——动态编译函数,按注册顺序依次调用。没有黑魔法,就是new Function。
Compiler:构建流程的总指挥
Compiler是Webpack构建的顶层调度器,webpack/lib/Compiler.js,总共1200多行。一个webpack(config) 调用会产生一个Compiler实例,贯穿整个构建生命周期。
它的 run() 方法是构建的起点——这也是插件机制最核心的入口。
// webpack/lib/Compiler.js(源码摘录)
class Compiler {
constructor(context, options) {
this.hooks = Object.freeze({
// 构建启动前
initialize: new SyncHook([]),
shouldEmit: new SyncBailHook(["compilation"]),
// 构建开始
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
// 编译阶段
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),
make: new AsyncParallelHook(["compilation"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
// 构建完成
finishMake: new AsyncSeriesHook(["compilation"]),
afterDone: new SyncHook(["stats"]),
// emit阶段
emit: new AsyncSeriesHook(["compilation"]),
assetEmitted: new AsyncSeriesHook(["file", "info"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
done: new AsyncSeriesHook(["stats"]),
failed: new SyncHook(["error"]),
});
}
run(callback) {
const finalCallback = (err, stats) => {
// 最终回调
};
const onCompiled = (err, compilation) => {
// 编译完成后的回调
};
this.hooks.beforeRun.callAsync(this, err => {
this.hooks.run.callAsync(this, err => {
this.compile(onCompiled);
});
});
}
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
compilation.finish(err => {
compilation.seal(err => {
this.hooks.afterCompile.callAsync(compilation, err => {
callback(null, compilation);
});
});
});
});
});
}
}
run() 的调用链非常明确:
beforeRun → run → beforeCompile → compile → make → finishMake → afterCompile → emit → afterEmit → done
这里面最重要的两个钩子:
make(AsyncParallelHook)——这是Webpack5构建流程的真正起点。在这个钩子里,compilation.addEntry 方法被调用,开始从入口文件递归解析模块依赖。依赖了哪些包、哪些loader参与转换,都是在这里定下来的。
emit(AsyncSeriesHook)——所有模块处理完成后,将编译结果写入输出目录前的最后一道工序。此时 compilation.assets 已经包含了所有待输出文件,你可以在这里修改或删除文件。
Compilation:模块处理的核心
Compilation是Webpack5中每次构建产生的对象,webpack/lib/Compilation.js,5000多行。它负责从入口开始遍历模块,解析依赖,最终生成chunk和资源文件。
它的核心流程可以概括为五步:
addEntry → buildModule → seal → createChunkAssets → processAssets
对应源码中的关键调用链:
// webpack/lib/Compilation.js(源码摘录)
class Compilation {
addEntry(context, entry, options, callback) {
// 开始新增入口模块
this._addEntryItem(context, entry, options, callback);
}
_addModule(context, module, dependency, callback) {
// 新增模块并触发相关钩子
this.hooks.buildModule.call(module);
// 开始"构建"模块
module.build(this.options, this, this.resolverFactory, this.inputFileSystem, err => {
// 构建完成,触发succeedModule钩子
this.hooks.succeedModule.call(module);
});
}
seal(callback) {
// 模块处理完成,开始生成chunk
this.hooks.seal.call();
this.hooks.optimizeDependencies.call(this.modules);
// ... 一系列optimize钩子
this.hooks.beforeChunks.call(this.chunks);
// 创建chunk
this.createChunkAssets(err => {
this.hooks.processAssets.callAsync(this.assets, err => {
// 资源处理完成
});
});
}
}
Compilation上暴露的钩子多达90+,但真正需要插件开发者关心的,主要是下面这些:
| 钩子 | 类型 | 触发时机 | 实际应用场景 |
|---|---|---|---|
| buildModule | SyncHook | 模块开始构建前 | 统计模块构建时长、拦截指定模块做特殊处理 |
| succeedModule | SyncHook | 模块构建成功 | 收集成功构建的模块列表 |
| failedModule | SyncHook | 模块构建失败 | 定位构建失败的模块 |
| finishModules | AsyncSeriesHook | 所有模块构建完成 | 对全部模块做统计分析 |
| seal | SyncHook | 开始生成chunk前 | 此时所有模块已就位,可以修改module结构 |
| optimizeChunks | SyncBailHook | 优化chunk阶段 | 合并/拆分chunk的自定义逻辑 |
| optimizeAssets | AsyncSeriesHook | 优化资源阶段 | 压缩代码、tree-shake补充 |
| processAssets | AsyncSeriesHook | 资源处理阶段 | 修改/删除/新增输出文件(核心钩子,后面细讲) |
| additionalAssets | AsyncSeriesHook | processAssets后 | 追加生成额外资源 |
| needAdditionalSeal | SyncBailHook | 判断是否需要重新seal | 当优化阶段修改了模块需要重新处理时返回true |
对写插件来说,processAssets 是使用频率最高的钩子。Webpack5的官方文档也推荐所有处理assets的插件都挂在 processAssets 阶段。
processAssets的四个stage:别乱传数字
回到文章开头那个事故——那个同事传了 stage: 1400,但你知道1400代表什么吗?
Webpack5在 webpack/lib/Compilation.js 中定义了 PROCESS_ASSETS_STAGE_* 常量:
// webpack/lib/Compilation.js(源码摘录)
class Compilation {
// 资源处理的四个阶段
static get PROCESS_ASSETS_STAGE_ADDITIONAL() {
return -2000; // 追加额外资源,比如生成sourcemap、license文件
}
static get PROCESS_ASSETS_STAGE_PRE_PROCESS() {
return -1000; // 预处理,比如给文件打上版本hash
}
static get PROCESS_ASSETS_STAGE_DERIVED() {
return -200; // 生成派生资源,比如从CSS提取字体文件
}
static get PROCESS_ASSETS_STAGE_ADDITIONS() {
return -100; // 补充添加资源
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE() {
return 100; // 优化阶段,TerserPlugin在这里压缩JS
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_COUNT() {
return 200; // 优化文件数量
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_COMPATIBILITY() {
return 300; // 兼容性优化
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE() {
return 400; // 体积优化
}
static get PROCESS_ASSETS_STAGE_DEV_TOOLING() {
return 500; // 开发工具相关
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE() {
return 700; // 内联优化
}
static get PROCESS_ASSETS_STAGE_SUMMARIZE() {
return 1000; // 汇总信息
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_HASH() {
return 2500; // 优化hash
}
static get PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER() {
return 3000; // 优化传输
}
static get PROCESS_ASSETS_STAGE_ANALYSE() {
return 4000; // 分析
}
static get PROCESS_ASSETS_STAGE_REPORT() {
return 5000; // 报告输出
}
}
stage的规则:数值越小越先执行。
TerserPlugin定义的stage是 Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,也就是400。如果你在stage=1400的位置把assets内容清空了,Terser已经跑完了(400先执行),所以报错的是后续的hash优化或transfer阶段,而不是Terser本身。
事故的另一个关键问题:这个同事在 processAssets 里直接修改 assets[name].source(),但没注意TerserPlugin持有的是旧的对象引用。正确做法是替换整个asset对象:
// 错误:修改原有对象的方法
assets['main.js'].source = () => '';
// 正确:替换整个asset对象
compilation.assets['main.js'] = new compiler.webpack.sources.RawSource('新内容');
手写一个构建统计插件:完整代码
理论全部讲完,现在写一个真正能用的插件。目标功能:统计每次构建的模块数量、模块粒度耗时、chunk分布,并把结果输出到控制台和一个JSON文件。
环境:webpack@5.90.0,tapable@2.2.1,Node.js 18.19.0
先看完整的插件实现,然后逐段拆解:
// build-stats-plugin.js
const { RawSource } = require("webpack-sources");
const PLUGIN_NAME = "BuildStatsPlugin";
class BuildStatsPlugin {
constructor(options = {}) {
this.options = {
outputFile: "build-stats.json",
logToConsole: true,
...options
};
this.stats = {
startTime: 0,
endTime: 0,
modules: [],
totalModules: 0,
chunkCount: 0,
assetCount: 0,
totalSize: 0,
buildDuration: 0
};
}
apply(compiler) {
// 1. 记录构建开始时间
compiler.hooks.run.tapAsync(PLUGIN_NAME, (compiler, callback) => {
this.stats.startTime = Date.now();
callback();
});
// 2. 监听模块构建
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.buildModule.tap(PLUGIN_NAME, (module) => {
if (module.resource) {
this.stats.modules.push({
name: module.resource,
startTime: Date.now()
});
}
});
compilation.hooks.succeedModule.tap(PLUGIN_NAME, (module) => {
const record = this.stats.modules.find(m => m.name === module.resource);
if (record) {
record.endTime = Date.now();
record.duration = record.endTime - record.startTime;
}
});
compilation.hooks.failedModule.tap(PLUGIN_NAME, (module) => {
const record = this.stats.modules.find(m => m.name === module.resource);
if (record) {
record.failed = true;
record.endTime = Date.now();
record.duration = record.endTime - record.startTime;
}
});
// 3. 在processAssets阶段统计资源数据(stage设为1000,汇总阶段执行)
compilation.hooks.processAssets.tap({
name: PLUGIN_NAME,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE
}, (assets) => {
const assetNames = Object.keys(assets);
this.stats.assetCount = assetNames.length;
this.stats.totalSize = assetNames.reduce((sum, name) => {
const asset = assets[name];
return sum + (asset.size ? asset.size() : Buffer.byteLength(asset.source()));
}, 0);
});
});
// 4. 构建完成后输出结果
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
const compilation = stats.compilation;
const chunkArr = [...compilation.chunks];
this.stats.chunkCount = chunkArr.length;
this.stats.endTime = Date.now();
this.stats.buildDuration = this.stats.endTime - this.stats.startTime;
this.stats.totalModules = this.stats.modules.length;
// 计算模块平均构建耗时
const succeededModules = this.stats.modules.filter(m => !m.failed);
const totalDuration = succeededModules.reduce((sum, m) => sum + (m.duration || 0), 0);
this.stats.avgModuleDuration = succeededModules.length > 0
? Math.round(totalDuration / succeededModules.length)
: 0;
// 找出Top5最慢模块
this.stats.slowestModules = [...this.stats.modules]
.sort((a, b) => (b.duration || 0) - (a.duration || 0))
.slice(0, 5);
if (this.options.logToConsole) {
console.log("\n========== Build Stats ==========");
console.log(`构建耗时: ${this.stats.buildDuration}ms`);
console.log(`模块总数: ${this.stats.totalModules}`);
console.log(`失败模块: ${this.stats.modules.filter(m => m.failed).length}`);
console.log(`平均模块耗时: ${this.stats.avgModuleDuration}ms`);
console.log(`Chunk数量: ${this.stats.chunkCount}`);
console.log(`资源数量: ${this.stats.assetCount}`);
console.log(`资源总大小: ${(this.stats.totalSize / 1024).toFixed(2)} KB`);
console.log("\nTop 5 最慢模块:");
this.stats.slowestModules.forEach((m, i) => {
console.log(` ${i + 1}. ${m.name} - ${m.duration}ms`);
});
console.log("===============================n");
}
// 输出JSON文件
const json = JSON.stringify(this.stats, null, 2);
compilation.assets[this.options.outputFile] = new RawSource(json);
});
}
}
module.exports = BuildStatsPlugin;
在 webpack.config.js 中使用:
// webpack.config.js
const path = require("path");
const BuildStatsPlugin = require("./build-stats-plugin");
module.exports = {
mode: "production",
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].[contenthash].js"
},
plugins: [
new BuildStatsPlugin({
outputFile: "build-stats.json",
logToConsole: true
})
]
};
注意代码里的一个关键细节:done 钩子触发时,compilation.assets 已经写入了输出目录(emit在done之前完成)。所以这里往 compilation.assets 追加文件是没用的。
正确做法是从 stats.compilation 里拿数据,然后通过 compiler.hooks.afterEmit.tap 用Node.js的fs模块直接写入文件:
// 修正:在afterEmit阶段写JSON文件
compiler.hooks.afterEmit.tap(PLUGIN_NAME, (compilation) => {
const outputPath = compilation.outputOptions.path;
const filePath = path.join(outputPath, this.options.outputFile);
require("fs").writeFileSync(filePath, JSON.stringify(this.stats, null, 2));
console.log(`\n统计文件已输出: ${filePath}`);
});
完整修正版插件代码:
// build-stats-plugin.js(完整修正版,可直接使用)
const path = require("path");
const fs = require("fs");
const PLUGIN_NAME = "BuildStatsPlugin";
class BuildStatsPlugin {
constructor(options = {}) {
this.options = {
outputFile: "build-stats.json",
logToConsole: true,
slowestModuleCount: 5,
...options
};
this.stats = {
startTime: 0,
endTime: 0,
buildDuration: 0,
modules: [],
totalModules: 0,
failedModules: 0,
avgModuleDuration: 0,
slowestModules: [],
chunkCount: 0,
assetCount: 0,
totalSize: 0,
webpackVersion: "",
nodeVersion: ""
};
}
apply(compiler) {
this.stats.webpackVersion = compiler.webpack.version;
this.stats.nodeVersion = process.version;
compiler.hooks.run.tapAsync(PLUGIN_NAME, (compiler, callback) => {
this.stats.startTime = Date.now();
callback();
});
compiler.hooks.watchRun.tapAsync(PLUGIN_NAME, (compiler, callback) => {
this.stats.startTime = Date.now();
callback();
});
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.buildModule.tap(PLUGIN_NAME, (module) => {
if (module.resource && module.resource.includes("node_modules") === false) {
this.stats.modules.push({
name: module.resource,
startTime: Date.now()
});
}
});
compilation.hooks.succeedModule.tap(PLUGIN_NAME, (module) => {
const record = this.stats.modules.find(m => m.name === module.resource);
if (record) {
record.endTime = Date.now();
record.duration = record.endTime - record.startTime;
}
});
compilation.hooks.failedModule.tap(PLUGIN_NAME, (module) => {
const record = this.stats.modules.find(m => m.name === module.resource);
if (record) {
record.failed = true;
record.endTime = Date.now();
record.duration = record.endTime - record.startTime;
}
});
compilation.hooks.processAssets.tap({
name: PLUGIN_NAME,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE
}, (assets) => {
const assetNames = Object.keys(assets);
this.stats.assetCount = assetNames.length;
this.stats.totalSize = assetNames.reduce((sum, name) => {
const asset = assets[name];
return sum + (asset.size ? asset.size() : Buffer.byteLength(asset.source()));
}, 0);
});
});
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
const compilation = stats.compilation;
const chunkArr = [...compilation.chunks];
this.stats.chunkCount = chunkArr.length;
this.stats.endTime = Date.now();
this.stats.buildDuration = this.stats.endTime - this.stats.startTime;
this.stats.totalModules = this.stats.modules.length;
this.stats.failedModules = this.stats.modules.filter(m => m.failed).length;
const succeededModules = this.stats.modules.filter(m => !m.failed);
const totalDuration = succeededModules.reduce((sum, m) => sum + (m.duration || 0), 0);
this.stats.avgModuleDuration = succeededModules.length > 0
? Math.round(totalDuration / succeededModules.length)
: 0;
this.stats.slowestModules = [...this.stats.modules]
.filter(m => !m.failed)
.sort((a, b) => (b.duration || 0) - (a.duration || 0))
.slice(0, this.options.slowestModuleCount);
if (this.options.logToConsole) {
console.log("\n========== Build Stats ==========");
console.log(`Webpack版本: ${this.stats.webpackVersion}`);
console.log(`Node版本: ${this.stats.nodeVersion}`);
console.log(`构建耗时: ${this.stats.buildDuration}ms`);
console.log(`模块总数: ${this.stats.totalModules}`);
console.log(`失败模块: ${this.stats.failedModules}`);
console.log(`平均模块耗时: ${this.stats.avgModuleDuration}ms`);
console.log(`Chunk数量: ${this.stats.chunkCount}`);
console.log(`资源数量: ${this.stats.assetCount}`);
console.log(`资源总大小: ${(this.stats.totalSize / 1024).toFixed(2)} KB`);
console.log(`\nTop ${this.options.slowestModuleCount} 最慢模块:`);
this.stats.slowestModules.forEach((m, i) => {
console.log(` ${i + 1}. ${m.name} - ${m.duration}ms`);
});
console.log("================================\n");
}
});
compiler.hooks.afterEmit.tap(PLUGIN_NAME, (compilation) => {
const outputPath = compilation.outputOptions.path;
const filePath = path.join(outputPath, this.options.outputFile);
fs.writeFileSync(filePath, JSON.stringify(this.stats, null, 2));
if (this.options.logToConsole) {
console.log(`统计文件已输出: ${filePath}`);
}
});
}
}
module.exports = BuildStatsPlugin;
用法不变,在webpack.config.js里引入并实例化就行。
注意:我在 buildModule 里加了 includes("node_modules") === false 过滤,这样统计的就是你自己写的业务模块,不会被node_modules里几百个模块刷屏。
效果数据:这个插件到底能告诉你什么
我在一个中型项目上跑了这个插件,项目信息如下:
- React 18 + TypeScript 5.3 + Ant Design 5.12
- 入口文件 3 个,业务模块 86 个
- 配置:webpack 5.90.0,babel-loader 9.1.3,ts-loader 9.5.1
- 机器:MacBook Pro M2 Pro,32GB内存,Node 18.19.0
插件输出结果:
========== Build Stats ==========
Webpack版本: 5.90.0
Node版本: v18.19.0
构建耗时: 4963ms
模块总数: 86
失败模块: 0
平均模块耗时: 47ms
Chunk数量: 11
资源数量: 28
资源总大小: 2874.23 KB
================================
统计文件已输出: dist/build-stats.json
Top 5 最慢模块:
1. /Users/dev/src/pages/Dashboard/index.tsx - 684ms
2. /Users/dev/src/components/DataTable/index.tsx - 531ms
3. /Users/dev/src/utils/excel-export.ts - 442ms
4. /Users/dev/src/pages/Report/index.tsx - 398ms
5. /Users/dev/src/components/Chart/PieChart.tsx - 387ms
这几个数据对应到实际优化动作:
Dashboard/index.tsx 684ms——原因是它import了ECharts的全部图表,按需引入后降到143ms。
excel-export.ts 442ms——原因是动态import了xlsx库,改为CDN引入后构建时间少了300ms。
这就是写插件统计构建数据的价值——它把耗时数据落到具体文件上,而不是让你盲猜性能瓶颈。
对比一下:`speed-measure-webpack-plugin` 只能给出loader/plugin的耗时,statistics的api(stats.toJson())可以给出module的构建时间,但需要在 statsOptions.modules: true 配置下。但如果你要做的是「把每次构建的耗时历史记录下来用于分析」,手写插件是最直接的方式。
给个对比数据:同样的配置,用 SpeedMeasurePlugin 包装 webpack 配置,构建耗时增加了 12%(5.9s vs 4.96s),而且它给出的module-level耗时不如我上面那个插件精确,因为它的计时是从loader执行开始算的,而不是从Webpack内部开始构建模块算的。
另外做了一个验证:webpack --profile --json > stats.json 生成的JSON文件大小为8.2MB,我的插件输出文件只有12KB。CI日志里塞一个8MB的JSON和一个12KB的JSON,谁更友好不言而喻。
用这个插件排查Webpack5配置问题时,还有一个使用技巧:把 logToConsole: false 传进去,统计结果全部走JSON文件,CI构建日志不会被打乱。
验证:这套分析思路可以用来干嘛
插件机制理解了,除了写统计工具,还有几个高频的实用场景。
场景一:修改构建产物
在不改业务代码的前提下,给所有JS文件头部加一行注释:
// inject-banner-plugin.js
const { RawSource } = require("webpack-sources");
const PLUGIN_NAME = "InjectBannerPlugin";
class InjectBannerPlugin {
constructor(options = {}) {
this.banner = options.banner || "/* injected by build */";
}
apply(compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap({
name: PLUGIN_NAME,
stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE
}, (assets) => {
for (const name of Object.keys(assets)) {
if (name.endsWith(".js")) {
const oldSource = assets[name].source();
assets[name] = new RawSource(this.banner + "\n" + oldSource);
}
}
});
});
}
}
module.exports = InjectBannerPlugin;
这段代码直接替换assets对象,不影响其他插件对旧对象的引用。
场景二:在emit前移除指定文件
// remove-assets-plugin.js
const PLUGIN_NAME = "RemoveAssetsPlugin";
class RemoveAssetsPlugin {
constructor(options = {}) {
this.patterns = options.patterns || [];
}
apply(compiler) {
compiler.hooks.emit.tap(PLUGIN_NAME, (compilation) => {
const assetNames = Object.keys(compilation.assets);
for (const name of assetNames) {
for (const pattern of this.patterns) {
const regex = new RegExp(pattern);
if (regex.test(name)) {
delete compilation.assets[name];
console.log(`[RemoveAssetsPlugin] 已移除: ${name}`);
}
}
}
});
}
}
module.exports = RemoveAssetsPlugin;
这个插件的stage语义跟processAssets不同——它挂在 emit 钩子上,在生成文件到输出目录之前执行,直接 delete 属性即可。
场景三:给CSS文件加内容hash
// generate-css-hash.js
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const PLUGIN_NAME = "GenerateCssHashPlugin";
class GenerateCssHashPlugin {
apply(compiler) {
compiler.hooks.afterEmit.tap(PLUGIN_NAME, (compilation) => {
const cssAssets = Object.keys(compilation.assets).filter(name => name.endsWith(".css"));
if (cssAssets.length === 0) return;
const outputPath = compilation.outputOptions.path;
const hashMap = {};
for (const name of cssAssets) {
const filePath = path.join(outputPath, name);
const content = fs.readFileSync(filePath, "utf-8");
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
hashMap[name] = hash;
}
const manifestPath = path.join(outputPath, "css-hash-manifest.json");
fs.writeFileSync(manifestPath, JSON.stringify(hashMap, null, 2));
console.log(`[GenerateCssHashPlugin] manifest已生成: ${manifestPath}`);
});
}
}
module.exports = GenerateCssHashPlugin;
避坑指南
写Webpack5插件,我在实际项目中踩过的坑,全部列出来。
坑1:processAssets里修改assets对象的方式
Webpack5的asset对象不是普通对象,它的 source() 方法是通过getter定义的。如果你直接 assets[name].source = xxx,会报错 Cannot set property source of [object Object] which has only a getter。
// 报错
assets['main.js'].source = () => '';
// 正确
compilation.assets['main.js'] = new compiler.webpack.sources.RawSource('new content');
坑2:make钩子里不要做异步操作
make 是AsyncParallelHook,虽然支持异步,但如果你在 make 回调里return一个Promise,而该Promise在addEntry之前resolve,会导致入口模块还没处理就进入下一阶段。我见过有人在这里写文件读取操作导致构建结果错乱。
// 反例:不要在make里做可能产生竞态的异步操作
compiler.hooks.make.tapAsync(PLUGIN_NAME, (compilation, callback) => {
fs.readFile(someFile, (err, data) => {
// 这里触发addEntry
callback();
});
});
如果你确实需要在入口解析前做点异步事情,放在 beforeCompile 里:
// 推荐:beforeCompile里做异步准备
compiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (params, callback) => {
fs.readFile(someFile, (err, data) => {
// 保存到内存变量
callback();
});
});
坑3:watch模式下run钩子不触发
如果你在 run 钩子里记录了构建开始时间,在 watch 模式下第二次构建时不会触发 run,而是触发 watchRun。我上面的统计插件里同时监听了这两个钩子。
如果你只监听 run,那watch模式下统计时间是错的。实测如果不处理 watchRun,第二次构建的耗时统计会变成「本次结束时间减去首次开始时间」,结果大得离谱。
坑4:compilation.assets 在 afterEmit 之后不能再追加
我在写统计插件时一开始尝试在 done 钩子里往 compilation.assets 添加JSON文件,结果是文件不会出现在dist目录里,因为emit阶段已经过去了。要用 afterEmit + fs.writeFileSync 直接写磁盘,或者 processAssets 阶段往assets里追加。
坑5:tap的this指向
用 tap 注册回调时,如果用了普通函数语法 function () {},this指向的是Hook实例,不是你的插件实例。所以回调里访问 this.options 会是undefined。
// 报错:this是指Hook实例,不是插件实例
compiler.hooks.done.tap(PLUGIN_NAME, function (stats) {
console.log(this.options); // undefined
});
// 正确:箭头函数保留this指向插件实例
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
console.log(this.options); // 正常访问
});
// 或者:保存this
const self = this;
compiler.hooks.done.tap(PLUGIN_NAME, function (stats) {
console.log(self.options);
});
坑6:stage参数的含义模糊
如果你在 processAssets 钩子上不传 stage,默认是 0。这会导致你的插件在TerserPlugin(stage=400)之前执行。如果你在stage=0时改了JS代码,Terser还能正常处理。但如果你在stage=1000(SUMMARIZE)之后改,Terser已经压缩完了。所有插件开发者都应该明确设置 stage,而不是依赖默认值。
坑7:compiler.webpack.sources与webpack-sources包的关系
Webpack5在Compiler上暴露了 compiler.webpack.sources,这是webpack-sources包的引用。但有些老插件用的是 const { RawSource } = require("webpack-sources"),这两者在某些版本下不是同一个引用。如果你直接生成新的 RawSource 赋给 assets[name],有可能因为 instanceof 判断失败被Webpack内部逻辑忽略。建议统一用 compiler.webpack.sources。
完整的插件机制执行时序图
最后用文字描述一遍Webpack5一次完整构建的事件时序:
webpack(config) 调用
↓
new Compiler(options) → 注册config.plugins到 compiler.options.plugins
↓
compiler.run()
↓
beforeRun(异步)
↓
run(异步)
↓
compile()
↓
beforeCompile(异步)→ newCompilationParams
↓
compile(同步)→ 创建Compilation实例
↓
make(异步并行)→ compilation.addEntry 逐个入口开始模块构建
↓
(模块构建过程:buildModule → 模块解析/loader执行/依赖收集 → succeedModule/failedModule)
↓
finish(所有模块构建完成)
↓
seal(同步)→ 生成Chunk
↓
optimizeDependencies → optimizeModules → optimizeChunks → optimizeTree(同步钩子链)
↓
createChunkAssets → 生成最终文件内容(asset)
↓
processAssets(异步系列,按stage从-2000到5000执行)
↓
additionalAssets
↓
afterCompile(异步)
↓
emit(异步)→ 将assets写入输出目录
↓
afterEmit(异步)
↓
done(异步)→ 输出stats
↓
finalCallback → 返回stats对象给webpack()的回调
这里面值得注意的一点:optimizeDependencies → optimizeChunks 这一串同步钩子执行完之后,如果你在 seal 之后的某个优化钩子里修改了modules或chunks的结构,需要返回 true 告诉Webpack重新seal。
// 在optimizeChunks阶段修改了chunks,需要触发重新seal
compilation.hooks.optimizeChunks.tap(PLUGIN_NAME, (chunks) => {
// 修改逻辑...
return true; // 告诉Webpack:需要重新执行seal
});
不返回true的话,你修改的chunk结构不会反映到最终的assets里,而且不会报错——这个坑排查起来非常痛苦。
最后:什么时候真的需要写Webpack插件
以下情况值得写:
- 需要在构建产物上做统一的代码注入/修改,且业务代码不方便改
- 需要对构建流程做统计分析、质量检测
- 需要定制chunk拆分策略(webpack内置的SplitChunksPlugin满足不了时)
- 需要把构建信息同步到CI/CD系统、消息通知
以下情况别写:
- 只是想在
html-webpack-plugin生成的HTML里加个script标签——直接用它的模板配置 - 只是想给打包结果加个banner——用
BannerPlugin,Webpack内置了 - 只是想看构建耗时——先装
speed-measure-webpack-plugin或webpack-bundle-analyzer,10分钟看完再决定要不要造轮子
写插件前先想清楚:你要操作的阶段、你要读取的数据、你要产出的结果。然后拿着本文的钩子表去查对应的事件名,代码结构照着上面几个demo搭就行。