深入理解 Webpack Plugin
2025-06-23 · 21minTapable
Tapable 是 Webpack 内部用于实现事件发布订阅的底层库,通过 Tapable 可以注册事件,在不同时机去触发执行我们的注册事件。
它是整个 Webpack 的底层事件流引擎,Webpack 的核心对象 Compiler 和 Compilation 都继承自 Tapable 。
Hooks
Tapable 提供了同步、异步、瀑布流、并行等多种类型的钩子,总共有9个钩子。
Webpack 在 compiler.hooks 和 compilation.hooks 上暴露了大量的钩子。
注册回调
Plugin 通过三种方法,向钩子注册一个回调函数,来干预构建流程的:
.tap('pluginName', (arg1, arg2) => {}): 用于同步钩子或基础注册。.tapAsync('pluginName', (arg1, arg2, callback) => { ... }): 用于异步钩子。回调函数接收一个额外的callback参数,插件必须在异步操作完成后调用callback()或callback(err)来通知流程继续。.tapPromise('pluginName', (arg1, arg2) => { ... return promise; }): 用于异步钩子。回调函数必须返回一个 Promise。Webpack 会等待这个 Promise 被 resolve 或 reject 后继续流程。
回调函数中的 arg1 和 arg2 不用严格固定,完全看自己的需求来定多少个都想。.tapAsync 只需要记得最后一个参数用于接收 callback 即可。
触发钩子
当 Webpack 的构建流程执行到某个生命周期时,会通过 call 触发相应的钩子,执行该钩子上所有注册的回调函数。
根据钩子类型不同,触发方法有3种:
.call(arg1, arg2):用于触发同步钩子。.callAsync(arg1, arg2, callback):用于触发基于callback的异步钩子。.promise(arg1, arg2).then(() => {}).catch(err => {}): 用于触发基于 Promise 的异步钩子。
钩子类型
Tapable 的钩子主要分为同步和异步两类。
同时还可以按照机制分类:
- 基本钩子:仅执行注册函数,啥也不关心。
- 瀑布流钩子(Waterfall):会将前一个注册函数的非
undefined返回值作为下一个注册函数的参数传递。 - 熔断钩子(Bail):在基础类型钩子上新增一个熔断机制,当任意一个注册函数执行返回了非
undefined的值时,会中断整个钩子执行过程,后续注册函数都不会执行。 - 循环钩子(Loop):当任意个一个钩子返回了非
undefined的值时,会从头开始执行所有注册函数,直到所有注册函数返回undefined。
根据同步/异步与4类机制组合,总共有9种类型钩子。
SyncHook (同步钩子)
最简单的钩子,同步、顺序地执行所有注册回调函数,不需要返回值。
示例
const { SyncHook } = require('tapable');
const hook = new SyncHook(['arg1', 'arg2']);
hook.tap('plugin2', (arg1, arg2) => {
console.log('plugin1:', arg1, arg2);
});
hook.tap('plugin2', (arg1, arg2) => {
console.log('plugin2:', arg1, arg2);
});
hook.call(1,2);
输出
plugin1: 1 2
plugin2: 1 2
用途:不需要中断或修改参数,只需在某个点通知多个监听器的场景。
SyncBailHook(同步熔断钩子)
同步、顺序地执行回调。当某个回调返回了非 undefined 时,则立即停止后续回调,并将返回值作为整个钩子触发调用 call 的返回值。
示例
const { SyncBailHook } = require('tapable');
const hook = new SyncBailHook(['arg1', 'arg2']);
hook.tap('plugin1', (arg1, arg2) => {
console.log('plugin1:', arg1, arg2);
return true
});
hook.tap('plugin2', (arg1, arg2) => {
console.log('plugin2:', arg1, arg2);
});
hook.call(1,2);
输出
plugin1: 1 2
用途: 用于需要条件中断的场景。例如:如果某个插件返回 false,则跳过输出阶段。
SyncWaterfallHook(同步瀑布流钩子)
同步、顺序地执行回调。第一个回调接收初始参数,后续每个回调接受前一个回调的返回值作为第一个参数,并返回一个新值给下一个回调。
最后一个回调的返回值作为整个钩子触发调用的返回值。
SyncWaterfallHook 仅能修改第一个参数的返回值。
示例
const { SyncWaterfallHook } = require('tapable');
const hook = new SyncWaterfallHook(['arg1', 'arg2']);
hook.tap('plugin1', (arg1, arg2) => {
console.log('plugin1:', arg1, arg2);
return 'plugin1Result';
});
hook.tap('plugin2', (arg1, arg2) => {
console.log('plugin2:', arg1, arg2);
});
hook.call(1,2);
输出
plugin1: 1 2
plugin2: plugin1Result 2
用途: 用于需要链式处理、逐步修改某个值的场景。例如:处理模块的 source。
SyncLoopHook(同步循环钩子)
同步顺序地执行回调。如果某个钩子返回了非 undefined 的值,则从头开始执行所有回调,直到所有回调都返回 undefined。
示例
const { SyncLoopHook } = require("tapable");
let plugin1Count = 1;
let plugin2Count = 1;
const hook = new SyncLoopHook([]);
hook.tap("plugin1", (_) => {
console.log(`plugin1: 第${plugin1Count++}次执行`);
if (plugin1Count < 3) {
return false
}
});
hook.tap("plugin2", (_) => {
console.log(`plugin2: 第${plugin2Count++}次执行`);
if (plugin2Count < 3) {
return false;
}
});
hook.call();
输出
plugin1: 第1次执行
plugin1: 第2次执行
plugin2: 第1次执行
plugin1: 第3次执行
plugin2: 第2次执行
用途: 相对少见。用于需要反复执行直到满足某个条件的场景。
AsyncParalleHook(异步并行钩子)
所有回调同时启动执行。当所有回调执行完毕(调用 callback() 或者 reslove())后,触发最终的 callAsync 回调或者 Promise.reslove。
示例
const { AsyncParallelHook } = require("tapable");
const hook = new AsyncParallelHook(["arg1", "arg2"]);
console.time("timer");
hook.tapPromise("plugin1", (arg1, arg2) => {
console.log("plugin2:", arg1, arg2);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapAsync("plugin2", (arg1, arg2, callback) => {
console.log("plugin1:", arg1, arg2);
setTimeout(() => {
callback();
}, 2000);
});
hook.callAsync(1, 2, () => {
console.log("done");
console.timeEnd("timer");
});
输出
plugin2: 1 2
plugin1: 1 2
done
timer: 2.006s
用途 用于可以并行执行且不依赖彼此结果的异步操作。例如:同时向多个 CDN 上传文件(如果顺序无关)。
AsyncSeriesHook(异步串行钩子)
串行执行所有异步回调。一个回调执行完成后,才会执行下一个回调。
所有回调都执行完后,触发最终的 callAysnc 回调或者 Promise.reslove。
示例
const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['arg1', 'arg2']);
console.time('timer');
hook.tapAsync('plugin1', (arg1, arg2, callback) => {
console.log('plugin1:', arg1, arg2);
setTimeout(() => {
callback();
}, 1000);
});
hook.tapPromise('plugin2', (arg1, arg2) => {
console.log('plugin2:', arg1, arg2);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 2000);
});
});
hook.callAsync(1, 2, () => {
console.log('done');
console.timeEnd('timer')
});
输出
plugin1: 1 2
plugin2: 1 2
done
timer: 3.006s
用途: 用于需要按顺序执行的异步操作,大多数涉及 I/O 或需要严格顺序的生命周期钩子都是这种类型。例如:
compiler.hooks.run: 启动构建。compiler.hooks.emit:输出资源到目录前。compiler.hooks.done:构建完成。
AsyncParalleBailHook(异步并行熔断钩子)
并行执行所有异步回调,如果任何一个回调抛出错误,则立即调用最终的 callAysnc 回调或者 Promise.reslove,将第一个抛出的错误返回,忽略其他尚未完成或开始的回调。
示例
const { AsyncParallelBailHook } = require("tapable");
const hook = new AsyncParallelBailHook(["arg1", "arg2"]);
console.time("timer");
hook.tapPromise("plugin1", (arg1, arg2) => {
return new Promise((resolve) => {
console.log("plugin1:", arg1, arg2);
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapAsync("plugin2", (arg1, arg2, callback) => {
setTimeout(() => {
console.log("plugin2:", arg1, arg2);
callback();
}, 3000);
});
hook.callAsync(1, 2, () => {
console.log("done");
console.timeEnd("timer");
});
输出
plugin1: 1 2
done
timer: 1.008s
plugin2: 1 2
用途: 用于并行执行但需要快速失败的场景(如并行校验,一个失败则整体失败)。
AsyncSeriesBailHook(异步串行熔断钩子)
串行执行所有异步回调。如果某个异步回调抛出错误时,则立即调用最终的 callAysnc 回调或者 Promise.reslove,该错误或非 undefined 值被传递出去。
示例
const { AsyncSeriesBailHook } = require("tapable");
const hook = new AsyncSeriesBailHook(["arg1", "arg2"]);
console.time("timer");
hook.tapPromise("plugin1", (arg1, arg2, callback) => {
console.log("plugin1:", arg1, arg2);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapAsync("plugin2", (arg1, arg2, callback) => {
console.log("plugin2:", arg1, arg2);
setTimeout(() => {
callback();
}, 1000);
});
hook.callAsync(1, 2, () => {
console.log("done");
console.timeEnd("timer");
});
输出
plugin1: 1 2
done
timer: 1.008s
用途: 用于需要按顺序执行且可能提前失败的异步操作链。例如:compiler.hooks.failed 可能涉及一些清理操作,但一个失败可能跳过后续清理。
AsyncSeriesWaterfallHook(异步串行瀑布流钩子)
串行执行所有异步回调。
第一个回调接收初始参数,后续每个回调接收前一个回调通过
callback(null, nextValue) 或者 Promise.reslove(nextValue) 传递过来的值作为第一个参数,并可以处理传递给下一个回调函数。
最后一个回调的结果传递给最终的 callAsync 回调或 Promise.reslove。
示例
const { AsyncSeriesWaterfallHook } = require("tapable");
const hook = new AsyncSeriesWaterfallHook(["arg1", "arg2"]);
console.time("timer");
hook.tapPromise("plugin1", (arg1, arg2) => {
console.log("plugin1:", arg1, arg2);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
});
hook.tapAsync("plugin2", (arg1, arg2, callback) => {
console.log("plugin2:", arg1, arg2);
setTimeout(() => {
callback();
}, 1000);
});
hook.callAsync(1, 2, () => {
console.log("done");
console.timeEnd("timer");
});
输出
plugin1: 1 2
plugin2: true 2
done
timer: 2.006s
用途:用于需要按顺序执行且每一步都依赖上一步结果的异步操作链。例如:链式处理请求或资源转换。
Compiler && Compilation
Complier 和 Compilation 是 Webpack 的两个核心对象,他们都继承了 Tapable 类,在掌握 Plugin 之前要先知道这两个对象。
Compiler(编译器)
特点:
- 代表不变的 Webpack 环境。
- 从启动到结束只存在一个实例。
- 持有完整的配置
compiler.options。 - 负责启动构建(
compiler.run/compiler.watch)。 - 暴露生命周期钩子
- 可以创建
Compilation对象。
生命周期
Compiler 的生命周期主要是以下阶段:
初始化 → 启动构建 → 编译准备 → 模块处理 → 优化阶段 → 资源生成 → 收尾阶段
每个阶段都有许多钩子供开发者调用,[文档]https://www.webpackjs.com/api/compiler-hooks/#hooks光有数量,也没详细介绍。
只需要掌握常用的钩子即可:
- compilation:
SyncHook类型。Compilation对象创建后执行。用于注册子钩子,修改模块行为。 - emit:
AsyncSeriesHook类型。 输出 asset 到 output 目录之前执行。用于对最终资源进行最终的增删改。 - afterEmit:
AsyncSeriesHook类型。
输出 asset 到 output 目录之后执行。用于对已写入磁盘的资源进行处理,例如上传CDN。 - done:
AsyncSeriesHook类型。
构建完成时执行,用于分析报告/通知。 - failed:
SyncHook类型
构建失败时执行,用于错误上报/清理。
Compilation (编译)
特点:
- 代表一次资源的构建过程。
- 每次构建,包括
watch模式下的每次文件改动,都会创建一个新的Compilation对象。 - 持有当前构建过程中的模块依赖图、chunk 图、生成资源等信息。
- 暴露模块和处理 chunk 细节的钩子。
- 可以操作模块和 chunk。
- 插件通过
compiler.hooks.thisCompilation或者compiler.hooks.compilation获取compilation对象实例。
Hook
一样只需要掌握比较常用的钩子即可了:
- processAssets
AsyncSeriesHook类型。
资源处理阶段执行,用于操作最终输出资源。compilation.hooks.processAssets.tap( { name: 'MyPlugin', // 控制执行阶段(重要!) stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS }, (assets) => { // ... 资源操作 } ); - optimizeChunks
SyncBailHook类型。 在优化 Chunks 阶段执行,用于对 chunks 进行代码分割等操作。compilation.hooks.optimizeChunks.tap('MyPlugin', (chunks) => { // ...操作 chunks }); - buildModule
SyncHook类型。
在模块开始构建前执行,用于对模块的预处理,每个模块都会单独执行一次。compilation.hooks.buildModule.tap('MyPlugin', (module) => { // ...操作 module }); - succeedModule
SyncHook类型。 在模块成功构建后执行,对处理后的模块进行操作,每个处理后的模块都会单独执行一次。compilation.hooks.succeedModule.tap('MyPlugin', (module) => { // ... 操作 module }); - finishModules
AsyncSeriesHook类型。 所有模块构建完成后执行,用于对全局模块进行分析操作。compilation.hooks.finishModules.tapAsync('MyPlugin', (modules, callback) => { // ... 分析 modules callback(); }); - afterOptimizeChunkAssets
SyncHook类型。 Chunk 资源完成优化后执行,用于对最终资源进行调整。compilation.hooks.afterOptimizeChunkAssets.tap('MyPlugin', (chunks) => { // ...最终资源调整 }); - needAdditionalSeal
SyncBailHook类型。 判断是否需要额外优化,用于优化控制。compilation.hooks.needAdditionalSeal.tap('MyPlugin', () => { // 根据条件判断是否需要重新优化 if (someCondition) { return true; // 触发重新优化 } });
注意:
- 不在钩子中存储对
compilation的长期引用,避免内存泄露。 - 在频繁触发的钩子(
buildModule)中避免复杂操作,性能优先。 - 合理使用
cache提高构建性能。
Plugin
Plugin 的本质,就是向特定钩子上注册事件处理函数的函数或者包含 apply 方法的对象。通常来说会是一个包含 apply 方法的类。
class MyPlugin {
/**
* apply 方法是插件的入口点。
* @param {object} compiler - Webpack 的 Compiler 实例,代表了完整的配置环境。
*/
apply(compiler) {
// 1. 访问 Compiler 提供的钩子
// 2. 在感兴趣的钩子上注册回调函数
compiler.hooks.someHook.tap('MyPlugin', (params) => {
// 3. 在回调函数中执行你的插件逻辑
console.log('Webpack is doing something!', params);
// ... 你的自定义操作 ...
});
// 可以注册到多个钩子
compiler.hooks.anotherHook.tapAsync(
'MyPlugin',
(params, callback) => {
// 异步操作示例
setTimeout(() => {
console.log('Async operation done!');
callback(); // 必须调用 callback 通知 Webpack 异步操作完成
}, 1000);
}
);
}
}
module.exports = MyPlugin;
Plugin 通过 apply(compiler) 获取 Compiler 对象实例,Webpack 会在启动遍历调用所有配置的插件的 apply 方法。
Plugin 开发
Plugin 开发首先需要明确自己的需求。
之前自定义了 一个 Loader,用于将指定目录的 SVG 转成 CSS 类,但存在个问题:需要显式导入 SVG 文件才能触发 Loader。
原本想着用那就写个插件,自动导入吧,但是转念一想,不如把整个功能用 Plugin 写完好了。
明确需求
- 根据指定目录 SVG 生成 CSS 文件。
- 将 CSS 文件自动导入项目中。
- SVG 文件变化时重新生成。
具体实现
新建 Svg2CssPlugin.js,插件类基本内容如下:
const { createHash } = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { Compiler, Compilation, sources } = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const chokidar = require("chokidar"); // 添加文件监听库
const PLUGIN_NAME = "Svg2CssPlugin";
class Svg2CssPlugin {
constructor(options = {}) {
this.options = {
iconDir: "src/icons",
prefix: "icon-",
outputFile: "icons.css", // 添加输出文件名选项
...options,
};
this.generatedCss = "";
this.cachedHash = "";
this.isDev = false;
}
/**
*
* @description CSS 类模板字符串
* @param {string} className
* @param {string} svgData
* @returns
*/
defaultCssTemplate(className, svgData) {
return `
.${className} {
display: inline-block;
mask: url("data:image/svg+xml;base64,${svgData}") no-repeat center;
mask-size: contain;
width: 1em;
height: 1em;
vertical-align: middle;
background-color: currentColor;
}`;
}
/**
*
* @param {Compiler} compiler
*/
apply(compiler) {
}
}
module.exports = Svg2CssPlugin;
读取 SVG 生成 CSS 内容
第一步当然是要读取指定目录,并生成 CSS 内容。这一步骤我打算初始化时先现生成好,存在插件类当中,查阅文档可以找到 beforeCompile 这个钩子:
/**
*
* @param {Compiler} compiler
*/
apply(compiler) {
// 初始化时生成CSS
compiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (compiler, callback) => {
this.generateCssContent()
.then(() => {
console.log("css content has generated");
callback();
})
.catch((error) => {
console.error(`css generating is wrong: ${error.message}`);
callback();
});
});
}
/**
* @description 生成CSS 内容
* @param {Compilation} context
*/
async generateCssContent() {
const { iconDir, prefix } = this.options;
const absIconDir = path.resolve(process.cwd(), iconDir);
if (!fs.existsSync(absIconDir)) {
console.warn(`[${PLUGIN_NAME}] SVG directory not found: ${absIconDir}`);
return;
}
const files = fs
.readdirSync(absIconDir)
.filter((file) => path.extname(file).toLowerCase() === ".svg");
if (files.length === 0) {
return;
}
let cssContent = `/* Auto-generated by ${PLUGIN_NAME} */\n\n`;
let hashInput = "";
for (const file of files) {
const filePath = path.join(absIconDir, file);
const className = `${prefix}${path
.basename(file, ".svg")
.replace(/\s/g, "-")}`;
try {
const svgBuffer = fs.readFileSync(filePath);
const processedSvg = svgBuffer.toString("base64");
cssContent += this.defaultCssTemplate(className, processedSvg) + "\n\n";
hashInput += `${filePath}:${fs.statSync(filePath).mtimeMs}`;
} catch (error) {
console.error(
`[${PLUGIN_NAME}] Error processing ${file}: ${error.message}`
);
}
}
// 计算内容哈希值
const newHash = createHash("md5").update(hashInput).digest("hex");
// 仅当内容变化时更新
if (newHash !== this.cachedHash) {
this.generatedCss = cssContent;
this.cachedHash = newHash;
console.log(
`[${PLUGIN_NAME}] CSS updated with hash: ${newHash.substring(0, 8)}`
);
}
}
生成的内容会生成一个哈希值进行缓存,减少不必要更新。
处理资源生成
有了 CSS 内容后,就需要想办法把它添加到项目当中,我希望是可以通过以 CSS 文件的方式在入口 HTML 中进行引入的。
这种属于产生额外资产的操作,当然就得在编译阶段执行执行了:
/**
*
* @param {Compiler} compiler
*/
apply(compiler) {
this.isDev = compiler.options.mode === "development";
// ...初始化生成 CSS
// 处理资源生成
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const output = this.getOutputFileName();
compilation.emitAsset(
output,
new sources.RawSource(this.generatedCss)
);
}
);
// 添加到HTML
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
PLUGIN_NAME,
(htmlData) => {
const output = this.getOutputFileName();
htmlData.assets.css.push(output);
return htmlData;
}
);
});
}
/**
* @description 获取输出文件名
*/
getOutputFileName() {
if (this.isDev) {
return this.options.outputFile;
}
return `icon.${this.cachedHash.substring(0, 8)}.css`;
}
通过 compilation.emitAsset 创建新的 CSS 文件,然后通过 HtmlWebpackPlugin 将其注入到入口 HTML 当中。
文件监听
文件监听通过之前提到了 chokidar 来实现:
/**
*
* @param {Compiler} compiler
*/
apply(compiler) {
// ...生成 CSS 内容
//...处理资源生成
// 开发模式下设置文件监听
if (this.isDev) {
// 启动文件监听
compiler.hooks.watchRun.tap(PLUGIN_NAME, (compiler) => {
if (!this.watcher) {
this.setupWatcher(compiler);
}
});
// 清理监听器
compiler.hooks.watchClose.tap(PLUGIN_NAME, () => {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
});
}
}
/**
* @description 设置文件监听器
*/
setupWatcher(compiler) {
const iconDir = path.resolve(compiler.context, this.options.iconDir);
this.watcher = chokidar.watch(iconDir, {
ignored: /(^|[\/\\])\../, // 忽略点文件
persistent: true,
ignoreInitial: true,
});
this.watcher
.on("add", (path) => this.handleFileChange(compiler, path))
.on("change", (path) => this.handleFileChange(compiler, path))
.on("unlink", (path) => this.handleFileChange(compiler, path))
.on("error", (error) => console.error(`Watcher error: ${error}`));
}
运行效果:

总结
其实 Webpack Plugin 开发还是蛮多坑的,文档的粗糙,钩子数量庞大等。
最关键的是Webpack 核心架构的问题:任何资源变更都会触发完整的新一轮 compilation 过程。即使使用 chokidar 精确监听 SVG 目录,依然无法避免 Webpack 的全局重编译机制。
这也是 Webpack 无法实现真正的局部更新的问题,也导致了插件执行成本高昂的问题。
这段时间查阅文档、搜索资料,让我更加深刻理解了 Webpack 的缺陷问题,虽然 Webpack 的生态确实庞大,但有些情况下,本质是用复杂方案解决架构缺陷。
我并不鼓励去过度深入学习 Webpack,除非工作项目一定要求使用 Webpack。
如今已经2025年了,对 Webpack 的掌握,只需要知道他的核心概念以及原理,能够进行优化即可。