
Webpack应该是前端工程化里绕不开的一座大山。记得我第一次被它的构建日志搞懵的时候满屏都是看不懂的阶段名称什么schema-utils、seal、optimize根本不知道打包器内部在干什么。后来真正开始写自定义插件、做构建性能优化、排查那些只在特定条件下才出现的诡异问题才发现有一件事始终绕不过去——生命周期。你把Webpack生命周期搞明白了等于拿到了读懂构建过程的完整地图不再靠猜也不再靠网上零碎的配置贴片。这篇文章我就从底层机制讲起把Webpack从敲下命令到产物落地的整个过程拆开揉碎讲清楚每个阶段发生了什么、对应哪些钩子、能做什么事情并结合真实的插件代码演示怎么在合适的时机介入。不管你是正要进阶的前端工程师还是已经被打包速度折磨到怀疑人生的工程化负责人这篇文章都值得放慢速度读一遍。1. 先从整体把握Webpack生命周期到底在说什么1.1 一次打包过程到底经历了什么很多人用Webpack好几年配置也写得溜但被问一句Webpack是怎么一步一步把代码变成文件的就卡住了。这不怪你因为配置和生命周期是两个维度的东西。配置是你想让打包器做什么生命周期是打包器内部按什么顺序在做。两者结合你才能真正掌控构建过程。从命令行敲下npm run build开始Webpack经历了一条非常清晰的链路读取与合并配置CLI参数、webpack.config.js、默认配置合并成最终配置对象。创建Compiler对象这是全局的调度器一次构建过程只创建一个。执行插件apply方法所有插件在这里拿到compiler注册自己的钩子。编译准备初始化内置插件、设置Resolver模块解析器、读取Entry。编译构建从入口模块出发递归解析依赖用Loader处理模块生成模块依赖图。封装与优化将模块按规则合并成Chunk执行代码优化Tree Shaking、压缩等生成最终代码。输出产物把生成的代码写入输出目录。构建完成或失败以done或failed钩子收尾。这条链路每一步和下一步之间都存在明确的时机点这些时机点就是Webpack的生命周期钩子。插件的本质就是在这些时机点里挂一段自定义逻辑。理解这条链路是掌握生命周期的地基。1.2 两个核心对象Compiler 与 Compilation聊生命周期之前必须先把Compiler和Compilation这两个对象区分开否则后面所有钩子都会混在一起。网上的教程往往把两个混着讲让初学者越看越糊涂。我用一个形象点的类比Compiler是包工头。一次工程只雇一个包工头他从头到尾统筹全局——准备环境、安排流程、处理文件输出、报告完成情况。只要Webpack进程还活着包工头就一直负责调度。在watch模式下包工头一直常驻。Compilation是施工队。每次真正要盖一栋房子完成一次编译时包工头就新拉一支施工队。施工队负责模块解析、依赖收集、代码优化等具体活儿。如果watch模式下某个文件变了包工头会再拉一支全新的施工队重新走一遍编译流程而包工头本身不变。这个区别在实战里特别关键如果你要保存跨编译的状态应该挂在Compiler上如果你要处理某一次编译的具体产物应该通过Compilation来操作。很多人写插件时把两者搞混导致watch模式下状态错乱或钩子重复触发就是这个原因。1.3 生命周期钩子分布全景生命周期钩子分布在两个层级Compiler钩子和Compilation钩子。Compiler钩子调解整体流程Compilation钩子深入到模块、Chunk、Asset等细节。下面这张表是我常用的钩子速查表覆盖了主要流程阶段钩子名称触发时机常见用途配置初始化environment环境准备前设置环境变量配置初始化afterEnvironment环境准备后依赖注入配置初始化entryOptionentry配置处理修改入口配置初始化afterPlugins插件注册完成检查插件顺序编译准备beforeRun/run开始运行前/时启动日志、性能计时编译准备beforeCompile编译参数准备完成修改编译参数编译构建compile编译开始初始化Compilation编译构建thisCompilationCompilation创建挂载Compilation钩子编译构建compilationCompilation创建后挂载Compilation钩子编译构建make开始构建模块图添加额外入口编译构建afterCompile模块图完成检查模块图完整性产物生成shouldEmit决定是否输出按条件跳过输出产物生成emit输出前修改assets产物生成afterEmit输出后产物后处理产物生成done构建成功完成统计耗时、通知异常failed构建失败错误上报注意compile和compilation这两个钩子长得像但含义完全不同compile是编译要开始了的通知compilation是施工队已经组建好了的通知。前者阶段太早拿不到Compilation实例后者已经能拿到并操作Compilation了。后面写插件时这两者的差异会直接影响代码的正确性。2. Tapable撑起整个生命周期的底层机制2.1 为什么我们绕不开 Tapable看到这里你可能会有个疑问生命周期钩子这东西用Node自带的EventEmitter不就能实现吗为什么Webpack要自己造一套叫Tapable的机制EventEmitter确实能做事件分发但Webpack需要的远比触发事件、调用监听函数复杂得多。它需要支持同步和异步钩子、控制钩子的串行或并行执行、让前一个插件的返回值影响后一个插件、在插件执行过程中拦截和改写参数、给插件指定执行阶段优先级。这些诉求已经远超EventEmitter的能力范围。所以Webpack作者自己实现了Tapable库。可以把它理解为一个强化版的事件系统——核心思路还是发布订阅但把订阅者的执行规则细化成了好几种模式。理解了Tapable你看Webpack源码时就不会被各种tap、tapAsync、tapPromise搞晕写插件时也才能选择正确的方式。2.2 六种 Hook 类型与触发规则Tapable把Hook分成了三大方向同步、异步、流水线。每个方向根据返回值是否影响流程又做了细分。我整理成了一张对照表Hook类型执行模式特性典型场景SyncHook同步串行所有订阅依次执行返回值不传递日志通知、状态标记SyncBailHook同步串行某个订阅返回非undefined就中断后续配置校验、短路处理SyncWaterfallHook同步瀑布前一个订阅的返回值作为后一个参数配置逐级加工AsyncParallelHook异步并行并行执行所有订阅全部完成才继续并行读取文件AsyncSeriesHook异步串行按序执行所有订阅全部完成才继续Webpack主流程AsyncSeriesWaterfallHook异步瀑布按序执行且返回值向下传递逐级修改参数Webpack的Compiler和Compilation主流程基本用的是AsyncSeriesHook因为编译的每个阶段都有严格的前后依赖关系必须一个接一个地完成。而Emit阶段如果需要并行处理多个资源则可能用AsyncParallelHook。这里还要提醒一个新手常犯的错误不同的Hook类型支持不同的注册方式。SyncHook只能用tap注册AsyncSeriesHook可以用tap注册推荐简单也可以用tapAsync和tapPromise注册。如果你在一个同步钩子上用tapAsyncWebpack会直接报错而不会自动兼容。2.3 拦截器与 Stage 的实战价值Tapable除了Hook类型还有两个高阶特性在写复杂插件时特别有用拦截器Intercept和阶段Stage。拦截器允许你在钩子调用前后、订阅注册时插入额外逻辑。比如你想统计某个钩子下每个插件的执行耗时可以用intercept的call和loop方法统一处理不用给每个插件单独加计时代码。阶段调整更是解决插件执行顺序冲突的钥匙。同一个钩子上挂了多个插件默认按注册顺序执行。但如果你希望某个插件在其他插件之前或之后执行可以这样注册compiler.hooks.someHook.tap( { name: MyPlugin, stage: 100, // 数字越小越先执行默认是 0 }, () { // 插件逻辑 } );甚至有更细粒度的写法——用before: OtherPluginName显式指定在某个插件之前执行。这个技巧在多个插件同时操作assets时尤其重要能避免我的改动被别人的插件覆盖了这类问题。3. 核心生命周期节点逐个拆解3.1 配置初始化阶段从 environment 到 entryOptionWebpack启动后最先进入的是配置初始化阶段。这一阶段大多数人写的插件都没挂过钩子因为普通业务项目很少需要在这里干预。但如果你在做一些团队级的基础设施这些钩子非常有用。environment是Webpack准备环境之前触发afterEnvironment紧接着触发。这两个钩子都发生在webpack.config.js中的plugins被注册之前。什么意思呢在这一阶段你能拿到Compiler但配置里的其他插件还没有注册。所以如果某个插件需要在所有插件注册之前做点事比如设置环境变量、注入一些全局配置environment是唯一合适的位置。entryOption钩子则是在入口配置被解析后触发。它接收context和entry两个参数你可以在这里动态修改入口文件。例如在多页面项目中通过命令行参数决定只构建某些页面在entryOption里裁剪入口数组就很合适——比在webpack.config.js里做一堆条件判断更干净。afterPlugins和afterResolvers标志着内置插件和配置插件已经全部注册完毕、模块解析器也已经初始化完成。此时插件之间互相都能看到了。如果插件A依赖插件B提供的功能可以在afterPlugins里检查compiler上有没有B挂载的标记没有就打印警告。这个阶段的主题就是早。很多问题看似玄学其实都是因为介入时机太晚或太早。比如你想改Webpack的内置Resolver行为只能选afterResolvers之后通过resolve钩子改早了拿到的是还没初始化的对象晚了又抢不到执行权。3.2 编译构建阶段从 compile 到 afterCompile这是Webpack生命周期中最核心、最复杂的一段。如果你要排查构建性能问题基本都集中在这个阶段。compile钩子触发时Webpack正式进入编译流程它背后的操作是创建Compilation实例但我们作为插件开发者通常不直接用compile处理业务逻辑而是用compilation钩子拿到实例后挂载更细粒度的钩子。make是模块图构建的开始也是我认为最重要的一个生命周期节点。在make阶段Webpack从入口出发递归地解析每个模块文件内容读进来、Loader逐层处理、AST解析、依赖收集……所有这些耗时的活儿都发生在make阶段。make之后模块图Module Graph建立完成但还没生成代码。在make阶段有几个实用场景手动添加额外入口文件、注入虚拟模块、拦截某个依赖的解析结果。社区里不少工具都是靠make阶段的钩子实现能力的比如webpack-virtual-modules就是在这里把内存中的虚拟模块塞进编译流程。afterCompile表示模块图构建完成。如果要在模块图基础上做整体分析比如统计各模块体积、检查循环依赖afterCompile是第一个安全位置。再往下走模块就要进入封装和产物生成阶段了数据形态会变分析和改动的成本会更高。还有一个容易被忽略但极其重要的细节compilation阶段可以通过compilation.hooks挂载模块级钩子比如buildModule模块构建时、finishModules所有模块构建完成。如果你只想观察某个模块的Loader执行过程用buildModule比用Compiler级钩子更精准。3.3 产物生成阶段从 seal 到 done模块图构建完成后Webpack会进入一个叫seal密封的阶段。这个词很形象模块图被固定下来不再增删接下来所有操作都是在已有模块基础上做优化和生成。seal触发后Webpack会执行一串内部优化生成Chunk、合并模块、Tree Shaking、代码压缩、hash计算……这些操作有的是同步的有的是异步的。在Compilation上有一系列对应的优化钩子比如optimizeChunks、optimizeChunkModules、optimizeTree、optimizeAssets。如果你做过打包优化你调的optimization.minimize等配置本质就是在这些阶段插入压缩插件的执行逻辑。理解这一点后你就能明白为什么某些优化配置无法生效——很可能你操作的时间点不对比如在seal之前改Chunk结构结果被后续的optimize过程覆盖了。shouldEmit是产物输出前的最后一个闸门。这个钩子很特殊它返回false可以阻止Webpack写文件。比如在CI环境做仅检查构建结果不实际输出的验证时可以用这个钩子跳过磁盘写入省下大量I/O时间。emit钩子触发时所有产物内容都在compilation.assets里但还没写入磁盘。这是修改产物内容的最佳时机。你可以遍历assets对JS文件做额外处理、给CSS注入特定前缀、丢弃指定文件等。afterEmit在文件写入完成后触发适合做产物校验、上传CDN、发送通知。最后是done钩子。它在构建成功结束后触发参数是一个stats对象里面包含了这次构建的完整统计信息模块数量、Chunk数量、耗时、warnings、errors等。这是所有插件里最常用的钩子之一做构建耗时统计、结果打印、质量门禁都靠它。3.4 watch 模式下的特殊生命周期很多人日常开发用的是webpack --watch但没注意到watch模式下生命周期有一些特殊节点。如果不了解这几点写插件时容易在开发模式下翻车。watch模式下Webpack启动后不会直接执行完整的构建而是先触发watchRun钩子然后才开始第一次编译。之后每当有文件变化Webpack会先触发invalid钩子表示某个文件失效需要重新编译再触发watchRun然后重新走一遍compile到done的流程。这里有个关键点watch模式每次重新编译都会创建一个全新的Compilation。也就是说如果你在自定义插件里用compilation持有某个对象文件一变这个对象就被新的替换掉了。如果你需要跨编译共享状态比如统计一整天的构建频率一定要把状态挂在Compiler上而不是Compilation上。watchClose钩子在watch模式结束时触发适合做清理工作比如关掉插件内部打开的句柄、清除临时文件。我在一个持续集成的场景里就是利用invalid钩子做增量日志记录把哪些文件触发了重新编译写入独立的日志文件帮助定位谁在频繁改代码导致构建被反复触发。4. 实操写一个插件完整观测生命周期4.1 最小插件骨架理解了原理不写代码验证一下总觉得隔了一层。Webpack插件的基本形态很固定——一个带着apply方法的对象apply接收compiler参数。我通常这样写最小骨架class LifecycleObserver { apply(compiler) { // 这一步可以用 tap 注册同步钩子 compiler.hooks.environment.tap(LifecycleObserver, () { console.log([lifecycle] environment 准备环境); }); compiler.hooks.compile.tap(LifecycleObserver, () { console.log([lifecycle] compile 编译开始); }); compiler.hooks.make.tapAsync(LifecycleObserver, (compilation, callback) { console.log([lifecycle] make 开始构建模块图); callback(); }); compiler.hooks.done.tap(LifecycleObserver, (stats) { console.log([lifecycle] done 构建完成); }); } } module.exports LifecycleObserver;注意我故意混合了tap和tapAsync两种方式——这是实际开发中常见的情况。make钩子是异步串行钩子必须调用callback通知Webpack继续下一步。如果忘了调用构建会直接卡死。回调不调用是Webpack插件开发里最经典的卡死原因没有之一。把LifecycleObserver加进webpack.config.js的plugins数组跑一次构建你会在屏幕上看到生命周期钩子按顺序打出来的日志。这一步做完你对生命周期就有了最直观的体感。4.2 用生命周期钩子做构建性能分析最常见的生命周期应用场景就是统计构建耗时。市面上很多性能分析工具的核心原理其实很简单就是在关键钩子之间埋计时点。我写过一个极简的耗时统计插件给团队排查慢构建用的整个逻辑不到三十行class BuildTimer { constructor() { this.timings {}; this.currentStage null; } apply(compiler) { const mark (name) { const now Date.now(); if (this.currentStage) { this.timings[this.currentStage] now - (this.timings[this.currentStage _start] || now); } this.currentStage name; this.timings[name _start] now; }; compiler.hooks.compile.tap(BuildTimer, () mark(compile)); compiler.hooks.make.tapAsync(BuildTimer, (compilation, cb) { mark(make); // 在 make 内监听模块构建的耗时 compilation.hooks.finishModules.tap(BuildTimer, () mark(finishModules)); cb(); }); compiler.hooks.seal.tap(BuildTimer, () mark(seal)); compiler.hooks.emit.tap(BuildTimer, () mark(emit)); compiler.hooks.done.tap(BuildTimer, (stats) { mark(done); console.log(构建耗时分布:, this.timings); }); } }把这个插件装上之后你会看到每个生命周期阶段的耗时分布比如make占了总耗时80%那问题基本就锁定在模块解析/loader执行上如果emit耗时高问题在写盘或产物处理上。有了耗时分布优化就不再是盲人摸象了。4.3 在产物生成阶段做资源加工生命周期不只能看还能改。在emit阶段修改产物内容是打包器留给插件的最佳介入点。我曾经遇到一个需求给所有构建产物加一个版本注释并且把某几个历史版本文件单独打包备份。用生命周期钩子做非常顺const { RawSource } require(webpack).sources; class VersionBannerPlugin { constructor(options) { this.version options.version || unknown; } apply(compiler) { compiler.hooks.emit.tap(VersionBannerPlugin, (compilation) { const assets compilation.getAssets(); for (const asset of assets) { if (asset.name.endsWith(.js)) { const content asset.source.source(); const banner /* version ${this.version} */\n; compilation.updateAsset(asset.name, new RawSource(banner content)); } } }); } }这里几个细节值得展开。第一我用的compilation.updateAsset是Webpack 5的标准接口而不是直接改asset.source——直接改虽然也能生效但不符合Webpack的资产管理系统规范后续如果有其他插件再读assets可能拿到不一致的数据。第二RawSource来自webpack.sources这是官方提供的数据结构必须用它包装后才能交给Webpack。这类需求在实际项目中特别多给JS加版本号、给CSS注入CDN前缀、把sourcemap单独挪走、过滤掉非必要语言包。全都是同一个套路找到合适的生命周期节点操作compilation.assets。5. 常见问题与排查心得5.1 钩子不触发先检查这三件事写Webpack插件最痛苦的事情不是逻辑写错而是代码根本就没执行。钩子不触发通常逃不出下面三个原因。第一个原因注册方式与钩子类型不匹配。同步钩子用了tapAsync或tapPromiseWebpack直接抛错插件内部逻辑一概不执行。解决方法是查清楚目标钩子是Sync还是Async然后选对应的注册方法。不知道类型的时候可以搜Webpack源码里的new AsyncSeriesHook之类的代码或者看官方文档的Hook类型说明。第二个原因注册时机太晚。比如在compile钩子里才想挂make钩子——不行。make在compile之后立刻触发你挂在compile里的逻辑要等compile回调执行完才生效而make早就跑过了。这类问题最隐蔽因为代码不报错只是静默失效。我的经验是能用Compiler级钩子解决就不要用Compilation级钩子能提前挂就尽量提前挂。第三个原因插件没有导出apply方法。Webpack通过检查插件对象是否有apply方法并调用它来实现注册如果插件类忘了写applyWebpack会静默忽略不报错也不执行。排查时可以先在apply第一行打日志确认插件有没有被调用到。5.2 阶段操作错误导致的经典事故有一种非常常见的开发事故在错误的生命周期阶段操作了尚未生成或已被锁定的数据。我总结了几个典型案例。在compile阶段尝试读取compilation.assets——此时Compilation才刚刚创建assets几乎是空的你拿到的自然是空对象。更麻烦的是这不会报错代码运行得挺正常只是结果完全不符合预期。我的习惯是凡是涉及产物内容先确认当前钩子是不是在seal或emit之后。在emit阶段修改Chunk结构——emit是产物输出前的钩子此时Chunk已经被优化并封装进assets你在compilation.chunks上做的任何改动都不会同步到assets里。如果要做Chunk级改动应该在optimizeChunks这类更早的钩子里做如果只是改最终文件内容应该在emit里直接操作assets。还有一个隐蔽问题在watch模式下同一个Compiler上的done钩子会被多次触发。如果你在done里做一次性操作比如发送通知、输出完整报告每次文件保存都会触发一遍。要控制频率可以在插件里加时间戳判断或者利用done回调里的stats.hasErrors()先做判断避免把构建警告误报成错误。5.3 几个实用的调试技巧排查生命周期相关问题时我总结了几个特别实用的调试手段。用stats对象代替猜。在done钩子里打印stats.toString({ modules: true, chunks: true, assets: true })你能看到这次构建里Webpack到底认识哪些模块、生成哪些Chunk、产出哪些文件远比肉眼观察终端输出可靠。给钩子加临时日志。怀疑某个阶段没走到时在所有关键钩子入口加一行console.log([pluginName] hookName 触发了)。日志要注意带时间戳或插件名多个插件的日志混在一起时能分清来源。利用Node调试器。如果插件逻辑过于复杂比如你发现某些钩子触发了但没走到预期分支用node --inspect-brk启动构建在Chrome DevTools里对插件代码打断点一步步看compiler和compilation对象上的数据。这种看得见数据的方式往往比看日志定位问题快得多。6. 给后来者的一些经验把Webpack生命周期原理弄清楚之后最大的变化是看待构建报错的视角完全不同了。以前遇到某某插件不起作用只能靠搜索和乱猜现在会先判断它应该挂在哪个钩子上是Compiler级还是Compilation级是同步还是异步再看它在生命周期链路上处于什么位置——往往一眼就能找出问题。我自己在反复踩坑里形成的几个习惯在这里分享给大家写插件前先画一条生命周期顺序图确认自己介入的节点同步能解决的事情不要用异步钩子操作assets之前先确认拿到的是不是最新数据凡是涉及watch模式的功能务必考虑Compilation被反复重建的情况。这些习惯帮我少踩了无数个坑有心的读者完全可以在实际项目里复述一遍这些判断逻辑。Webpack的生命周期确实复杂但它不是靠死记硬背就能掌握的。最好的方式是自己动手写一个带日志的插件把构建过程完整地看一遍然后再回到文档或源码里去验证自己的理解。跑通一次之后你会觉得Webpack从一只黑盒子变成了一台你能打开机箱看清楚齿轮如何咬合的机器。