【每日一面】webpack plugin 原理

【每日一面】webpack plugin 原理

是大神呀 2 2025-11-24

基础问答

问:什么是 Webpack Plugin?它的核心作用是什么?

答:Webpack Plugin 是 Webpack 插件系统,本质是一个实现了 apply 方法的 JavaScript 类或函数。Plugin 通过 Webpack 提供的构建生命周期的回调钩子介入构建全流程(如初始化配置、模块编译、资源输出等),实现代码压缩、资源生成、环境注入、打包分析等需求。

扩展延伸

我们知道 Loader 的局限性,只是对模块的内容进行转换,做的是一个预处理的工作,更复杂的工作 Loader 就没有办法处理了,这时候就由 Plugin 接手,处理这些 Loader 无法处理的任务。

核心 API

Plugin 的能力主要是 Compiler/Compilation 这两个对象提供的:

  • Compiler 对象:整个 Webpack 构建过程中仅一个,进行全局的配置管理、生命周期钩子调度等。

    • options :包含 Webpack 的完整配置,如 output.path, entry 等
    • hooks :Webpack 全生命周期的 Hooks,如 done,emit 等

除了普通的 API,Compiler 对象还挂载了文件系统操作,用于去读写文件。

  • Compilation 对象:每次触发编译都会生成一个,用于对模块进行处理。

    • assets​:当前编译的所有输出资源(key 为文件名,value 为资源对象,含 source()​ 方法获取内容、size() 方法获取大小)
    • modules​:当前编译的所有模块(每个模块含 rawRequest​ 原始路径、source()​ 源码、dependencies 依赖)
    • errors/warnings:编译错误 / 警告集合,可以手动添加错误阻断构建

这里提供一个示例方便你去理解这些 API:

class BuildLoggerPlugin {
  constructor() {
    this.compilationCount = 0;
    this.compilerInitialized = false;
    this.getUniqueId = () => Math.random().toString(36).substring(2, 10);
  }

  apply(compiler) {
    // 1. Compiler 全局唯一(仅初始化1次)
    if (!this.compilerInitialized) {
      this.compilerInitialized = true;
      const compilerId = this.getUniqueId();
      console.log(`\n📌 Compiler 全局实例初始化完成`);
      console.log(`- Compiler 实例ID:${compilerId}`);
      console.log(`- 全局输出目录:${compiler.options.output.path}`);
      console.log(`- 构建模式:${compiler.options.mode}`);
    }

    // 2. Compilation 单次编译(每次编译创建新实例)
    compiler.hooks.compilation.tap('BuildLoggerPlugin', (compilation) => {
      this.compilationCount++;
      const compilationId = this.getUniqueId();
      console.log(`\n🔄 第 ${this.compilationCount} 次编译 - Compilation 实例创建`);
      console.log(`- Compilation 实例ID:${compilationId}`);
      console.log(`- 编译触发时间:${new Date().toLocaleTimeString()}`);
    });
 
    // 作用完全一致:资源输出前执行,且能访问当前编译的 compilation 资源
    compiler.hooks.emit.tapAsync('BuildLoggerPlugin', (compilation, callback) => {
      const outputAssets = Object.keys(compilation.assets);
      console.log(`- 本次编译输出资源数:${outputAssets.length} 个`);
      console.log(`- 输出资源列表:${outputAssets.join(', ')}`);
      callback(); // 必须调用,告知异步完成
    });

    // 3. Webpack 进程结束日志
    compiler.hooks.done.tap('BuildLoggerPlugin', () => {
      console.log(`\n✅ Webpack 构建进程结束`);
      console.log(`- Compiler 全局实例是否唯一:${this.compilerInitialized && this.compilationCount >= 1}`);
      console.log(`- 本次进程总编译次数:${this.compilationCount} 次`);
    });
  }
}

module.exports = BuildLoggerPlugin;

首次编译后,这里的输出内容为:

📌 Compiler 全局实例初始化完成
- Compiler 实例ID:7zvooaxn
- 全局输出目录:D:\project\blog-demo\webpack-plugin\dist
- 构建模式:development

🔄 第 1 次编译 - Compilation 实例创建
- Compilation 实例ID:5q8rg7pn
- 编译触发时间:11:13:38
- 本次编译输出资源数:1 个
- 输出资源列表:bundle.js

✅ Webpack 构建进程结束
- Compiler 全局实例是否唯一:true
- 本次进程总编译次数:1 次

更新代码之后,输出信息为:

🔄 第 2 次编译 - Compilation 实例创建
- Compilation 实例ID:pxnldrys
- 编译触发时间:11:16:36
- 本次编译输出资源数:3 个
- 输出资源列表:bundle.js, main.911132b746590bcf5394.hot-update.js, main.911132b746590bcf5394.hot-update.json

✅ Webpack 构建进程结束
- Compiler 全局实例是否唯一:true
- 本次进程总编译次数:2 次

从编译结果中,也可以看到 Compiler 全程仅初始化一次,Compilation 则是在每次编译时都会创建一个新的实例(两次创建的实例 ID 不一样)。

常用 Hooks 列表

钩子名称所属对象钩子类型触发时机核心用途注册方式
entryOptionCompilerSyncHookWebpack 初始化完成,入口配置确定后修改入口配置、初始化全局参数、开启缓存tap
beforeRunCompilerAsyncSeriesHook构建开始前(仅首次构建触发,热更新不触发)执行前置异步操作(如请求远程配置、初始化工具)tapPromise
runCompilerAsyncSeriesHook构建正式开始(编译模块前)启动构建日志、初始化第三方工具(如代码校验工具)tapAsync
thisCompilationCompilerSyncHook当前编译实例(Compilation)创建后,未添加模块前初始化单次编译的资源、注册模块相关钩子(如 succeedModule)tap
compilationCompilerSyncHook新 Compilation 实例创建后(首次构建、热更新均触发)监听单次编译的后续钩子(如 processAssets)、操作模块依赖tap
buildModuleCompilationSyncHook模块开始构建前模块编译前预处理(如查缓存、修改模块路径)tap
succeedModuleCompilationSyncHook模块编译成功后获取模块元数据(如 Loader 传递的信息)、记录模块编译结果tap
processAssetsCompilationAsyncParallelHook资源优化阶段(代码压缩、分割后,输出前),取代以前的一些分开的资源处理 hooks并行优化资源(如注入注释、删除无用代码、修改资源内容)tapAsync
emitCompilerAsyncSeriesHook所有资源优化完成,即将输出到磁盘前新增 / 删除 / 修改输出资源、生成构建报告、拷贝静态资源tapAsync
afterEmitCompilerAsyncSeriesHook所有资源已输出到磁盘后资源输出后的后续操作(如上传资源到 CDN、发送构建通知)tapPromise
doneCompilerSyncHook整个构建流程完全结束(成功 / 失败均触发)输出构建统计信息、分析构建结果、清理临时文件tap
failedCompilerSyncHook构建流程失败时(如模块编译错误、Plugin 报错)捕获构建错误、执行失败兜底操作(如清理缓存、回滚资源)tap
watchRunCompilerAsyncSeriesHook监听模式下(如 webpack-dev-server),文件变化触发重新构建前检测文件变化、更新缓存、打印热更新日志tapAsync
normalModuleFactoryCompilerSyncHook普通模块工厂(NormalModuleFactory)创建后自定义模块解析规则、修改模块加载方式tap

这里解释下钩子类型:

  • SyncHook:同步的钩子,回调函数中不可以有异步逻辑,否则会阻塞构建或使逻辑错误,只能用 tap 注册
  • AsyncSeriesHook/AysncParallelHook: 异步钩子,回调函数可以包含异步逻辑,webpack 会等待所有异步操作完成后继续,不能用 tap 注册,会报错。其中 Series 和 Parallel 表示回调函数的执行顺序是串行还是并行执行。

和 Loader 的区别

在之前的 Loader 面试题中总结过,这里搬过来便于浏览:

对比维度LoaderPlugin
核心定位专注于单个文件内容的处理与转换,解决 Webpack 无法识别非 JS/JSON 资源的问题专注于整个构建流程的干预与扩展,在构建过程中提供功能补充(如优化、生成、监控)
处理粒度每次仅处理一个独立文件(如单独转换某個 CSS 文件、某张图片)作用于整个构建流程(如对所有打包后的 JS 文件进行压缩、为所有 HTML 注入脚本)
运行时机模块解析阶段Webpack 构建的全阶段,Webpack 的生命周期
配置方式通过 module.rules 配置:需指定 test(匹配文件)、use(Loader 列表),按规则匹配执行通过 plugins 数组配置:需实例化插件类(如new HtmlWebpackPlugin()),全局生效
上下文信息仅能访问当前处理文件的局部上下文(this 仅包含当前文件路径、参数等),无法操作全局构建状态可访问 Webpack 完整的全局上下文(Compiler/Compilation 对象),能修改构建依赖图、输出文件等全局信息
依赖关系链式多个 Loader 处理同一文件时,按 “从右到左” 顺序执行,后一个 Loader 的输出作为前一个的输入(如 style-loader 依赖 css-loader 的输出)Webpack 生命周期,插件间通过钩子执行顺序依赖(如 TerserPlugin 需在代码生成后执行,依赖 optimizeChunkAssets 钩子),无固定执行顺序,由钩子触发时机决定
配置复杂度简单中等 / 复杂
典型使用场景1. 样式处理:css-loader(解析 CSS)、sass-loader(编译 SCSS)2. 语法转换:babel-loader(ES6+ 转 ES5)、ts-loader(TS 转 JS)3. 资源处理:asset-loader(处理图片 / 字体)、raw-loader(读取文件为字符串)1. 资源生成:HtmlWebpackPlugin(生成 HTML 文件)、CopyWebpackPlugin(拷贝静态资源)2. 代码优化:TerserPlugin(压缩 JS)、CssMinimizerPlugin(压缩 CSS)3. 环境配置:DefinePlugin(注入环境变量)、HotModuleReplacementPlugin(开启热更新)4. 质量监控:ESLintPlugin(代码校验)、BundleAnalyzerPlugin(打包体积分析)
错误影响范围单个 Loader 报错仅导致当前文件处理失败,不影响其他文件的构建(如某张图片处理失败,不影响 JS 文件打包)插件报错可能导致整个构建流程中断(如 HtmlWebpackPlugin 模板路径错误,会导致所有 HTML 生成失败,构建终止)

面试追问

  1. Webpack5 中 Compiler 和 Compilation 的区别是什么?

    Compiler 是全局唯一的,从 webpack 启动到关闭都只有这一个实例,而 Compilation 则是每次编译时创建一个,只存在于本次编译过程(从一个模块的编译到资源输出)。

  2. Webpack5 的 Plugin 系统有哪些优化?

    钩子基于 Tapable4 重构,对于异步钩子支持并行执行,提供如 thisCompilation 等更精细的钩子,支持复杂场景,同时将一些插件内置,不再需要单独安装引入。

  3. 自定义钩子怎么调试?每次都要运行项目去调试吗?岂不是很浪费时间

    可以通过一个简化的 DEMO 项目去启动调试 Webpack 的自定义 Plugin,避免工程项目过大导致编译等待时间太长,可以通过 console.log 在指定位置处输出日志来调试。但是如果需要更精细化的调试方式,则需要使用 debug 断点配置了,Webpack 的配置不变,通过命令 node --inspect-brk ./custom-plugin.js 启动我们的插件,随后通过调试工具,如 vscode 的调试工具运行 Webpack 相关的启动命令,随后,就可以访问到自定义插件的中的断点内容了。

    Webpack 同时也提供了相关的调试方案,用的是 node-nightly 库和 Chrome Devtool 联合处理,相对来说可能复杂一些。