
很多前端工程师用 Webpack 用了好几年配置能写一大摞但问到 Webpack 内部究竟是怎么运转的往往是“知道个大概”。尤其是生命周期这个概念大家经常挂在嘴边说“emit 阶段可以生成额外文件”“done 之后可以做上报”可真要问一句Webpack 从启动到结束内部到底经历哪些阶段每个阶段里到底发生了什么哪些 Hook 是同步、哪些是异步为什么有时候你在某几个 Hook 里写逻辑就是不生效……这类问题才是真正拉开普通配置选手和深水区玩家差距的地方。这篇文章我不打算给你复述一遍官方文档而是按照我阅读源码和实际写插件时的理解把 Webpack 的生命周期拆开揉碎从设计思路到每个阶段的关键事件再到真实项目里的踩坑记录完整过一遍。无论你是想自己写 loader 和 plugin还是只是想把打包性能调明白理解生命周期都是绕不开的一课。1. 为什么必须先搞懂生命周期它决定了你的插件能否在正确的时机干活先解决一个根本问题为什么 Webpack 要把构建过程拆成那么多阶段为什么不能像老式打包工具那样一个入口跑到底核心答案藏在 Webpack 的扩展性设计里。Webpack 的一切能力——代码分割、Tree Shaking、持久化缓存、热更新——本质上都是通过插件机制挂载到构建流程的各个节点上实现的。如果构建过程是一团黑盒、只有开始和结束两个事件那所有插件只能在这两个时间点做有限的事根本不可能实现类似“在模块解析完成后修正依赖关系”这种细粒度控制。所以 Webpack 把整个构建拆成了具有先后顺序的事件流并通过 Tapable 这个内部库统一管理。Tapable 提供了一套类似 EventEmitter 但更强大的钩子机制支持同步、异步、串行、并行、熔断等丰富语义。理解生命周期本质上是理解这条事件流上每个节点的语义、时机和约束。我接触过的不少开发者会有个误区以为生命周期只是“给插件作者准备的东西”平时配置 Webpack 用不到。这个想法挺吃亏的。举两个最日常的例子你在配置里写optimization.minimize: true实际上就是在finishModules阶段之后触发了JavaScriptMinimizerPlugin的处理逻辑你开启了devServer.hot热更新的整个客户端-服务端协商流程也是建立在watchRun、done这些生命周期事件之上的。可以说配置文件里的每一条规则、每一个优化项最终都映射到某个生命周期阶段上的行为。一句话总结生命周期是 Webpack 的骨架插件是长在骨架上的肌肉配置只是给肌肉下达指令的神经信号。骨架不清楚后面两样都玩不转。2. 生命周期全景图从启动到结束Webpack 到底经历了什么先给一张全景式的阶段图之后每一段我再逐级展开。注意我这里描述的是webpack作为库被 Node.js 调用后的一次标准构建流程非 watch 模式。阶段一初始化参数。读取配置文件或 CLI 参数合并生成最终的 Options 对象。阶段二创建 Compiler 对象。这是整个构建过程的总指挥负责控制构建流程、调度插件。阶段三开始运行。处理beforeRun/run钩子进入构建主流程。阶段四编译compile。创建 Compilation 对象它是一个单次构建的“上下文容器”承载模块图、依赖图、输出资源。阶段五构建模块make/finishModules。从入口出发递归解析依赖构建 ModuleGraph通过 Loader 转换源码解析 AST处理依赖。阶段六封装seal/optimize。生成 Chunk 图执行优化Tree Shaking、代码分割、压缩准备。阶段七输出emit/afterEmit。将最终 Assets 写入文件系统。阶段八结束done。清理资源执行回调。如果你只记住一条主线那就是Compiler 是总控Compilation 承载每一次构建的核心状态。Compilation的生命周期比Compiler更微观、也更频繁——尤其在 watch 模式下每次文件变更都可能触发新的 Compilation而 Compiler 一直常驻。为了直观对比下面用一个表格列出 Compiler 和 Compilation 的主要职责差异维度CompilerCompilation生命周期时长贯穿整个 Webpack 进程仅存在于一次构建中核心职责调度构建流程、管理插件/钩子维护模块图、依赖、输出资源是否会被重置通常不会watch 模式下每次变更都可能新建关键事件run, watchRun, doneseal, optimize, moduleAssets开发者接触度中插件主入口高几乎插件都要用它拿数据这张表请记住后面所有阶段拆解都会回到这个区分上。3. 逐阶段拆解每个生命周期事件里Webpack 到底在忙什么现在我们把上面那条主线拆成可以直接对照查询的阶段明细。这不只是 list 一堆 Hook 名字我尽量把每个 Hook 触发的时机、适合作什么事、有什么前置条件都交代清楚。3.1 environment / afterEnvironment插件的初始挂载点environment和afterEnvironment是两个很不起眼、但处于早期阶段的钩子。它们触发时Compiler 已经创建配置文件已经处理完毕但还没有进入运行流程。从源码上看这两个钩子的触发点在webpack函数内部const compiler createCompiler(options); compiler.environment(); compiler.afterEnvironment();environment的用途通常是给运行环境打补丁比如修改process.title或者注入一些全局变量供后续 Loader/Plugin 使用。afterEnvironment则稍微晚一点适合那些需要确保环境变量已经就绪的插件初始化工作。实际开发中这两个钩子出镜率不高。我唯一一次用到是在写一个跨平台构建工具时需要根据不同的环境变量提前设置NODE_OPTIONS当时就是挂在afterEnvironment里。如果你只是写业务插件基本不需要关心它们。3.2 entryOption / afterPlugins / afterResolvers参数合并后的收尾这里稍微绕一点。entryOption这个钩子比较特殊它触发于WebpackOptionsApply过程中——也就是把用户配置和默认配置合并之后、插件尚未全部初始化完毕的时期。流程是这样的创建 Compiler 时会调用new WebpackOptionsApply().process(options, compiler)。在这个过程中根据options的类型数组还是对象、target 设置、mode、devtool 等逐一注册对应的内置插件。entryOption是在EntryPlugin注册之前触发的所以它拿到的 context 是entry配置项的原始值。afterPlugins在用户插件注册完成后触发afterResolvers则是在 resolver 工厂准备完毕之后触发。对这个阶段最常见的困惑是为什么我的插件在entryOption里拿不到最终的 entry 列表因为此时 Webpack 还没有解析 entry 配置这里拿到的只是用户写的原始对象可能是字符串、数组或对象结构。真正拿到规范化 entry 的地方要等到compilation阶段去读compilation.entries。3.3 beforeRun / run / watchRun构建启动前的最后时刻beforeRun和run是标准构建模式的启动钩子watchRun则是 watch 模式下的对应版本。关键点来了这几个钩子触发时上一次构建可能还残留一些状态。尤其 watch 模式下watchRun每次文件变更都会触发而且它接收一个compiler参数你可以通过compiler.modifiedFiles查看本次触发的文件列表。这里分享一个常见场景。很多团队会在打包前做“清理产物目录”的操作过去大家在配置里写CleanWebpackPlugin。如果我需要手动控制清理逻辑比如保留部分文件可以在watchRun或run钩子里异步执行compiler.hooks.beforeRun.tapPromise(CleanDistPlugin, async () { await cleanDistExceptSomeFiles(); });就这么一个操作放在错误的钩子里效果就差很远。如果你放到emit阶段再清理可能新文件的写入顺序已经和你清理逻辑产生竞争会出现“老子刚生成的文件被误删”这种灵异问题。3.4 compile / beforeCompile / afterCompile创建 Compilationcompile是构建的真正起点。此前所有事情都是准备从compile开始Webpack 正式为“这一次构建”创建 Compilation 对象。这里引入一个非常重要的概念性知识Complication 不是只有一个。在 watch 模式下每次增量构建都会重新创建一个 Compilation。每次创建的 Compilation 都保存了本次构建独立的状态——包括所有模块、依赖、chunk、assets 等。可以说Compilation 是“瞬时快照”而 Compiler 是“常驻进程”。compile钩子触发顺序beforeCompilecompile此时compilation尚未创建适合做一些全局状态修改thisCompilation创建了新 Compilation但还没开始填充数据compilation所有插件都可以在这里拿到 compilation 实例很多人混淆thisCompilation和compilation其实二者的区别是thisCompilation触发更早且只在当前 Compiler 上下文中触发一次而compilation钩子在每次 Compilation 创建时都会触发属于高频钩子。真正写插件的时候99% 的情况下你监听的是compilation。3.5 make / finishMake模块解析与构建的核心战场make是生命周期里最“燃”的阶段——大量耗时操作都在这里发生从入口出发递归解析模块、应用 Loader、构建 Dependency Graph。make的行为分为两个层次在Compiler上make是一个 AsyncSeriesHook内部会调用compilation.addEntry启动入口模块构建。在Compilation上有大量细粒度钩子比如buildModule、succeedModule、finishModules等。这里的buildModule并不是模块加载完成而是模块构建开始。理解这个细节对你的性能分析很有帮助。例如你在用 speed-measure-webpack-plugin 查看各 Loader 耗时其底层原理就是监听buildModule前后时间差来统计。finishMake是make之后、seal之前的边界。它触发时所有模块都已经构建完成、依赖关系已经整理到 ModuleGraph 中。此时ModuleGraph 可以看作一棵完整的“指纹树”。我在插件开发中常用的策略就是监听finishModulesCompilation 上的钩子在finishMake之后触发遍历所有模块信息做自定义分析。例如团队内部想输出一份“哪些模块被重复打包了”的报告就可以在这里记录每个模块的id和引用次数数据非常全面且时机安全。3.6 seal / optimize / optimizeChunks封装与优化seal阶段的工作极具分量。这里面发生的事包括根据optimization.splitChunks配置对 Chunk 进行拆分与合并触发optimizeDependencies、optimizeModules、optimizeChunks等钩子执行 Tree Shaking标记未被使用的导出生成每个 Chunk 的渲染文件即 Runtime 代码 业务代码拼接前的准备seal和optimize的区别在于范围。seal是整个封装阶段的入口和出口optimize内部则再细分了十几个子阶段。平时开发中最常用的链路是optimizeChunks调整 chunk 合并拆分optimizeTree优化 chunk 树结构optimizeAssets优化资产列表此时 asset 还未写入磁盘有一个很经典的坑你想在emit阶段修改已经生成的 JS 文件内容结果发现修改不起作用或者压缩插件把你的修改又覆盖了。原因很可能就是你的钩子挂在optimizeAssets里但TerserPlugin的压缩逻辑也挂在这里而且它的stage数字更靠前优先级更高。要规避这类问题官方提供了stage参数Compilation.PROCESS_ASSETS_STAGE_*例如compilation.hooks.processAssets.tap( { name: MyPrefixPlugin, stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, }, (assets) { // 对资源做汇总处理 } );3.7 emit / afterEmit写入文件系统前的最后机会emit是传统插件开发中曝光率最高的阶段之一。它触发时最终的 Assets 字典已经生成但还没有被写进文件系统。你在emit阶段可以直接修改compilation.assets向其中添加或替换文件。emit和afterEmit的关系很简单emit在写文件之前afterEmit在写文件之后。基于这个特性可以产生很多实用玩法在emit阶段注入一个包含构建时间、版本号、Git Hash 的build-info.json文件。在afterEmit阶段上传静态资源到 CDN或者在本地生成 source-map 的上报日志。注意一个细节emit阶段操作的是compilation.assets这是Compilation上的资产集合不是文件系统。如果你在这之后调用compilation.deleteAsset()实际上是同步删除了内存中的资产记录但还没有影响磁盘。这一点和我上一节提到的run阶段清理磁盘不是同一个概念别搞混。3.8 done / failed / invalid终点与运气的分野done意味着一次成功的构建生命周期结束。它拿到的stats对象包含了丰富的统计信息例如compiler.hooks.done.tap(BuildReportPlugin, (stats) { console.log(stats.toJson().time); // 构建耗时 console.log(stats.toJson().chunks); // chunk 信息 console.log(stats.hasErrors()); // 是否有错误 });failed则在构建抛出致命错误时触发注意它和compilation.errors的区别compilation.errors收集的是模块级错误某个模块解析失败、Loader 报错构建可能继续也可能中途挂掉而compiler.hooks.failed是编译过程本身崩了才会触发比如配置错误、代码异常未被捕获。invalid出现在 watch 模式下当监听的文件发生变更但尚未开始重新构建时触发。这个钩子的意义主要用于通知外部工具“构建即将开始请准备刷新”。走到这里一条完整构建链路的主干就清楚了。下面我们把视角从“事件流”转向“插件实现”看看真实项目中如何利用这些生命周期事件写出实用的工具。4. 写一个贯穿生命周期的插件从统计构建数据到自动注入版本号光讲概念容易飘我带你实现一个不算复杂但很实用的插件。它能做两件事在emit阶段生成一个build-meta.json内容包括构建时间、构建模式development/production、Git 最新提交信息。在done阶段输出构建耗时和 chunk 数量便于 CI 日志里快速查看。这个插件至少要利用四个生命周期钩子environment、emit、done以及watchRunwatch 模式下也能正确生成。4.1 插件骨架与 Hook 注册方式先看整体代码骨架const fs require(fs); const path require(path); const { execSync } require(child_process); class BuildMetaPlugin { constructor(options {}) { this.options { outputFile: build-meta.json, ...options, }; } apply(compiler) { // 1. 在环境准备阶段获取版本信息 compiler.hooks.environment.tap(BuildMetaPlugin, () { try { this.gitHash execSync(git rev-parse --short HEAD) .toString() .trim(); } catch { this.gitHash unknown; } }); // 2. 每次构建开始前记录起始时间包含 watch 模式 compiler.hooks.watchRun.tap(BuildMetaPlugin, () { this.startTime Date.now(); }); compiler.hooks.run.tap(BuildMetaPlugin, () { this.startTime Date.now(); }); // 3. emit 阶段注入元信息文件 compiler.hooks.emit.tapAsync(BuildMetaPlugin, (compilation, callback) { const now new Date(); const meta { buildTime: now.toISOString(), mode: compiler.options.mode, gitHash: this.gitHash, entries: Array.from(compilation.entries.keys()), }; const json JSON.stringify(meta, null, 2); compilation.assets[this.options.outputFile] { source: () json, size: () json.length, }; callback(); }); // 4. done 阶段输出构建摘要 compiler.hooks.done.tap(BuildMetaPlugin, (stats) { const duration Date.now() - this.startTime; const info stats.toJson({ chunks: true }); console.log( [BuildMeta] 构建完成, 耗时: ${duration}ms, chunks: ${info.chunks.length} ); }); } } module.exports BuildMetaPlugin;4.2 需要注意的两个技术点第一compilation.assets的结构。这里我直接给source()和size()两个方法。这是 Webpack 内部对 Asset 的标准结构要求webpack-sources库的RawSource本质上也是实现这两个接口。如果你用compilation.emitAsset(name, new RawSource(content))效果相同只是emitAsset会额外走一些校验和排序算法。直接改assets适合简单场景但不建议在大型插件中乱用官方更推荐emitAsset。第二execSync的异常处理。Git 命令在 CI 环境、压缩包环境、以及非 Git 仓库目录下都可能失败。如果不做 try/catch插件会让整个构建崩溃。这也是生命周期插件开发中的常见教训——不要假设环境完善任何外部命令都可能缺席。5. 从生命周期角度看打包优化三个常见优化点背后的 Hook 逻辑理解了生命周期之后再回头看看那些天天用的构建优化手段你会看得更透。这里挑三个最常见的优化项解读它们背后的阶段逻辑。5.1 Loader 耗时定位buildModule 与 succeedModule很多开发者用过 speed-measure-webpack-plugin简称 SMP但未必了解它在做什么。它的工作原理是包裹compiler.hooks.compilation钩子在创建 Compilation 后重新替换内部的normalModuleLoader模块加载器并监听buildModule和succeedModule两个钩子buildModule触发时记录Date.now()succeedModule触发时计算差值并把耗时归因到对应 loader 链上所以说SMP 本质上是围绕Compilation的模块构建生命周期做时间统计的封装。你自己写一个轻量版也完全可以并不依赖于 SMP 本体。统计逻辑并不复杂关键在于你选对钩子数据一定得在buildModule和succeedModule之间采集早了拿不到模块信息晚了已经被模块缓存覆盖。5.2 Tree Shaking 发生在什么阶段Tree Shaking 并不是一个独立的 Hook而是分布在多个阶段中的多个动作。核心流程是在模块解析阶段parseWebpack 通过concatenateScope分析 ES Module 的import/export标记每个导出的引用状态。在seal阶段的optimizeDependencies和optimizeModules中Webpack 核心插件FlagDependencyExportsPlugin和FlagDependencyUsagePlugin会执行“标记哪些导出未被使用”。最终在生成代码前未被使用的导出会被删除。这里最容易被误解的点是Tree Shaking 依赖的是 ES Module 的静态结构必须在parse之后才能推断出哪些变量是“死代码”。所以如果你在项目里用 CommonJS 或者把 ES Module 代码转成 ES5Tree Shaking 基本就失效了。5.3 持久化缓存的读取时机Webpack 5 的持久化缓存cache是一个“跨进程缓存系统”。从生命周期的视角看缓存模块系统在compile阶段之前就被检查了。具体来说在Compiler对象的compile方法内部Webpack 会先调用readRecords读取上一次构建的缓存记录如果存在。然后构建过程中每个模块的buildInfo信息会被序列化存进内存缓存在done后异步写入磁盘缓存。这个流程解释了一个常见现象为什么第一次构建总是比第二次慢得多因为第一次构建时磁盘上没有缓存记录所有模块都要完整走一遍解析、构建、转换的流程第二次构建时很多模块直接命中缓存跳过了 Loader 执行和 AST 解析。从性能优化角度你可以做的一件事是在 CI 环境里为cache.buildDependencies增加对package-lock.json、yarn.lock、babel.config.js等文件的依赖追踪锁文件变动时自动失效缓存。这个操作本身不需要写插件在配置里声明即可但你会比那些不理解的同行更能判断缓存为何失效。6. 生命周期实战排错Hook 不执行、顺序混乱、重复触发的排查思路生命周期最大的陷阱在于时机而不是语法。下面列几个我实际工作中踩过的、也是社区高频出现的几种典型问题。6.1 问题一监听compiler.hooks.emit没反应如果你是在 Webpack 配置文件中以plugins: [new MyPlugin()]的方式注册插件那emit通常没问题。但有一种常见失败场景在自定义的compiler.hooks.compilation.tap里注册了compilation.hooks.optimizeChunkAssets却监听不到任何事件。排查方向确认你的插件apply方法有没有被调用。如果插件类在模块加载时就报错apply可能根本没执行。确认钩子名称拼写是否正确。optimizeChunkAssets早先在 Webpack 4 里有到 Webpack 5 已经被processAssets取代。如果你从老项目复制插件代码到 Webpack 5 项目大概率会遇到这种“改了版本、没用新版 API”的情况。如果是在compilation钩子里注册子钩子注意子钩子的注册时机compilation触发时 Compilation 已经创建但某些子钩子例如模块构建类钩子此时注册本次构建可能已经错过某些阶段。所以不要把所有子钩子都一股脑注册在compilation里而要根据目标阶段注册到对应时机。6.2 问题二异步钩子不等待 Promise这是一个非常经典的错误。Tapable 的钩子类型决定了你的回调用法tap只支持同步回调Promise 返回值会被忽略。tapAsync需要显式调用第二个参数callback。tapPromise需要返回 Promise。很多新手在compiler.hooks.done.tap里写了async函数就以为完事了发现日志顺序不对还以为是异步问题。其实tap根本不接受 Promise 返回值。你写await的时候实际执行效果就是“同步终止在第一个 await 之前”后面的逻辑全部丢到微任务队列里别人早跑完了。正确的姿势是// 错误示范 compiler.hooks.done.tap(MyPlugin, async () { await doSomething(); console.log(done); }); // 正确做法 compiler.hooks.done.tapPromise(MyPlugin, async () { await doSomething(); console.log(done); });6.3 问题三watch 模式下插件重复执行watch 模式下插件逻辑在每次文件变更后都会执行一遍。如果你在插件里给对象添加监听器或者向某个全局数组 push 数据就可能出现内存泄漏或重复监听。处理思路是区分“每次构建都要做的事”和“只初始化一次的事”。初始化任务放在initializeWebpack 5或environment钩子里每次构建的逻辑放进watchRun/compilation/done等高频钩子中。我在开发一个热更新上报插件时就在watchRun里判断compiler.modifiedFiles把重复触发的上报数据做合并去重只在所有文件遍历结束后才统一上报一次。7. 深入 Tapable生命周期之所以有序的底层机制为了真正理解生命周期你必须认识 Tapable。它是 Webpack 生命周期调度的底层驱动定义了各类 Hook 的语义。不理解它你对生命周期的理解就永远停留在“背 API”层面。7.1 Tapable 钩子类型对比类型名称执行语义适用场景同步SyncHook按注册顺序依次执行数据读取、标记类操作同步SyncBailHook任一回调返回非 undefined 即中断熔断逻辑如 find 类操作同步SyncWaterfallHook回调返回值传给下一个回调配置合并、数据处理流水线同步SyncLoopHook循环执行直到所有回调返回 undefined递归校验异步AsyncSeriesHook串行执行等待每个回调完成资源顺序处理异步AsyncParallelHook并行执行全部完成后再继续并行优化任务异步AsyncSeriesWaterfallHook串行执行且值依次传递跨插件数据流Webpack 主流程大量使用AsyncSeriesHook比如run、compile、make、emit都是串行异步钩子。这保证了生命周期阶段的先后顺序严格可控。而compilation钩子内部大量使用SyncHook因为模块操作不需要等待磁盘 IO同步遍历即可。7.2 钩子的执行顺序与 stage 优先级Tapable 还允许你给钩子注册时添加stage和before选项控制同一钩子上多个插件的执行顺序。compiler.hooks.emit.tap( { name: MyPlugin, stage: 100, }, (compilation) {} );stage越大越靠后执行。Webpack 内置插件经常使用这个机制TerserPlugin的压缩阶段使用Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE数值较小优先执行而如果你想在压缩后继续处理产物就要选用数值更大的 stage如PROCESS_ASSETS_STAGE_ADDITIONAL。7.3 为什么 webpack 5 中processAssets取代了旧钩子Webpack 5 之前的emit和afterEmit钩子虽然直观但共享了同一个触发时机无法表达“先压缩再额外处理”这种内部秩序。为此 Webpack 5 引入了processAssets这一大钩子体系通过 stage 细分了资产处理流水线。生命周期也从“两个时间点”变成了“一条带优先级的流水线”。这也是为什么老插件在 Webpack 5 下总出现“文件生成时机不对”的原因。理解了 Tapable 的调度逻辑你在迁移插件时就不会只靠 trial-and-error而是能直接推算出它该挂在哪个 stage。8. 生命周期与工程化实践从数据上报到自定义 Webpack 插件体系聊完了原理和排错最后落回到工程实践。理解生命周期最大的红利是可以搭建一套适合自己团队的 Webpack 插件体系而不是每次都在配置里堆第三方插件。8.1 利用 done 钩子做构建质量门禁我在团队里做过一个BuildQualityPlugin。它在done钩子中读取stats按照事先配置的阈值做检查构建耗时超过 60 秒在 CI 中警告超过 120 秒直接 failchunk 数量超过 200 个时报错单个 chunk 体积超过 1MB 时打警告。这个插件的价值不在于检查本身而在于把“经验值”沉淀成自动化规则。别人解释不清为什么项目越来越卡你用数据分析告诉他“因为你的 chunk 从 80 涨到了 240每次构建的 parse 时间占到了总耗时的 60%。”这就甩开“玄学优化”好几个身位了。8.2 在 emit 阶段做产物签名与完整性校验emit阶段还能做一件很有价值的事为产物生成完整性签名。我们在发布前端资源到 CDN 时会希望 HTML 文件引用的 JS/CSS 带上内容哈希或者 SRISubresource Integrity值。通过监听emit遍历compilation.assets对每个 JS/CSS 文件计算sha384哈希然后生成一个新文件sri-hashes.json供后端的页面渲染服务读取并注入 HTML。这套方案绕开了“Webpack 内置哈希文件名可能被 index.html 引用”这个耦合限制独立生成映射表。而且因为emit阶段发生在内存中不涉及磁盘 IO整体性能影响几乎可以忽略。8.3 用生命周期拆分构建监控指标如果团队有性能监控平台你可以把生命周期关键阶段的时间戳统一上报组成“构建火焰图”。搭建方式很简单用compiler.hooks.compilation.tap监听compilation的各个子钩子把它们包成记录点const recordTiming (hook, label) { hook.tap(PerformanceTracker, () { timings.push({ label, time: Date.now() }); }); }; compiler.hooks.compilation.tap(PerformanceTracker, (compilation) { recordTiming(compilation.hooks.buildModule, buildModule); recordTiming(compilation.hooks.succeedModule, succeedModule); recordTiming(compilation.hooks.finishModules, finishModules); });这个方案的优点是零侵入不改变构建逻辑纯粹观测。有了数据之后你会很清楚瓶颈在 loader 解析还是 chunk 优化而不是像一个无头苍蝇一样乱试优化项。9. 关于 Webpack 生命周期我还想多说几句我在实际工作中发现真正能把 Webpack 生命周期用到出神入化的场景往往是“Webpack 本身不提供能力但由 Webpack 的扩展点自由组合出来”的地方。比如多页面应用打包时按路由拆包的自动化策略、低代码平台的构建时元信息注入、灰度发布时的产物指纹体系……这些能力没有一项是 Webpack 开箱即用的但都是通过生命周期各阶段的钩子组合实现的。如果你准备深入学习我的建议是不要只看文档和博客直接去读 Webpack 源码里lib/Compiler.js和lib/Compilation.js两个文件。虽然代码量大但它们的框架非常清晰compiler.hooks和compilation.hooks的定义就在文件开头。对照着本文梳理的阶段顺序去读你会看到每个钩子确实是在我描述的那个时机被触发的。这种“眼见为实”的体验比背一百篇教程都管用。最后分享一个小技巧。排查生命周期问题时最快的定位方法是临时加一个“日志插件”class LifecycleLogger { apply(compiler) { const hooks [ environment, afterEnvironment, entryOption, afterPlugins, afterResolvers, beforeRun, run, beforeCompile, compile, thisCompilation, compilation, make, finishMake, seal, ]; for (const name of hooks) { compiler.hooks[name]?.tap(LifecycleLogger, () { console.log([Lifecycle] ${name}); }); } } }把这段代码加到配置里跑一次构建终端里会清晰地打印出构建流程的先后顺序。遇到“我的插件为什么没生效”的时候先确认自己挂在哪个阶段再对照这个日志看触发顺序问题通常一目了然。这也是我个人最推荐的一个生命周期学习工具。