Webpack 的核心定位是一个「打包器」:它把散落的模块按依赖关系拼装成产物。但真实项目里,我们还需要生成 HTML、抽取 CSS、压缩代码、注入全局变量、拷贝静态资源……这些事情 loader 做不了,因为它们只作用于「单个文件的转换」,不掌握整个构建流程的全局信息。
这时就该 Plugin(插件) 出场了。本文从「是什么、能做什么、怎么工作」三个层面,把 webpack 插件机制一次讲透,最后手写一个插件把知识落到代码上。
一、Plugin 是什么
一句话概括:Plugin 是一段带有 apply 方法的代码,它通过监听 webpack 构建过程中抛出的钩子(hook),在特定时机执行自定义逻辑。
一个最简插件长这样:
class HelloPlugin {
apply(compiler) {
console.log('HelloPlugin 被使用了');
}
}
module.exports = HelloPlugin;
配置里用 plugins 数组引入:
const HelloPlugin = require('./plugins/hello-plugin');
module.exports = {
plugins: [new HelloPlugin()],
};
注意这里 new HelloPlugin(),插件是一个实例,不是函数。webpack 启动时会遍历 plugins 数组,依次调用每个实例的 apply(compiler),把 compiler 对象交给插件。
Plugin 与 Loader 的区别
| 维度 | Loader | Plugin |
|---|---|---|
| 作用对象 | 单个模块文件 | 整个构建流程 |
| 运行时机 | 模块加载、转换阶段 | 从启动到产物落盘的各个生命周期 |
| 关注点 | 「这个文件怎么转成 JS」 | 「在某个阶段做点什么」 |
| 配置位置 | module.rules | plugins |
| 形态 | 导出函数的模块 | 带 apply 方法的类实例 |
简单说:loader 管「文件」,plugin 管「流程」。css-loader 把 CSS 转成 JS 模块,而 MiniCssExtractPlugin 负责在构建后期把这些 CSS 从 JS 里再抽离成独立文件——前者是转换,后者是流程级操作。
二、Plugin 有什么作用
插件几乎覆盖了构建的所有环节,常见能力可以归为几类:
- 产物生成:
HtmlWebpackPlugin生成 HTML 并自动注入打包后的 script/link 标签;CopyWebpackPlugin拷贝静态资源。 - 资源处理:
MiniCssExtractPlugin抽取 CSS;CompressionWebpackPlugin生成 gzip 文件。 - 代码优化:
TerserPlugin压缩 JS;CssMinimizerPlugin压缩 CSS;SplitChunksPlugin拆分公共代码。 - 构建注入:
DefinePlugin定义编译期全局常量;BannerPlugin给产物加版权注释。 - 流程辅助:
CleanWebpackPlugin清理旧产物;BundleAnalyzerPlugin可视化产物体积;FriendlyErrorsPlugin美化构建提示。
这些插件的实现方式完全一致:找到合适的钩子,挂上自己的逻辑。
三、Plugin 的工作原理
3.1 核心:Tapable 事件流
webpack 内部并没有硬编码「先做什么再做什么」,而是把整个构建过程抽象成一条基于 Tapable 的事件流。每个关键节点都是一个 hook,webpack 在流程中触发(call)它,插件则提前注册监听。
可以把它类比成浏览器的 addEventListener:
// webpack 内部(简化)
compiler.hooks.done.call(stats); // 触发事件
// 插件里
compiler.hooks.done.tap('MyPlugin', (stats) => { /* 响应 */ });
区别在于,Tapable 提供了多种 hook 类型,支持同步/异步、返回值传递、中断等更复杂的语义。
3.2 钩子的类型
按同步/异步、以及是否传递返回值,Tapable 提供了 9 种钩子:
| 类型 | 说明 |
|---|---|
SyncHook | 同步,依次执行,不关心返回值 |
SyncBailHook | 同步,任一监听器返回非 undefined 就中断后续 |
SyncWaterfallHook | 同步,上一个的返回值作为下一个的入参 |
SyncLoopHook | 同步,监听器返回 true 就重复执行 |
AsyncParallelHook | 异步并行,等所有监听器完成 |
AsyncParallelBailHook | 异步并行,遇非 undefined 返回值即结束 |
AsyncSeriesHook | 异步串行 |
AsyncSeriesBailHook | 异步串行,可中断 |
AsyncSeriesWaterfallHook | 异步串行,可传递返回值 |
对应的注册方式也不同:
// 同步钩子用 tap
compiler.hooks.emit.tap('MyPlugin', (compilation) => {});
// 异步钩子用 tapAsync(回调)或 tapPromise(Promise)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
doSomething(() => callback());
});
compiler.hooks.emit.tapPromise('MyPlugin', async (compilation) => {
await doSomething();
});
判断一个钩子该用哪种注册方式,取决于它的类型:
Sync*只能用tap,Async*则三种都支持。用错了 webpack 会直接报错。
3.3 apply 方法的职责
插件通过 apply(compiler) 拿到 compiler,再从 compiler.hooks(以及 compilation.hooks)上注册监听。因此 apply 就是插件的入口,而真正的逻辑写在各个回调里。
3.4 构建生命周期总览
把前面提到的钩子按执行顺序串起来,就是 webpack 的完整生命周期。下图用 compiler 与 compilation 两层来划分:蓝色是全局流程,绿色是单次编译内部的处理。
蓝色为
compiler.hooks,绿色为compilation.hooks/compilation阶段。
几个关键节点的含义:
- thisCompilation / compilation:在
make之前创建compilation对象,它是「本次编译的上下文」,后续所有模块、chunk、asset 都挂在它身上。 - make:从 entry 出发递归解析依赖、构建模块,每构建一个模块会触发
compilation.hooks.buildModule,这是耗时最长的一段。 - compilation.seal:模块都构建完了(
finishModules之后),开始把模块组装成 chunk 并生成产物。 - processAssets:webpack 5 处理产物的推荐钩子,用
stage精确控制修改时机。 - afterCompile:发生在
seal之后,此时compilation的内部处理已全部结束。 - emit / done:产物落盘、构建结束;watch 模式下
done之后等待文件变化,再次触发watchRun。
这里容易混淆的一点是:compilation 对象在 make 之前就创建好了,而它的内部阶段(make 期间触发 buildModule,make 之后执行 seal、processAssets)一直持续到 afterCompile 之前。所以「构建模块」和「封装产物」都属于同一个 compilation 的生命周期,而非两个独立阶段。
理解了这条时间线,就能回答「我的逻辑该挂在哪个钩子上」这个问题。
四、webpack 内部的两大钩子容器
webpack 把钩子分挂在两个核心对象上:compiler 和 compilation。
4.1 compiler.hooks —— 全局生命周期
compiler 在整个 webpack 进程中只创建一次,代表完整的构建生命周期,包括启动、编译、监听、退出。常用钩子:
| 钩子 | 类型 | 触发时机 |
|---|---|---|
environment | SyncHook | 创建环境,插件可读取配置前的最后时机 |
afterEnvironment | SyncHook | 环境创建完成 |
initialize | SyncHook | compiler 初始化完成 |
run | AsyncSeriesHook | 开始编译前 |
watchRun | AsyncSeriesHook | 监听模式下每次重新编译前 |
compile | SyncHook | 一次新的编译开始前 |
thisCompilation | SyncHook | 创建 compilation 时 |
compilation | SyncHook | 创建 compilation 后 |
make | AsyncParallelHook | 从 entry 开始递归构建模块前 |
afterCompile | AsyncSeriesHook | 编译完成 |
shouldEmit | SyncBailHook | 判断是否需要输出产物 |
emit | AsyncSeriesHook | 生成产物到输出目录前(可修改文件) |
assetEmitted | AsyncSeriesHook | 单个产物写入磁盘后 |
afterEmit | AsyncSeriesHook | 产物写入磁盘后 |
done | AsyncSeriesHook | 编译全部完成 |
failed | SyncHook | 编译失败 |
watchClose | SyncHook | 监听模式结束 |
shutdown | AsyncSeriesHook | 编译器关闭 |
4.2 compilation.hooks —— 单次编译
compilation 在每次编译时都会重新创建,代表「一次编译的产物」——包含所有模块、chunk、asset 以及它们之间的关系。常用钩子:
| 钩子 | 类型 | 触发时机 |
|---|---|---|
buildModule | SyncHook | 模块开始构建前 |
succeedModule | SyncHook | 模块构建成功 |
finishModules | AsyncSeriesHook | 所有模块构建完成 |
seal | SyncHook | 编译开始封装(不再接收新模块) |
optimize | SyncHook | 开始优化 |
optimizeModules | SyncHook | 优化模块 |
optimizeChunks | SyncHook | 优化 chunk |
optimizeTree | AsyncSeriesHook | 优化依赖树 |
processAssets | AsyncSeriesHook | 处理产物资源(webpack 5 推荐,替代旧的 emit 系列) |
afterOptimizeAssets | SyncHook | 产物优化完成 |
emit | AsyncSeriesHook | 生成产物前 |
afterEmit | AsyncSeriesHook | 产物生成后 |
done | SyncHook | 编译结束 |
additionalAssets | AsyncSeriesHook | 添加额外资源 |
选择原则:需要跨多次编译存活的资源、或读取整体配置,用
compiler.hooks;只关心当前这次编译的模块/chunk/asset,用compilation.hooks。修改产物优先用 webpack 5 的compilation.hooks.processAssets。
五、Plugin 要打交道的「构建对象」
写插件本质上是操作 webpack 暴露出来的数据结构,最常接触的有三类。
5.1 compiler
compiler 是编译器的「总指挥」,常用属性:
compiler.options:归一化后的 webpack 配置。compiler.context:项目根目录。compiler.outputPath/outputFileSystem:输出路径与文件系统。compiler.hooks:上文提到的全局钩子。compiler.webpack:webpack 主模块的引用,方便取sources、Compilation等。
5.2 compilation
compilation 是「本次编译的结果集」,常用属性:
compilation.modules:本次涉及的所有模块。compilation.chunks:chunk 集合。compilation.assets:待输出的资源(key 是文件名,value 是资源对象)。compilation.entrypoints:入口点。compilation.hooks:本次编译的钩子。compilation.getAsset(name)/updateAsset/emitAsset:读写产物的方法。
5.3 asset 与 source
产物不是字符串,而是带 source() 方法的资源对象。webpack 提供 webpack-sources 里的 RawSource、ConcatSource 等:
const { sources } = compiler.webpack;
// 新增一个文件
compilation.emitAsset(
'version.txt',
new sources.RawSource('v1.0.0')
);
// 读取并修改已有文件
const asset = compilation.getAsset('bundle.js');
const content = asset.source.source(); // 拿到字符串
const updated = content + '\n// powered by webpack';
compilation.updateAsset('bundle.js', new sources.RawSource(updated));
六、手写一个 Plugin
需求:记录并打印整个构建的耗时。涉及 compiler.hooks.run(异步串行)和 compiler.hooks.done(异步串行)。
class BuildTimePlugin {
apply(compiler) {
let start = 0;
compiler.hooks.run.tap('BuildTimePlugin', () => {
start = Date.now();
});
compiler.hooks.done.tapPromise('BuildTimePlugin', async (stats) => {
const duration = Date.now() - start;
console.log(`构建耗时:${duration}ms`);
const { errors, warnings } = stats.compilation;
console.log(`错误 ${errors.length} 个,警告 ${warnings.length} 个`);
});
}
}
module.exports = BuildTimePlugin;
配置:
const BuildTimePlugin = require('./plugins/build-time-plugin');
module.exports = {
// ...其他配置
plugins: [new BuildTimePlugin()],
};
执行 npm run build,就能在控制台看到耗时。这个例子虽然简单,却完整走通了插件的标准流程:实例化 → 调用 apply → 注册钩子 → 在回调里操作构建数据。
进阶:生成一个 version 文件
下面这个插件在每个产物旁边额外生成一个 version.json,用到 compilation.hooks.processAssets 和 sources.RawSource:
const { sources } = require('webpack');
class VersionPlugin {
constructor(options = {}) {
this.version = options.version || '1.0.0';
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('VersionPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'VersionPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
compilation.emitAsset(
'version.json',
new sources.RawSource(JSON.stringify({ version: this.version }))
);
}
);
});
}
}
这里用 stage 控制处理产物的阶段,值来自 Compilation.PROCESS_ASSETS_STAGE_* 常量。选对 stage,才能保证插件的执行顺序符合预期。
七、注意事项
- 钩子类型要与注册方式匹配:
Sync*只能tap,Async*可用tap/tapAsync/tapPromise。 - 异步逻辑必须正确结束:
tapAsync一定要调用callback(),tapPromise要返回 Promise,否则构建会卡住。 - 优先使用
processAssets:webpack 5 已废弃compilation.hooks.emit处理产物的用法,processAssets提供更细的 stage 控制。 - 用
stage而非注册顺序控制时序:多个插件操作同一产物时,依赖 stage 更可靠。 - 谨慎修改
compilation.assets:直接赋值会绕过缓存与 sourcemap 处理,应使用emitAsset/updateAsset。 - 插件实例要独立:
plugins里每次new,不要在多个配置间复用同一个实例,避免状态串味。
八、小结
Plugin 机制的本质,是 webpack 把构建流程拆解成一条 Tapable 事件流并对外开放:
- 是什么:带
apply(compiler)方法的类实例。 - 能做什么:介入构建全流程,处理产物、优化代码、注入变量。
- 怎么工作:通过
compiler.hooks/compilation.hooks注册监听,在合适时机操作compilation与assets。
理解 compiler(全局)与 compilation(单次)、同步与异步钩子的区别,是写出可靠插件的基础。掌握这些后,无论是改造社区插件还是定制团队专属的构建能力,都不会再觉得神秘。