创见博客
Webpack Plugin 详解:作用、原理与内部钩子全解析
七崽爱吃小饼干2026/09/20阅读 0

Webpack 的核心定位是一个「打包器」:它把散落的模块按依赖关系拼装成产物。但真实项目里,我们还需要生成 HTML、抽取 CSS、压缩代码、注入全局变量、拷贝静态资源……这些事情 loader 做不了,因为它们只作用于「单个文件的转换」,不掌握整个构建流程的全局信息。

这时就该 Plugin(插件) 出场了。本文从「是什么、能做什么、怎么工作」三个层面,把 webpack 插件机制一次讲透,最后手写一个插件把知识落到代码上。

一、Plugin 是什么

一句话概括:Plugin 是一段带有 apply 方法的代码,它通过监听 webpack 构建过程中抛出的钩子(hook),在特定时机执行自定义逻辑。

一个最简插件长这样:

js
class HelloPlugin {
  apply(compiler) {
    console.log('HelloPlugin 被使用了');
  }
}

module.exports = HelloPlugin;

配置里用 plugins 数组引入:

js
const HelloPlugin = require('./plugins/hello-plugin');

module.exports = {
  plugins: [new HelloPlugin()],
};

注意这里 new HelloPlugin(),插件是一个实例,不是函数。webpack 启动时会遍历 plugins 数组,依次调用每个实例的 apply(compiler),把 compiler 对象交给插件。

Plugin 与 Loader 的区别

维度LoaderPlugin
作用对象单个模块文件整个构建流程
运行时机模块加载、转换阶段从启动到产物落盘的各个生命周期
关注点「这个文件怎么转成 JS」「在某个阶段做点什么」
配置位置module.rulesplugins
形态导出函数的模块带 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:

js
// 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异步串行,可传递返回值

对应的注册方式也不同:

js
// 同步钩子用 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 进程中只创建一次,代表完整的构建生命周期,包括启动、编译、监听、退出。常用钩子:

钩子类型触发时机
environmentSyncHook创建环境,插件可读取配置前的最后时机
afterEnvironmentSyncHook环境创建完成
initializeSyncHookcompiler 初始化完成
runAsyncSeriesHook开始编译前
watchRunAsyncSeriesHook监听模式下每次重新编译前
compileSyncHook一次新的编译开始前
thisCompilationSyncHook创建 compilation 时
compilationSyncHook创建 compilation 后
makeAsyncParallelHook从 entry 开始递归构建模块前
afterCompileAsyncSeriesHook编译完成
shouldEmitSyncBailHook判断是否需要输出产物
emitAsyncSeriesHook生成产物到输出目录前(可修改文件)
assetEmittedAsyncSeriesHook单个产物写入磁盘后
afterEmitAsyncSeriesHook产物写入磁盘后
doneAsyncSeriesHook编译全部完成
failedSyncHook编译失败
watchCloseSyncHook监听模式结束
shutdownAsyncSeriesHook编译器关闭

4.2 compilation.hooks —— 单次编译

compilation 在每次编译时都会重新创建,代表「一次编译的产物」——包含所有模块、chunk、asset 以及它们之间的关系。常用钩子:

钩子类型触发时机
buildModuleSyncHook模块开始构建前
succeedModuleSyncHook模块构建成功
finishModulesAsyncSeriesHook所有模块构建完成
sealSyncHook编译开始封装(不再接收新模块)
optimizeSyncHook开始优化
optimizeModulesSyncHook优化模块
optimizeChunksSyncHook优化 chunk
optimizeTreeAsyncSeriesHook优化依赖树
processAssetsAsyncSeriesHook处理产物资源(webpack 5 推荐,替代旧的 emit 系列)
afterOptimizeAssetsSyncHook产物优化完成
emitAsyncSeriesHook生成产物前
afterEmitAsyncSeriesHook产物生成后
doneSyncHook编译结束
additionalAssetsAsyncSeriesHook添加额外资源

选择原则:需要跨多次编译存活的资源、或读取整体配置,用 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 等:

js
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(异步串行)。

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

配置:

js
const BuildTimePlugin = require('./plugins/build-time-plugin');

module.exports = {
  // ...其他配置
  plugins: [new BuildTimePlugin()],
};

执行 npm run build,就能在控制台看到耗时。这个例子虽然简单,却完整走通了插件的标准流程:实例化 → 调用 apply → 注册钩子 → 在回调里操作构建数据。

进阶:生成一个 version 文件

下面这个插件在每个产物旁边额外生成一个 version.json,用到 compilation.hooks.processAssets 和 sources.RawSource:

js
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 事件流并对外开放:

  1. 是什么:带 apply(compiler) 方法的类实例。
  2. 能做什么:介入构建全流程,处理产物、优化代码、注入变量。
  3. 怎么工作:通过 compiler.hooks / compilation.hooks 注册监听,在合适时机操作 compilation 与 assets。

理解 compiler(全局)与 compilation(单次)、同步与异步钩子的区别,是写出可靠插件的基础。掌握这些后,无论是改造社区插件还是定制团队专属的构建能力,都不会再觉得神秘。

评论
0/100