Webpack 的构建流程是什么?怎样写一个 Plugin?

先理解原理和示例,再练习完整口述与追问。

初始化合并配置并创建 Compiler,注册插件 → run 后从 entry 开始,每个模块经 loader 转换、解析 AST 收集依赖,递归构建依赖图 → seal 阶段按入口和动态导入划分 chunk,优化、生成代码与哈希 → emit 写出文件。Plugin 是带 apply(compiler) 方法的类,通过 Tapable 钩子介入各阶段;Loader 只负责把单个文件转换为模块代码。

Compiler 代表整个构建生命周期,在 watch 模式下只有一个;Compilation 代表一次具体的编译,包含模块、chunk 和产物,每次文件变化都会新建。两者都继承 Tapable,提供同步钩子(SyncHook)、异步串行(AsyncSeriesHook)、可中断(SyncBailHook)等类型,插件用 tap、tapAsync、tapPromise 注册。

Loader 从右到左(从下到上)执行,例如 ['style-loader', 'css-loader', 'sass-loader'] 先编译 Sass 再处理 CSS。Webpack 5 中修改产物应使用 compilation.hooks.processAssets 并指定阶段,不要在 emit 中直接改写 compilation.assets。

js
class FileListPlugin {
  apply(compiler) {
    const { RawSource } = compiler.webpack.sources
    compiler.hooks.thisCompilation.tap('FileListPlugin', compilation => {
      compilation.hooks.processAssets.tap(
        { name: 'FileListPlugin', stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE },
        assets => {
          const list = Object.keys(assets).map(name => `- ${name}`).join('\n')
          compilation.emitAsset('filelist.md', new RawSource(`# 产物清单\n${list}\n`))
        }
      )
    })
  }
}
module.exports = FileListPlugin

异步钩子用 tapAsync 却忘记调用 callback,构建会挂起;在插件中缓存 Compilation 对象会导致 watch 模式内存泄漏。Vite 开发期用原生 ESM 按需编译,生产用 Rollup(新版为 Rolldown),插件钩子模型不同,不能混用。

Webpack 先合并配置创建 Compiler 并注册插件,然后从入口出发,每个模块先过 loader 转换,再解析 AST 找依赖,递归形成依赖图;接着按入口和动态导入切分 chunk,做优化、生成代码和哈希,最后写出文件。Loader 负责单文件转换,Plugin 是带 apply 方法的类,通过 Compiler 和 Compilation 上的 Tapable 钩子介入任意阶段,Webpack 5 中修改产物用 processAssets 钩子。