上一篇把 plugin 的原理和内部钩子讲清楚了,这一篇只谈一件事:plugin 到底怎么写。从最小结构开始,一步步补上钩子注册、异步处理、读取配置、操作产物,最后手写两个能直接用的插件。
一、最小结构
一个 webpack plugin 就是一个**「带有 apply 方法的类」**:
class MyPlugin {
apply(compiler) {
console.log('my plugin 已生效');
}
}
module.exports = MyPlugin;
在配置里 new 出实例,放进 plugins 数组:
const MyPlugin = require('./plugins/my-plugin');
module.exports = {
entry: './src/index.js',
output: { path: require('path').resolve(__dirname, 'dist') },
plugins: [new MyPlugin()],
};
webpack 启动后会遍历所有插件实例,逐个调用 apply(compiler),把 compiler 交给插件。所以:
- 类负责保存状态(配置项、缓存等);
apply是唯一入口,用来注册钩子;- 钩子回调里才是真正的业务逻辑。
二、注册钩子:tap
插件通过 compiler.hooks.xxx.tap(name, callback) 监听生命周期。第一个参数是插件名(用于调试定位),第二个参数是回调:
class MyPlugin {
apply(compiler) {
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('构建完成,耗时', stats.endTime - stats.startTime, 'ms');
});
}
}
同步钩子只能用 tap。遇到异步钩子,还有两种写法:
// 回调风格
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
doAsyncWork(() => callback());
});
// Promise 风格
compiler.hooks.emit.tapPromise('MyPlugin', async (compilation) => {
await doAsyncWork();
});
tapAsync必须调用callback(),否则构建会一直挂着;tapPromise必须返回 Promise。这是新手最常踩的坑。
三、拿到 compilation
compiler 只提供全局阶段,真正操作模块和产物要靠 compilation。它由 compilation 钩子给出:
class MyPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
console.log('本次编译涉及模块数', compilation.modules.size);
});
}
}
更推荐的做法是监听 thisCompilation,它比 compilation 触发更早,可以更早地注册 compilation.hooks:
class MyPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('MyPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
(assets) => {
// 在这里读写产物
}
);
});
}
}
四、操作产物
产物不是字符串,而是带 source() 方法的资源对象。webpack 把它们放在 compilation.assets 里,推荐用 emitAsset / updateAsset / getAsset 来读写,而不是直接操作 assets 对象。
新增一个文件
const { sources } = compiler.webpack;
compilation.emitAsset(
'info.txt',
new sources.RawSource('hello webpack')
);
修改已有文件
const asset = compilation.getAsset('bundle.js');
const oldContent = asset.source.source(); // 拿到字符串
const newContent = `/* banner */\n${oldContent}`;
compilation.updateAsset(
'bundle.js',
new sources.RawSource(newContent)
);
处理所有 JS 产物
for (const name of Object.keys(compilation.assets)) {
if (!name.endsWith('.js')) continue;
const source = compilation.assets[name].source();
// ...
}
五、接收配置项
插件通过构造函数接收 options,这是社区插件的标准做法:
class BannerPlugin {
constructor(options = {}) {
this.banner = options.banner || '';
this.test = options.test || /\.js$/;
}
apply(compiler) {
// 在回调里通过 this.banner / this.test 使用配置
}
}
使用方:
plugins: [
new BannerPlugin({ banner: '© 2026 my team' }),
];
默认值建议在构造函数里统一处理,避免回调里到处判空。
六、完整示例一:给 JS 产物加 banner
把前面的知识串起来,写一个 BannerPlugin:
const { sources } = require('webpack');
class BannerPlugin {
constructor(options = {}) {
this.banner = options.banner || '';
this.test = options.test || /\.js$/;
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('BannerPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'BannerPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
for (const name of Object.keys(assets)) {
if (!this.test.test(name)) continue;
const original = compilation.getAsset(name).source.source();
compilation.updateAsset(
name,
new sources.RawSource(`${this.banner}\n${original}`)
);
}
}
);
});
}
}
module.exports = BannerPlugin;
效果:
/* © 2026 my team */
(()=>{"use strict";/* ... */})();
七、完整示例二:生成 manifest.json
再写一个更实用的插件:构建结束后生成 manifest.json,记录产物文件名和大小。
const { sources } = require('webpack');
class ManifestPlugin {
constructor(options = {}) {
this.filename = options.filename || 'manifest.json';
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('ManifestPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'ManifestPlugin',
// 用 REPORT 阶段保证所有产物都已生成
stage:
compiler.webpack.Compilation
.PROCESS_ASSETS_STAGE_REPORT,
},
(assets) => {
const manifest = {};
for (const [name, asset] of Object.entries(assets)) {
manifest[name] = asset.source.size();
}
compilation.emitAsset(
this.filename,
new sources.RawSource(
JSON.stringify(manifest, null, 2)
)
);
}
);
});
}
}
module.exports = ManifestPlugin;
配置:
plugins: [
new BannerPlugin({ banner: '© 2026 my team' }),
new ManifestPlugin(),
],
构建后 dist/ 里会多出一个 manifest.json:
{
"bundle.js": 1234,
"manifest.json": 38
}
八、为什么用 processAssets 而不是 emit
webpack 5 里 compilation.hooks.emit 仍存在,但处理产物已统一推荐 processAssets。原因是它提供了一系列 stage 常量,让多个插件改同一个文件时顺序可控:
| stage 常量 | 用途 |
|---|---|
PROCESS_ASSETS_STAGE_ADDITIONAL | 新增额外资源 |
PROCESS_ASSETS_STAGE_ADDITIONS | 在既有产物上追加内容 |
PROCESS_ASSETS_STAGE_OPTIMIZE | 优化资源 |
PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE | 体积优化(压缩) |
PROCESS_ASSETS_STAGE_DEV_TOOLING | 生成 sourcemap 等 |
PROCESS_ASSETS_STAGE_REPORT | 只读统计,最后执行 |
规则:越靠后的 stage 越晚执行。新增文件用 ADDITIONAL,改内容用 ADDITIONS,做统计用 REPORT,这样即使插件执行顺序不确定,结果也符合预期。
九、注意事项
- 钩子类型要对:
Sync*只能tap;Async*可用tap/tapAsync/tapPromise。 - 异步必须收尾:
tapAsync记得callback(),tapPromise记得return。 - 优先级用 stage 控制,不要依赖插件的注册顺序。
- 不要直接改
compilation.assets:用emitAsset/updateAsset,否则会绕过缓存和 sourcemap 处理。 - 插件实例独立:
plugins里每次new,不要跨配置复用实例,避免this上的状态串味。 - 回调里的
this:用箭头函数保留构造函数的this,否则需要自行bind。
十、小结
写 plugin 有一套固定套路:
- 定义一个类,构造函数接收 options;
- 实现
apply(compiler),用tap注册钩子; - 在
thisCompilation里拿到compilation; - 用
processAssets配合合适的 stage 读写产物; - 用
emitAsset/updateAsset提交结果。
记住这个骨架,再结合上一篇的生命周期钩子表,你就能按需把逻辑挂到正确的位置,写出实用的 webpack 插件了。