创见博客
Webpack Plugin 基本写法:从零手写两个实用插件
七崽爱吃小饼干2026/09/20阅读 0专栏 webpack原理

上一篇把 plugin 的原理和内部钩子讲清楚了,这一篇只谈一件事:plugin 到底怎么写。从最小结构开始,一步步补上钩子注册、异步处理、读取配置、操作产物,最后手写两个能直接用的插件。

一、最小结构

一个 webpack plugin 就是一个**「带有 apply 方法的类」**:

js
class MyPlugin {
  apply(compiler) {
    console.log('my plugin 已生效');
  }
}

module.exports = MyPlugin;

在配置里 new 出实例,放进 plugins 数组:

js
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) 监听生命周期。第一个参数是插件名(用于调试定位),第二个参数是回调:

js
class MyPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成,耗时', stats.endTime - stats.startTime, 'ms');
    });
  }
}

同步钩子只能用 tap。遇到异步钩子,还有两种写法:

js
// 回调风格
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 钩子给出:

js
class MyPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
      console.log('本次编译涉及模块数', compilation.modules.size);
    });
  }
}

更推荐的做法是监听 thisCompilation,它比 compilation 触发更早,可以更早地注册 compilation.hooks:

js
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 对象。

新增一个文件

js
const { sources } = compiler.webpack;

compilation.emitAsset(
  'info.txt',
  new sources.RawSource('hello webpack')
);

修改已有文件

js
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 产物

js
for (const name of Object.keys(compilation.assets)) {
  if (!name.endsWith('.js')) continue;
  const source = compilation.assets[name].source();
  // ...
}

五、接收配置项

插件通过构造函数接收 options,这是社区插件的标准做法:

js
class BannerPlugin {
  constructor(options = {}) {
    this.banner = options.banner || '';
    this.test = options.test || /\.js$/;
  }

  apply(compiler) {
    // 在回调里通过 this.banner / this.test 使用配置
  }
}

使用方:

js
plugins: [
  new BannerPlugin({ banner: '© 2026 my team' }),
];

默认值建议在构造函数里统一处理,避免回调里到处判空。

六、完整示例一:给 JS 产物加 banner

把前面的知识串起来,写一个 BannerPlugin:

js
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;

效果:

js
/* © 2026 my team */
(()=>{"use strict";/* ... */})();

七、完整示例二:生成 manifest.json

再写一个更实用的插件:构建结束后生成 manifest.json,记录产物文件名和大小。

js
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;

配置:

js
plugins: [
  new BannerPlugin({ banner: '© 2026 my team' }),
  new ManifestPlugin(),
],

构建后 dist/ 里会多出一个 manifest.json:

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 有一套固定套路:

  1. 定义一个类,构造函数接收 options;
  2. 实现 apply(compiler),用 tap 注册钩子;
  3. 在 thisCompilation 里拿到 compilation;
  4. 用 processAssets 配合合适的 stage 读写产物;
  5. 用 emitAsset / updateAsset 提交结果。

记住这个骨架,再结合上一篇的生命周期钩子表,你就能按需把逻辑挂到正确的位置,写出实用的 webpack 插件了。

评论
0/100