Loading...

深入理解 Webpack Plugin

2025-06-23 · 21min

Tapable

Tapable 是 Webpack 内部用于实现事件发布订阅的底层库,通过 Tapable 可以注册事件,在不同时机去触发执行我们的注册事件。

它是整个 Webpack 的底层事件流引擎,Webpack 的核心对象 Compiler 和 Compilation 都继承自 Tapable 。

Hooks

Tapable 提供了同步、异步、瀑布流、并行等多种类型的钩子,总共有9个钩子。

Webpack 在 compiler.hooks 和 compilation.hooks 上暴露了大量的钩子。

注册回调

Plugin 通过三种方法,向钩子注册一个回调函数,来干预构建流程的:

  • .tap('pluginName', (arg1, arg2) => {}): 用于同步钩子或基础注册。
  • .tapAsync('pluginName', (arg1, arg2, callback) => { ... }): 用于异步钩子。回调函数接收一个额外的 callback 参数,插件必须在异步操作完成后调用 callback() 或 callback(err) 来通知流程继续。
  • .tapPromise('pluginName', (arg1, arg2) => { ... return promise; }): 用于异步钩子。回调函数必须返回一个 Promise。Webpack 会等待这个 Promise 被 resolve 或 reject 后继续流程。

回调函数中的 arg1 和 arg2 不用严格固定,完全看自己的需求来定多少个都想。.tapAsync 只需要记得最后一个参数用于接收 callback 即可。

触发钩子

当 Webpack 的构建流程执行到某个生命周期时,会通过 call 触发相应的钩子,执行该钩子上所有注册的回调函数。

根据钩子类型不同,触发方法有3种:

  • .call(arg1, arg2):用于触发同步钩子。
  • .callAsync(arg1, arg2, callback):用于触发基于 callback 的异步钩子。
  • .promise(arg1, arg2).then(() => {}).catch(err => {}): 用于触发基于 Promise 的异步钩子。

钩子类型

Tapable 的钩子主要分为同步和异步两类。

同时还可以按照机制分类:

  • 基本钩子:仅执行注册函数,啥也不关心。
  • 瀑布流钩子(Waterfall):会将前一个注册函数的非 undefined 返回值作为下一个注册函数的参数传递。
  • 熔断钩子(Bail):在基础类型钩子上新增一个熔断机制,当任意一个注册函数执行返回了非 undefined 的值时,会中断整个钩子执行过程,后续注册函数都不会执行。
  • 循环钩子(Loop):当任意个一个钩子返回了非 undefined 的值时,会从头开始执行所有注册函数,直到所有注册函数返回 undefined。

根据同步/异步与4类机制组合,总共有9种类型钩子。

SyncHook (同步钩子)

最简单的钩子,同步、顺序地执行所有注册回调函数,不需要返回值。

示例

const { SyncHook } = require('tapable');

const hook = new SyncHook(['arg1', 'arg2']);

hook.tap('plugin2', (arg1, arg2) => {
  console.log('plugin1:', arg1, arg2);
});

hook.tap('plugin2', (arg1, arg2) => {
  console.log('plugin2:', arg1, arg2);
});

hook.call(1,2);

输出

plugin1: 1 2
plugin2: 1 2

用途:不需要中断或修改参数,只需在某个点通知多个监听器的场景。

SyncBailHook(同步熔断钩子)

同步、顺序地执行回调。当某个回调返回了非 undefined 时,则立即停止后续回调,并将返回值作为整个钩子触发调用 call 的返回值。

示例

const { SyncBailHook } = require('tapable');

const hook = new SyncBailHook(['arg1', 'arg2']);

hook.tap('plugin1', (arg1, arg2) => {
  console.log('plugin1:', arg1, arg2);

  return true
});

hook.tap('plugin2', (arg1, arg2) => {
  console.log('plugin2:', arg1, arg2);
});

hook.call(1,2);

输出

plugin1: 1 2

用途: 用于需要条件中断的场景。例如:如果某个插件返回 false,则跳过输出阶段。

SyncWaterfallHook(同步瀑布流钩子)

同步、顺序地执行回调。第一个回调接收初始参数,后续每个回调接受前一个回调的返回值作为第一个参数,并返回一个新值给下一个回调。

最后一个回调的返回值作为整个钩子触发调用的返回值。

SyncWaterfallHook 仅能修改第一个参数的返回值。

示例

const { SyncWaterfallHook } = require('tapable');

const hook = new SyncWaterfallHook(['arg1', 'arg2']);

hook.tap('plugin1', (arg1, arg2) => {
  console.log('plugin1:', arg1, arg2);
  
  return 'plugin1Result';
});

hook.tap('plugin2', (arg1, arg2) => {
  console.log('plugin2:', arg1, arg2);
});

hook.call(1,2);

输出

plugin1: 1 2
plugin2: plugin1Result 2

用途: 用于需要链式处理、逐步修改某个值的场景。例如:处理模块的 source。

SyncLoopHook(同步循环钩子)

同步顺序地执行回调。如果某个钩子返回了非 undefined 的值,则从头开始执行所有回调,直到所有回调都返回 undefined。

示例

const { SyncLoopHook } = require("tapable");

let plugin1Count = 1;
let plugin2Count = 1;

const hook = new SyncLoopHook([]);

hook.tap("plugin1", (_) => {
  console.log(`plugin1: 第${plugin1Count++}次执行`);
  if (plugin1Count < 3) {
    return false
  }
});

hook.tap("plugin2", (_) => {
  console.log(`plugin2: 第${plugin2Count++}次执行`);
  if (plugin2Count < 3) {
    return false;
  }
});

hook.call();

输出

plugin1: 第1次执行
plugin1: 第2次执行
plugin2: 第1次执行
plugin1: 第3次执行
plugin2: 第2次执行

用途: 相对少见。用于需要反复执行直到满足某个条件的场景。

AsyncParalleHook(异步并行钩子)

所有回调同时启动执行。当所有回调执行完毕(调用 callback() 或者 reslove())后,触发最终的 callAsync 回调或者 Promise.reslove。

示例

const { AsyncParallelHook } = require("tapable");

const hook = new AsyncParallelHook(["arg1", "arg2"]);

console.time("timer");

hook.tapPromise("plugin1", (arg1, arg2) => {
  console.log("plugin2:", arg1, arg2);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(true);
    }, 1000);
  });
});

hook.tapAsync("plugin2", (arg1, arg2, callback) => {
  console.log("plugin1:", arg1, arg2);
  setTimeout(() => {
    callback();
  }, 2000);
});

hook.callAsync(1, 2, () => {
  console.log("done");
  console.timeEnd("timer");
});

输出

plugin2: 1 2
plugin1: 1 2
done
timer: 2.006s

用途 用于可以并行执行且不依赖彼此结果的异步操作。例如:同时向多个 CDN 上传文件(如果顺序无关)。

AsyncSeriesHook(异步串行钩子)

串行执行所有异步回调。一个回调执行完成后,才会执行下一个回调。

所有回调都执行完后,触发最终的 callAysnc 回调或者 Promise.reslove。

示例

const { AsyncSeriesHook } = require('tapable');

const hook = new AsyncSeriesHook(['arg1', 'arg2']);

console.time('timer');

hook.tapAsync('plugin1', (arg1, arg2, callback) => {
  console.log('plugin1:', arg1, arg2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.tapPromise('plugin2', (arg1, arg2) => {
  console.log('plugin2:', arg1, arg2);
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 2000);
  });
});

hook.callAsync(1, 2, () => {
  console.log('done');
  console.timeEnd('timer')
});

输出

 plugin1: 1 2
 plugin2: 1 2
 done
 timer: 3.006s

用途: 用于需要按顺序执行的异步操作,大多数涉及 I/O 或需要严格顺序的生命周期钩子都是这种类型。例如:

  • compiler.hooks.run: 启动构建。
  • compiler.hooks.emit :输出资源到目录前。
  • compiler.hooks.done :构建完成。

AsyncParalleBailHook(异步并行熔断钩子)

并行执行所有异步回调,如果任何一个回调抛出错误,则立即调用最终的 callAysnc 回调或者 Promise.reslove,将第一个抛出的错误返回,忽略其他尚未完成或开始的回调。

示例

const { AsyncParallelBailHook } = require("tapable");

const hook = new AsyncParallelBailHook(["arg1", "arg2"]);

console.time("timer");

hook.tapPromise("plugin1", (arg1, arg2) => {
  return new Promise((resolve) => {
    console.log("plugin1:", arg1, arg2);
    setTimeout(() => {
      resolve(true);
    }, 1000);
  });
});

hook.tapAsync("plugin2", (arg1, arg2, callback) => {
  setTimeout(() => {
    console.log("plugin2:", arg1, arg2);
    callback();
  }, 3000);
});

hook.callAsync(1, 2, () => {
  console.log("done");
  console.timeEnd("timer");
});

输出

plugin1: 1 2
done
timer: 1.008s
plugin2: 1 2

用途: 用于并行执行但需要快速失败的场景(如并行校验,一个失败则整体失败)。

AsyncSeriesBailHook(异步串行熔断钩子)

串行执行所有异步回调。如果某个异步回调抛出错误时,则立即调用最终的 callAysnc 回调或者 Promise.reslove,该错误或非 undefined 值被传递出去。

示例

const { AsyncSeriesBailHook } = require("tapable");

const hook = new AsyncSeriesBailHook(["arg1", "arg2"]);

console.time("timer");

hook.tapPromise("plugin1", (arg1, arg2, callback) => {
  console.log("plugin1:", arg1, arg2);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(true);
    }, 1000);
  });
});

hook.tapAsync("plugin2", (arg1, arg2, callback) => {
  console.log("plugin2:", arg1, arg2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.callAsync(1, 2, () => {
  console.log("done");
  console.timeEnd("timer");
});

输出

plugin1: 1 2
done
timer: 1.008s

用途: 用于需要按顺序执行且可能提前失败的异步操作链。例如:compiler.hooks.failed 可能涉及一些清理操作,但一个失败可能跳过后续清理。

AsyncSeriesWaterfallHook(异步串行瀑布流钩子)

串行执行所有异步回调。

第一个回调接收初始参数,后续每个回调接收前一个回调通过 callback(null, nextValue) 或者 Promise.reslove(nextValue) 传递过来的值作为第一个参数,并可以处理传递给下一个回调函数。

最后一个回调的结果传递给最终的 callAsync 回调或 Promise.reslove。

示例

const { AsyncSeriesWaterfallHook } = require("tapable");

const hook = new AsyncSeriesWaterfallHook(["arg1", "arg2"]);

console.time("timer");

hook.tapPromise("plugin1", (arg1, arg2) => {
  console.log("plugin1:", arg1, arg2);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(true);
    }, 1000);
  });
});

hook.tapAsync("plugin2", (arg1, arg2, callback) => {
  console.log("plugin2:", arg1, arg2);
  setTimeout(() => {
    callback();
  }, 1000);
});

hook.callAsync(1, 2, () => {
  console.log("done");
  console.timeEnd("timer");
});

输出

plugin1: 1 2
plugin2: true 2
done
timer: 2.006s

用途:用于需要按顺序执行且每一步都依赖上一步结果的异步操作链。例如:链式处理请求或资源转换。

Compiler && Compilation

Complier 和 Compilation 是 Webpack 的两个核心对象,他们都继承了 Tapable 类,在掌握 Plugin 之前要先知道这两个对象。

Compiler(编译器)

特点:

  • 代表不变的 Webpack 环境。
  • 从启动到结束只存在一个实例。
  • 持有完整的配置 compiler.options。
  • 负责启动构建( compiler.run /compiler.watch)。
  • 暴露生命周期钩子
  • 可以创建 Compilation 对象。

生命周期

Compiler 的生命周期主要是以下阶段:

初始化 → 启动构建 → 编译准备 → 模块处理 → 优化阶段 → 资源生成 → 收尾阶段

每个阶段都有许多钩子供开发者调用,[文档]https://www.webpackjs.com/api/compiler-hooks/#hooks光有数量,也没详细介绍。

只需要掌握常用的钩子即可:

  • compilation: SyncHook 类型。 Compilation 对象创建后执行。用于注册子钩子,修改模块行为。
  • emit: AsyncSeriesHook 类型。 输出 asset 到 output 目录之前执行。用于对最终资源进行最终的增删改。
  • afterEmit: AsyncSeriesHook 类型。
    输出 asset 到 output 目录之后执行。用于对已写入磁盘的资源进行处理,例如上传CDN。
  • done: AsyncSeriesHook 类型。
    构建完成时执行,用于分析报告/通知。
  • failed:
    SyncHook 类型
    构建失败时执行,用于错误上报/清理。

Compilation (编译)

特点:

  • 代表一次资源的构建过程。
  • 每次构建,包括 watch 模式下的每次文件改动,都会创建一个新的 Compilation 对象。
  • 持有当前构建过程中的模块依赖图、chunk 图、生成资源等信息。
  • 暴露模块和处理 chunk 细节的钩子。
  • 可以操作模块和 chunk。
  • 插件通过 compiler.hooks.thisCompilation 或者 compiler.hooks.compilation 获取 compilation 对象实例。

Hook

一样只需要掌握比较常用的钩子即可了:

  • processAssets
    AsyncSeriesHook 类型。
    资源处理阶段执行,用于操作最终输出资源。
    compilation.hooks.processAssets.tap(
      {
        name: 'MyPlugin',
        // 控制执行阶段(重要!)
        stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
      },
      (assets) => {
        // ... 资源操作
      }
    );
    
  • optimizeChunksSyncBailHook 类型。 在优化 Chunks 阶段执行,用于对 chunks 进行代码分割等操作。
    compilation.hooks.optimizeChunks.tap('MyPlugin', (chunks) => {
      // ...操作 chunks
    });
    
  • buildModuleSyncHook 类型。
    在模块开始构建前执行,用于对模块的预处理,每个模块都会单独执行一次。
    compilation.hooks.buildModule.tap('MyPlugin', (module) => {
      // ...操作 module
    });
    
  • succeedModuleSyncHook 类型。 在模块成功构建后执行,对处理后的模块进行操作,每个处理后的模块都会单独执行一次。
    compilation.hooks.succeedModule.tap('MyPlugin', (module) => {
      // ... 操作 module
    });
    
  • finishModulesAsyncSeriesHook 类型。 所有模块构建完成后执行,用于对全局模块进行分析操作。
    compilation.hooks.finishModules.tapAsync('MyPlugin', (modules, callback) => {
      // ... 分析 modules
    
      callback();
    });
    
  • afterOptimizeChunkAssetsSyncHook 类型。 Chunk 资源完成优化后执行,用于对最终资源进行调整。
    compilation.hooks.afterOptimizeChunkAssets.tap('MyPlugin', (chunks) => {
      // ...最终资源调整
    });
    
  • needAdditionalSealSyncBailHook 类型。 判断是否需要额外优化,用于优化控制。
    compilation.hooks.needAdditionalSeal.tap('MyPlugin', () => {
      // 根据条件判断是否需要重新优化
      if (someCondition) {
        return true; // 触发重新优化
      }
    });
    

注意:

  • 不在钩子中存储对 compilation 的长期引用,避免内存泄露。
  • 在频繁触发的钩子(buildModule)中避免复杂操作,性能优先。
  • 合理使用 cache 提高构建性能。

Plugin

Plugin 的本质,就是向特定钩子上注册事件处理函数的函数或者包含 apply 方法的对象。通常来说会是一个包含 apply 方法的类。

class MyPlugin {
  /**
   * apply 方法是插件的入口点。
   * @param {object} compiler - Webpack 的 Compiler 实例,代表了完整的配置环境。
   */
  apply(compiler) {
    // 1. 访问 Compiler 提供的钩子
    // 2. 在感兴趣的钩子上注册回调函数
    compiler.hooks.someHook.tap('MyPlugin', (params) => {
      // 3. 在回调函数中执行你的插件逻辑
      console.log('Webpack is doing something!', params);
      // ... 你的自定义操作 ...
    });

    // 可以注册到多个钩子
    compiler.hooks.anotherHook.tapAsync(
      'MyPlugin',
      (params, callback) => {
        // 异步操作示例
        setTimeout(() => {
          console.log('Async operation done!');
          callback(); // 必须调用 callback 通知 Webpack 异步操作完成
        }, 1000);
      }
    );
  }
}

module.exports = MyPlugin;

Plugin 通过 apply(compiler) 获取 Compiler 对象实例,Webpack 会在启动遍历调用所有配置的插件的 apply 方法。

Plugin 开发

Plugin 开发首先需要明确自己的需求。

之前自定义了 一个 Loader,用于将指定目录的 SVG 转成 CSS 类,但存在个问题:需要显式导入 SVG 文件才能触发 Loader。

原本想着用那就写个插件,自动导入吧,但是转念一想,不如把整个功能用 Plugin 写完好了。

明确需求

  • 根据指定目录 SVG 生成 CSS 文件。
  • 将 CSS 文件自动导入项目中。
  • SVG 文件变化时重新生成。

具体实现

新建 Svg2CssPlugin.js,插件类基本内容如下:

const { createHash } = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { Compiler, Compilation, sources } = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const chokidar = require("chokidar"); // 添加文件监听库
const PLUGIN_NAME = "Svg2CssPlugin";

class Svg2CssPlugin {
  constructor(options = {}) {
    this.options = {
      iconDir: "src/icons",
      prefix: "icon-",
      outputFile: "icons.css", // 添加输出文件名选项
      ...options,
    };

    this.generatedCss = "";
    this.cachedHash = "";
    this.isDev = false;
  }

  /**
   * 
   * @description CSS 类模板字符串
   * @param {string} className 
   * @param {string} svgData 
   * @returns 
   */
  defaultCssTemplate(className, svgData) {
    return `
.${className} {
    display: inline-block;
    mask: url("data:image/svg+xml;base64,${svgData}") no-repeat center;
    mask-size: contain;
    width: 1em;
    height: 1em;
    vertical-align: middle;
    background-color: currentColor;
}`;
  }

  /**
   *
   * @param {Compiler} compiler
   */
  apply(compiler) {
    
  }

  
}

module.exports = Svg2CssPlugin;

读取 SVG 生成 CSS 内容

第一步当然是要读取指定目录,并生成 CSS 内容。这一步骤我打算初始化时先现生成好,存在插件类当中,查阅文档可以找到 beforeCompile 这个钩子:

  /**
   *
   * @param {Compiler} compiler
   */
  apply(compiler) {
    // 初始化时生成CSS
    compiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (compiler, callback) => {
      this.generateCssContent()
        .then(() => {
          console.log("css content has generated");
          callback();
        })
        .catch((error) => {
          console.error(`css generating is wrong: ${error.message}`);
          callback();
        });
    });
  }
  
  /**
   * @description 生成CSS 内容
   * @param {Compilation} context
   */
  async generateCssContent() {
    const { iconDir, prefix } = this.options;
    const absIconDir = path.resolve(process.cwd(), iconDir);

    if (!fs.existsSync(absIconDir)) {
      console.warn(`[${PLUGIN_NAME}] SVG directory not found: ${absIconDir}`);
      return;
    }

    const files = fs
      .readdirSync(absIconDir)
      .filter((file) => path.extname(file).toLowerCase() === ".svg");

    if (files.length === 0) {
      return;
    }

    let cssContent = `/* Auto-generated by ${PLUGIN_NAME} */\n\n`;
    let hashInput = "";

    for (const file of files) {
      const filePath = path.join(absIconDir, file);
      const className = `${prefix}${path
        .basename(file, ".svg")
        .replace(/\s/g, "-")}`;

      try {
        const svgBuffer = fs.readFileSync(filePath);
        const processedSvg = svgBuffer.toString("base64");

        cssContent += this.defaultCssTemplate(className, processedSvg) + "\n\n";
        hashInput += `${filePath}:${fs.statSync(filePath).mtimeMs}`;
      } catch (error) {
        console.error(
          `[${PLUGIN_NAME}] Error processing ${file}: ${error.message}`
        );
      }
    }

    // 计算内容哈希值
    const newHash = createHash("md5").update(hashInput).digest("hex");

    // 仅当内容变化时更新
    if (newHash !== this.cachedHash) {
      this.generatedCss = cssContent;
      this.cachedHash = newHash;
      console.log(
        `[${PLUGIN_NAME}] CSS updated with hash: ${newHash.substring(0, 8)}`
      );
    }
  }

生成的内容会生成一个哈希值进行缓存,减少不必要更新。

处理资源生成

有了 CSS 内容后,就需要想办法把它添加到项目当中,我希望是可以通过以 CSS 文件的方式在入口 HTML 中进行引入的。

这种属于产生额外资产的操作,当然就得在编译阶段执行执行了:

  /**
   *
   * @param {Compiler} compiler
   */
  apply(compiler) {
    this.isDev = compiler.options.mode === "development";
    
    // ...初始化生成 CSS
    
    // 处理资源生成
    compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: PLUGIN_NAME,
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
        },
        () => {
          const output = this.getOutputFileName();

          compilation.emitAsset(
            output,
            new sources.RawSource(this.generatedCss)
          );
        }
      );

      // 添加到HTML
      HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
        PLUGIN_NAME,
        (htmlData) => {
          const output = this.getOutputFileName();
          htmlData.assets.css.push(output);
          return htmlData;
        }
      );
    });
  }

  /**
   * @description 获取输出文件名
   */
  getOutputFileName() {
    if (this.isDev) {
      return this.options.outputFile;
    }
    return `icon.${this.cachedHash.substring(0, 8)}.css`;
  }

通过 compilation.emitAsset 创建新的 CSS 文件,然后通过 HtmlWebpackPlugin 将其注入到入口 HTML 当中。

文件监听

文件监听通过之前提到了 chokidar 来实现:

  /**
   *
   * @param {Compiler} compiler
   */
  apply(compiler) {
    // ...生成 CSS 内容
    
    //...处理资源生成
    
    // 开发模式下设置文件监听
    if (this.isDev) {
      // 启动文件监听
      compiler.hooks.watchRun.tap(PLUGIN_NAME, (compiler) => {
        if (!this.watcher) {
          this.setupWatcher(compiler);
        }
      });

      // 清理监听器
      compiler.hooks.watchClose.tap(PLUGIN_NAME, () => {
        if (this.watcher) {
          this.watcher.close();
          this.watcher = null;
        }
      });
    }
  }

  /**
   * @description 设置文件监听器
   */
  setupWatcher(compiler) {
    const iconDir = path.resolve(compiler.context, this.options.iconDir);

    this.watcher = chokidar.watch(iconDir, {
      ignored: /(^|[\/\\])\../, // 忽略点文件
      persistent: true,
      ignoreInitial: true,
    });

    this.watcher
      .on("add", (path) => this.handleFileChange(compiler, path))
      .on("change", (path) => this.handleFileChange(compiler, path))
      .on("unlink", (path) => this.handleFileChange(compiler, path))
      .on("error", (error) => console.error(`Watcher error: ${error}`));
  }

运行效果:

自定义 Plugin 运行效果

总结

其实 Webpack Plugin 开发还是蛮多坑的,文档的粗糙,钩子数量庞大等。

最关键的是Webpack 核心架构的问题:任何资源变更都会触发完整的新一轮 compilation 过程。即使使用 chokidar 精确监听 SVG 目录,依然无法避免 Webpack 的全局重编译机制。

这也是 Webpack 无法实现真正的局部更新的问题,也导致了插件执行成本高昂的问题。

这段时间查阅文档、搜索资料,让我更加深刻理解了 Webpack 的缺陷问题,虽然 Webpack 的生态确实庞大,但有些情况下,本质是用复杂方案解决架构缺陷。

我并不鼓励去过度深入学习 Webpack,除非工作项目一定要求使用 Webpack。

如今已经2025年了,对 Webpack 的掌握,只需要知道他的核心概念以及原理,能够进行优化即可。