一个让我排查了两天的性能问题
上个月,公司一个中后台项目构建耗时从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的Compiler和Compilation都继承Tapable,所有生命周期都注册在这些实例上。跟我往下走。
Webpack5构建流程:9个阶段
打开node_modules/webpack/lib/webpack.js,核心逻辑在createCompiler。完整流程拆成9个阶段,下面每个阶段会对应一个关键钩子。
- 初始化参数:读取CLI传入的options,与默认配置合并,得到
finalOptions。 - 创建Compiler:
webpack(options, callback)中调用createCompiler,实例化Compiler,挂载所有内置插件。 - 实例化Compilation:编译开始后创建
Compilation对象,管理模块、依赖、产物。 - 解析模块:从入口文件出发,递归解析依赖,使用
NormalModuleFactory创建模块。 - 构建模块:执行loader将资源转换为JS,生成AST,找到依赖后继续。
- 生成chunk:根据模块依赖图与splitChunks配置,将模块分配到chunk。
- 生成资源:每个chunk调用
render方法,生成代码字符串。 - 写入文件系统:调用
emitAssets,输出最终文件。 - 回调与watch:执行
done钩子,如果watch模式则继续监听。
核心钩子顺序表(常用部分):
| 钩子 | 类型 | 触发阶段 | 常见用途 |
|---|---|---|---|
entryOption | SyncBailHook | 入口配置解析后 | 修改entry |
afterPlugins | SyncHook | 加载所有插件后 | 插件依赖注入 |
beforeRun | AsyncSeriesHook | 构建前 | 清理临时目录 |
run | AsyncSeriesHook | 开始构建 | 性能打点 |
beforeCompile | AsyncSeriesHook | 编译前 | 初始化自定义环境 |
compile | SyncHook | 创建Compilation前 | 广播事件 |
make | AsyncParallelHook | 开始编译入口 | 添加额外模块 |
afterCompile | AsyncSeriesHook | 模块编译结束 | 写缓存 |
emit | AsyncSeriesHook | 输出资源前 | 修改最终产物 |
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一个新的Compilation。Compilation上也有自己的钩子,例如buildModule、succeedModule、finishModules。
看代码位置:
// 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.2s | 8.4s | 890MB |
| 内置filesystem缓存(gzip) | 8.1s | 1.9s | 760MB |
| 我的简易插件 | 8.5s | 5.4s | 702MB |
可以看到,简易插件二次构建耗时降了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
构建中最耗时的两阶段是make和seal。make阶段是异步并行执行入口模块的编译,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里注册回调,回调能修改compilation或assets等对象。整个过程类似于“宿主系统提供生命周期,插件在这些生命周期里修改状态”。
如果你要开发的插件需要修改打包产物内容,请使用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]必须提供source和size方法,否则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,定位到具体钩子,而不是盲目加缓存。