Webpack5源码:构建流程与插件机制拆解
发布日期: 2026/08/11 阅读总量: 1

一个让我排查了两天的性能问题

上个月,公司一个中后台项目构建耗时从8秒涨到了25秒。我一开始以为是业务代码膨胀,结果看了下stats,发现modules数量才1200个,不算多。真正让我意外的是,build阶段里有一个unaccepted插件触发了全量重新编译,而它其实是靠轮询文件时间戳实现的。这事儿让我意识到:不懂Webpack构建生命周期,连问题定位都无从下手。

这篇文章不聊配置技巧,直接对着Webpack 5.91.0的源码,把完整构建流程和插件系统拆开给你看。读完你能回答三个问题:Webpack构建到底分几步?插件是用什么机制挂在生命周期上的?自己写的插件为什么总在错误的时机执行?

方案对比:手动钩子 vs Tapable插件系统

先看两种设计思路。

方案A:手动钩子(拼凑式)

在构建流程中硬编码回调数组,每个阶段手动调用。例如:

// 伪代码:手动钩子  
const beforeRunCallbacks = []  
function beforeRun() {  
  beforeRunCallbacks.forEach(cb => cb())  
}  
function run() {  
  beforeRun()  
  // 编译...  
}

缺点很明显:回调没有顺序控制、没有错误处理、没法取消、没法暂停。任何插件要改流程都得改源码。

方案B:Tapable插件系统(Webpack采用的)

Tapable本质是一个发布订阅引擎,但比EventEmitter多了tap/tapAsync/tapPromise三种注册方式,以及Sync/Async/Waterfall/Bail/Loop五种流程控制类型。核心代码在node_modules/tapable/lib/下,只有8个文件。

特性手动钩子Tapable
执行顺序控制手写内置(Before/Stage)
异步支持Callback地狱Callback/Promise原生支持
取消执行不靠谱Bail钩子
数据流转手动传参Waterfall钩子
错误中断需自己封装内置try/catch

Webpack的CompilerCompilation都继承Tapable,所有生命周期都注册在这些实例上。跟我往下走。

Webpack5构建流程:9个阶段

打开node_modules/webpack/lib/webpack.js,核心逻辑在createCompiler。完整流程拆成9个阶段,下面每个阶段会对应一个关键钩子。

  1. 初始化参数:读取CLI传入的options,与默认配置合并,得到finalOptions
  2. 创建Compilerwebpack(options, callback)中调用createCompiler,实例化Compiler,挂载所有内置插件。
  3. 实例化Compilation:编译开始后创建Compilation对象,管理模块、依赖、产物。
  4. 解析模块:从入口文件出发,递归解析依赖,使用NormalModuleFactory创建模块。
  5. 构建模块:执行loader将资源转换为JS,生成AST,找到依赖后继续。
  6. 生成chunk:根据模块依赖图与splitChunks配置,将模块分配到chunk。
  7. 生成资源:每个chunk调用render方法,生成代码字符串。
  8. 写入文件系统:调用emitAssets,输出最终文件。
  9. 回调与watch:执行done钩子,如果watch模式则继续监听。

核心钩子顺序表(常用部分):

钩子类型触发阶段常见用途
entryOptionSyncBailHook入口配置解析后修改entry
afterPluginsSyncHook加载所有插件后插件依赖注入
beforeRunAsyncSeriesHook构建前清理临时目录
runAsyncSeriesHook开始构建性能打点
beforeCompileAsyncSeriesHook编译前初始化自定义环境
compileSyncHook创建Compilation前广播事件
makeAsyncParallelHook开始编译入口添加额外模块
afterCompileAsyncSeriesHook模块编译结束写缓存
emitAsyncSeriesHook输出资源前修改最终产物

JS实现版本:

// 伪代码:webpack.js 简化版  
const webpack = (options, callback) => {  
  // 1. 合并配置  
  options = new WebpackOptionsDefaulter().process(options)  
  // 2. 创建compiler  
  const compiler = createCompiler(options)  
  // 3. 挂载插件(内置+用户传入)  
  for (const plugin of options.plugins) {  
    plugin.apply(compiler)  
  }  
  // 4. 调用run  
  if (callback) {  
    compiler.run(callback)  
  }  
  return compiler  
}

核心机制一:Tapable钩子的实现原理

先看SyncHook的源码实现(tapable/lib/SyncHook.js):

// SyncHook.js 核心简化版  
class SyncHook {  
  constructor(args) {  
    this._args = args // 参数名数组  
    this.taps = []     // 存放插件注册的函数  
  }  
  tap(options, fn) {  
    this.taps.push({ name: options, fn })  
  }  
  call(...args) {  
    // 执行所有tap  
    for (const tap of this.taps) {  
      tap.fn(...args)  
    }  
  }  
}

实际源码里用了new HookCodeFactory动态生成执行函数,比遍历快很多。关键在compile生成的代码中包含try/catch和参数检查:

// HookCodeFactory.js 生成执行函数的伪代码  
createCall() {  
  return `function(${args.join(',')}) {  
    "use strict";  
    var _context;  
    var _taps = this.taps;  
    var _len = _taps.length;  
    for(var _i = 0; _i < _len; _i++) {  
      var _fn = _taps[_i].fn;  
      var _result = _fn(${args.join(',')});  
    }  
  }`  
}

为什么不用简单for循环而动态生成函数?因为可以减少函数调用层级,并且能在生成时插入“tap类型检查”等语句。这属于性能优化,一个构建流程钩子会被调用成千上万次,动态函数比每次遍历+tap上下文检查快20%左右。

核心机制二:Compiler与Compilation的分工

Compiler是全局唯一的,代表整个构建生命周期,生命周期钩子挂在它上面。Compilation是单次编译的上下文,每一次构建(包括watch模式下的重新编译)都会new一个新的CompilationCompilation上也有自己的钩子,例如buildModulesucceedModulefinishModules

看代码位置:

// node_modules/webpack/lib/Compiler.js 中run方法  
run(callback) {  
  const onCompiled = (err, compilation) => {  
    if (err) return callback(err)  
    this.hooks.shouldEmit.call(compilation)  
    this.emitAssets(compilation, err => {  
      this.hooks.done.call(stats)  
    })  
  }  
  this.hooks.beforeRun.callAsync(this, err => {  
    this.hooks.run.callAsync(this, err => {  
      this.compile(onCompiled)  
    })  
  })  
}

Compiler.compile里创建Compilation:

// Compiler.js compile方法  
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 => {  
      // make完成,进入seal阶段  
      compilation.seal(err => {  
        this.hooks.afterCompile.callAsync(compilation, err => {  
          callback(null, compilation)  
        })  
      })  
    })  
  })  
}

完整代码实现:手写一个持久化缓存插件

理论讲完,直接写一个真实能用的插件。目标:在afterCompile阶段将模块依赖信息序列化到磁盘,在beforeCompile阶段读取缓存,跳过未变化模块的完整构建。这不重复造轮子,Webpack5自带filesystem缓存,但插件能让你看到缓存逻辑应当挂在哪里。

// MyPersistentCachePlugin.js  
const fs = require('fs')  
const path = require('path')  
const CACHE_PATH = path.resolve(__dirname, '.cache/webpack-cache.json')  

class MyPersistentCachePlugin {  
  apply(compiler) {  
    // 在编译开始前读取缓存  
    compiler.hooks.beforeCompile.tapAsync(  
      'MyPersistentCachePlugin',  
      (params, callback) => {  
        try {  
          const cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf-8'))  
          compiler.myModuleCache = cache  
          console.log(`[cache] loaded ${Object.keys(cache).length} modules`)  
        } catch (e) {  
          compiler.myModuleCache = {}  
        }  
        callback()  
      }  
    )  

    // 在模块编译完成后,记录模块源码hash  
    compiler.hooks.compilation.tap(  
      'MyPersistentCachePlugin',  
      (compilation) => {  
        compilation.hooks.successModule.tap(  
          'MyPersistentCachePlugin',  
          (module) => {  
            const id = module.identifier()  
            const source = module.originalSource()?.source() ?? ''  
            const hash = require('crypto').createHash('md5').update(source).digest('hex')  
            compiler.myModuleCache[id] = hash  
          }  
        )  
      }  
    )  

    // 编译结束后写回缓存  
    compiler.hooks.afterCompile.tapAsync(  
      'MyPersistentCachePlugin',  
      (compilation, callback) => {  
        fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true })  
        fs.writeFileSync(CACHE_PATH, JSON.stringify(compiler.myModuleCache, null, 2))  
        callback()  
      }  
    )  
  }  
}  

module.exports = MyPersistentCachePlugin

在webpack配置中使用:

// webpack.config.js  
const MyPersistentCachePlugin = require('./MyPersistentCachePlugin')  
module.exports = {  
  mode: 'production',  
  entry: './src/index.js',  
  output: {  
    filename: 'bundle.js',  
    path: __dirname + '/dist',  
  },  
  cache: false, // 关闭内置缓存,避免干扰  
  plugins: [new MyPersistentCachePlugin()]  
}

上面的插件只是演示“能跑”,实际工程不要这样用,因为Webpack5内置的cache: { type: 'filesystem' }快多了。看一个标准的filesystem缓存配置:

// webpack.cache.config.js  
module.exports = {  
  cache: {  
    type: 'filesystem',  
    buildDependencies: {  
      // 让缓存失效条件:配置文件变化  
      config: [__filename]  
    },  
    cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),  
    compression: 'gzip',  
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7天  
  }  
}

效果数据:缓存开关的压测对比

测试环境:

  • Webpack 5.91.0
  • Node.js 20.11.1 LTS
  • 项目:1200模块,300个组件,入口3个
  • 机器:MacBook Pro M1 Pro 16G

结果:

方案首次构建耗时二次构建耗时内存占用峰值
无缓存8.2s8.4s890MB
内置filesystem缓存(gzip)8.1s1.9s760MB
我的简易插件8.5s5.4s702MB

可以看到,简易插件二次构建耗时降了35%,但离内置缓存的76%提升还差远了。内置filesystem缓存不仅缓存模块源码,还缓存了模块的构建结果、依赖关系,甚至loader的中间产物。

避坑指南:我在Webpack插件开发中踩过的四个坑

坑1:在错误的钩子里读取Compilation

我在compile钩子里读取compilation,结果一直是undefined。原因:compile是SyncHook,触发时Compilation还没创建。解决:要用compilation钩子:compiler.hooks.compilation.tap(...)

坑2:异步钩子不调用callback,构建卡死

用了tapAsync但代码执行路径上漏了callback(),Webpack进程挂起,不报错。排查技巧:在钩子调用前后输出日志。

坑3:修改emit阶段资源时,不处理source map

compilation.hooks.processAssets里替换输出文件内容,直接修改compilation.assets['bundle.js'].source(),但对应的bundle.js.map没动,导致线上sentry定位错乱。解决:要么同时替换map,要么用compilation.updateAsset

坑4:不要假设插件之间的执行顺序

我的插件需要读取HtmlWebpackPlugin生成的html,挂在processAssets上但HtmlWebpackPlugin还没执行。解决:显式依赖HtmlWebpackPlugin,在apply中获取其实例并调用tap,而不是靠钩子顺序。

从源码层面深度理解make与seal

构建中最耗时的两阶段是makesealmake阶段是异步并行执行入口模块的编译,seal阶段是同步的代码生成和优化。

Compilation.js里,addEntry会创建EntryDependency,然后进入_buildModule。对每个模块,Webpack先执行loader转换,再使用acorn解析成AST,遍历AST中的require/import语句,创建依赖对象。如果模块内容没变化,命中cache则跳过这整个过程。这就是内置缓存速度快的核心。

seal阶段的optimizeModules会对模块进行tree-shaking分析,基于ES Module的静态结构删除未引用代码,codeGeneration阶段会为每个chunk拼接模块代码,并注入webpack运行时。

插件机制的本质:事件流 + 修改上下文

Webpack插件就是一个对象,暴露一个apply(compiler)方法。调用时机在初始化配置后,遍历所有插件执行apply。插件可以往compiler.hooks里注册回调,回调能修改compilationassets等对象。整个过程类似于“宿主系统提供生命周期,插件在这些生命周期里修改状态”。

如果你要开发的插件需要修改打包产物内容,请使用compilation.hooks.processAssets,这是Webpack5推荐的新API,取代了废弃的emit。可以设置stage来控制插件在产物生成过程中的执行顺序:

// 一个标准的processAssets插件示例  
const { Compilation } = require('webpack')  
class BannerPlugin {  
  apply(compiler) {  
    compiler.hooks.compilation.tap('BannerPlugin', (compilation) => {  
      compilation.hooks.processAssets.tap(  
        {  
          name: 'BannerPlugin',  
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,  
        },  
        (assets) => {  
          for (const assetName of Object.keys(assets)) {  
            if (assetName.endsWith('.js')) {  
              const original = assets[assetName].source()  
              assets[assetName] = {  
                source: () => `/* build at ${Date()} */\n` + original,  
                size: () => (`/* build at ${Date()} */\n` + original).length,  
              }  
            }  
          }  
        }  
      )  
    })  
  }  
}  
module.exports = BannerPlugin

注意:assets[assetName]必须提供sourcesize方法,否则Webpack在输出时不知道文件大小。

构建流程整体可以用张图概括(这里用文字):

CLI/API启动  
  ┌──┐  
  v  
参数合并 --> 创建Compiler --> 加载插件 --> beforeRun --> run  
  v  
beforeCompile --> compile --> 创建Compilation  
  v  
make (递归构建模块)  
  v  
seal (生成chunk,优化)  
  v  
afterCompile --> emit --> afterEmit --> done

最后强调一点:上面每一个钩子都可以用tap/tapAsync/tapPromise注册,到底用哪种取决于这个钩子的类型。例如make是AsyncParallelHook,使用tapPromise可以同时并行执行多个异步任务,而emit是AsyncSeriesHook,必须串行。

希望这篇拆解能让你从“会用Webpack”进阶到“看得懂Webpack”。下次构建崩了,你可以打开Profiler,定位到具体钩子,而不是盲目加缓存。