Webpack5插件机制:从Tapable到完整构建流程
发布日期: 2026/08/14 阅读总量: 1

一个让构建直接挂掉的上线事故

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.jsHookCodeFactory.jsSyncHook.jsAsyncSeriesHook.js 等。

钩子的9种类型

Tapable定义了9种钩子,分成三大类:

类型钩子类名特点
同步SyncHook串行执行所有tap回调,不关心返回值
同步SyncBailHook串行执行,任一回调返回非undefined则中断后续
同步SyncWaterfallHook串行执行,上一个回调的返回值作为下一个的入参
同步SyncLoopHook循环执行,直到所有回调都返回undefined
异步AsyncParallelHook并行执行所有异步回调,全部完成后触发finally
异步AsyncParallelBailHook并行执行,任一回调出错则整体失败
异步AsyncSeriesHook串行执行异步回调(可await),一个完成后再执行下一个
异步AsyncSeriesBailHook串行执行,任一回调返回非undefined则跳过后面的
异步AsyncSeriesWaterfallHook串行执行,上一个回调的返回值传递给下一个

Webpack5的Compiler和Compilation中,全局钩子基本是 SyncHookAsyncSeriesHook,模块级钩子用的是 AsyncSeriesHookAsyncParallelHook

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+,但真正需要插件开发者关心的,主要是下面这些:

钩子类型触发时机实际应用场景
buildModuleSyncHook模块开始构建前统计模块构建时长、拦截指定模块做特殊处理
succeedModuleSyncHook模块构建成功收集成功构建的模块列表
failedModuleSyncHook模块构建失败定位构建失败的模块
finishModulesAsyncSeriesHook所有模块构建完成对全部模块做统计分析
sealSyncHook开始生成chunk前此时所有模块已就位,可以修改module结构
optimizeChunksSyncBailHook优化chunk阶段合并/拆分chunk的自定义逻辑
optimizeAssetsAsyncSeriesHook优化资源阶段压缩代码、tree-shake补充
processAssetsAsyncSeriesHook资源处理阶段修改/删除/新增输出文件(核心钩子,后面细讲)
additionalAssetsAsyncSeriesHookprocessAssets后追加生成额外资源
needAdditionalSealSyncBailHook判断是否需要重新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.0tapable@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-pluginwebpack-bundle-analyzer,10分钟看完再决定要不要造轮子

写插件前先想清楚:你要操作的阶段、你要读取的数据、你要产出的结果。然后拿着本文的钩子表去查对应的事件名,代码结构照着上面几个demo搭就行。