
1. 为什么必须吃透loader和pluginwebpack的构建流水线里它们各管哪一段1.1 从一次打包说起webpack真正做了哪几件事先放下自定义loader和plugin这件事回到最基础的场景你执行npm run buildwebpack到底在后台干了什么如果你只是会配置babel-loader和html-webpack-plugin那对webpack的理解始终是模糊的。把一次构建拆开看大致是这几步读入口文件解析模块依赖关系然后对每个文件做转换接着根据依赖图把所有模块组合成chunk最后把chunk写入输出目录。转换这一步主要交给loader而从解析到生成的整个过程里webpack把各种时机暴露成钩子也就是plugin发挥作用的地方。我见过不少同事在配置里堆了一长串loader还搞得明白但一旦遇到我要在这个文件产物里额外加一段注释我想把某个目录下的文件内容编译成一个JSON这种非标准需求就卡住了。因为标准需求都有现成的包而偏门需求必须自己动手。你一旦明白loader是把文件内容从A变到B的工具plugin是在构建生命周期某个时机做额外操作的能力思路一下子就通了。loader本质上是导出一个函数的模块这个函数接收文件源码作为输入返回处理后的字符串或者Buffer。它和普通函数最大的区别是函数体内的this被webpack注入了很多有用的上下文——比如this.callback、this.async()、this.getOptions()以及this.context这样的元信息。所以写loader时千万不要用箭头函数否则就拿不到webpack给你的这套上下文了。这一点新手踩坑率极高。plugin本质上是导出一个类的模块类里有一个apply方法。webpack在启动后会把一个叫compiler的对象传给apply这个对象暴露了webpack整个生命周期的事件钩子。只要学会挂载合适的钩子你就能在编译启动前生成资源前写入磁盘后等节点插入自己的逻辑。loader解决的是文件内容层面的转换plugin解决的是构建流程层面的编排两者职责完全不同选错工具是很多设计问题的根源。1.2 loader做不了的事为什么必须交给plugin有人会问我能不能在loader里往输出目录写一个文件技术上loader确实可以通过require(fs)直接写磁盘但这并不是webpack的构建逻辑它绕过了webpack对产物资源的管理比如watch模式下文件的变动、增量构建时清理老文件等这些机制全都失效了。正确的思路是一切往最终产物里塞东西的操作都尽量在plugin里做通过操作compilation.assets来实现。compilation.assets是webpack对即将输出文件的内存映射往里面添加一个键值对webpack就会在emit阶段把它当作正式资源输出。这样和clean-webpack-plugin、html-webpack-plugin这些工具的配合也不会出现偏差。再举一个例子你想在每个JS文件头部加上构建时间戳。如果用loader做你得在规则里匹配所有JS文件然后逐个处理处理过程中还要小心别把sourceMap搞坏。但用plugin的compilation.hooks.processAssets钩子你可以拿到一份完整的所有资源清单统一遍历、统一修改改完再写回assets。这是一个典型的loader做不到干净、plugin做得漂亮的场景。所以我的建议是拿到需求先判断一下它属于内容转换还是流程编排。比如把TypeScript编译成JavaScript、把Sass编译成CSS、给代码替换占位符这些都是内容级操作写loader。但清空输出目录、生成额外文件、注入全局变量、统计构建耗时这些属于流程级操作写plugin。想清楚这条分界线后面每一步都顺了。2. loader实操手写一个能按需替换代码占位标记的loader2.1 最小loader理解输入文件内容输出新内容这个模型直接上手写代码。我在某个跨端项目里需要处理一种需求代码里写了// PLATFORM这种占位标记打包时需要根据构建目标替换成ios、android或web。一开始我用的是string-replace-loader后来需求越来越复杂——要做严格匹配、要支持多个占位符、要带警告日志于是干脆自己写一个。先看最简版本// mark-replace-loader.js module.exports function (source) { return source.replace(/\/\/ PLATFORM/g, // platform: ${process.env.BUILD_TARGET || web}); };配置文件里这样用// webpack.config.js module.exports { module: { rules: [ { test: /\.js$/, use: [ { loader: path.resolve(__dirname, mark-replace-loader.js), }, ], }, ], }, };这个loader会在所有JS文件被处理时把所有带PLATFORM的注释替换掉。就这么简单你已经拥有一个能用的自定义loader了。但很快你会发现问题这个loader没有接收任何参数硬编码了BUILD_TARGET复用性很差。真正写loader要做到只做转换不做决策决策应该由使用方通过options传进来。2.2 支持配置项用loader-utils接收用户参数webpack 5里推荐用loader-utils或者直接读this.getOptions()。我习惯用this.getOptions()它已经内置在loader的上下文里语法更简洁// mark-replace-loader.js module.exports function (source) { const options this.getOptions() || {}; const { placeholder /\\/\\/ PLATFORM/, replacement web } options; const replaced source.replace(new RegExp(placeholder, g), replacement); // 如果没有任何匹配给个警告方便排查 if (replaced source) { this.emitWarning(new Error(没有找到占位标记: ${placeholder}请检查文件是否写错)); } return replaced; };配置文件变成// webpack.config.js use: [ { loader: path.resolve(__dirname, mark-replace-loader.js), options: { placeholder: \\/\\/ PLATFORM, replacement: // platform: ios, }, }, ],到这里loader已经具备基本的参数化能力了。这里有个细节值得注意this.emitWarning是loader专属的能力它会把警告信息挂到编译统计里最终在webpack的构建输出里展示。如果只是用console.warn警告会淹没在终端日志里别人根本不知道哪里出了问题。用emitWarning之后你可以在命令行看到WARNING in ./src/index.js的统计信息也能被CI脚本通过stats.hasWarnings()捕获。2.3 异步场景与sourceMap把信息传全很多loader处理过程不需要异步但如果你的转换逻辑里包含了文件读取、网络请求、或者调用某个异步API那就不能直接return了。异步loader的标准写法是用this.async()获取一个callback// async-markdown-loader.js const marked require(marked); module.exports function (source) { const callback this.async(); // 模拟一个异步转换流程 setTimeout(() { try { const html marked.parse(source); callback(null, html); } catch (err) { callback(err); } }, 10); };这种写法的好处是错误处理路径非常明确第一参数传错误对象第二参数传转换结果。注意异步loader里不是return结果而是把结果交给callback。除了结果还需要考虑sourceMap。webpack构建链路里每个loader都可能产生新的sourceMap而webpack需要把它一层层传递下去才能保证浏览器里调试时看到的是原始源码。如果你把文件内容改了却不传sourceMap调试时看到的就是一团混淆后的代码。正确的做法是用this.callbackmodule.exports function (source, inputSourceMap) { const options this.getOptions(); const output transform(source, options); this.callback(null, output, outputSourceMap, meta); };这里四个参数分别是错误对象、处理后的源码、处理后的sourceMap、其他附加元数据。我第一次写loader时偷懒没有传sourceMap结果用webpack-dev-server调试时样式定位全是乱码排查了半天才发现是loader没把map传下去。所以哪怕是个很小的替换loader也建议用this.callback传一遍把sourceMap原样往下游传递基本没有成本module.exports function (source, inputSourceMap) { const output transform(source); this.callback(null, output, inputSourceMap); };2.4 缓存与执行顺序藏着最多坑的地方webpack默认会缓存loader的处理结果只要文件内容没变第二次构建会直接复用上次的转换结果这能大幅提升构建速度。但这也意味着如果你的loader依赖了文件内容以外的其他变量——比如系统环境变量、某个配置文件的读取结果——那么这些外部依赖的变化不会触发缓存失效。解决办法有两种。第一种是在loader里手动声明依赖文件让webpack把被声明的文件纳入watch的监视范围module.exports function (source) { const configPath path.resolve(__dirname, mark.config.js); this.addDependency(configPath); const config require(configPath); // ... return transform(source, config); };第二种是干脆关掉缓存。比如我的mark-replace-loader读取process.env.BUILD_TARGET但webpack只知道它没有依赖文件变化实际上环境变量变了它也不会重新执行。保险起见可以这样module.exports function (source) { this.cacheable(false); // ... };cacheable(false)让该loader的结果永远不缓存代价是每次构建都要重新执行对于替换文本这种轻量操作完全可接受。这个取舍一定要知道否则你会遇到改了配置但构建结果不变的诡异问题其实是缓存把你坑了。3. plugin实操手写一个每次构建都会生成的构建信息插件3.1 先理解compiler与compilationplugin的两个核心对象写plugin之前必须分清compiler和compilation这两个对象这是很多人糊涂的根源。compiler是webpack的主控对象一次完整的构建过程对应一个compiler实例。它暴露的事件包括run开始编译、watchRunwatch模式下重新编译、emit即将输出资源、done构建完成等。一般来说compiler层面的钩子用来处理整一次构建级别的事情。compilation则是依赖图遍历、模块编译、资源生成的中央调度器它代表这一次构建中所有模块和资源的集合。编译过程中每处理一个模块、每生成一个chunk都能在compilation的钩子上看到事件。如果你要做的事情和当前这批资源相关大概率要用compilation的钩子。拿一个最直观的例子我想在每次构建结束后生成一个包含构建时间、产物大小、模块数量、本次构建的git commit号的build-info.json。这个需求里构建时间、产物信息都是本次构建级别的数据所以用compiler.hooks.done钩子最合适。3.2 用emit钩子写一个BuildInfoPlugin先看emit钩子的版本。emit在所有模块编译完成、即将把资源写入输出目录之前触发所以它是往compilation.assets里添加额外文件的最佳时机。// BuildInfoPlugin.js const fs require(fs); const path require(path); class BuildInfoPlugin { apply(compiler) { compiler.hooks.emit.tapAsync(BuildInfoPlugin, (compilation, callback) { const stats compilation.getStats().toJson({ all: false, chunks: true, assets: true, errors: true, }); const info { time: new Date().toISOString(), commitHash: this.getCommitHash(), assetsCount: Object.keys(compilation.assets).length, chunks: stats.chunks?.map((c) ({ id: c.id, files: c.files, })), }; const json JSON.stringify(info, null, 2); compilation.assets[build-info.json] { source: () json, size: () json.length, }; callback(); }); } getCommitHash() { try { const hash require(child_process) .execSync(git rev-parse HEAD, { cwd: process.cwd() }) .toString() .trim(); return hash; } catch (e) { return unknown; } } } module.exports BuildInfoPlugin;这段代码有几个关键点第一compilation.assets里塞的对象必须实现source()和size()两个方法。source()返回内容字符串size()返回内容长度。很多新手只写了source忘写sizewebpack在计算hash或者压缩时就会报错。第二回调函数callback()必须被调用。如果你用的是tapAsync忘记调用callback会导致整个构建卡死而且终端没有任何报错。同样是异步场景如果你更喜欢Promise的写法可以用tapPromisecompiler.hooks.emit.tapPromise(BuildInfoPlugin, (compilation) { return Promise.resolve().then(() { // 做一些异步操作 compilation.assets[build-info.json] { ... }; }); });第三在apply方法里不要用箭头函数绑定this否则插件内部方法访问this.getCommitHash()时会丢失上下文。老实写普通函数加原型方法是最安全的。3.3 从emit到done两个钩子的取舍有人会问emit和done看起来都能拿到构建信息选哪个我的经验是如果你需要把生成的文件作为webpack产物输出一定用emit或processAssets因为done触发时资源已经写入磁盘了再修改没有意义。但如果只是想记录构建日志、推送通知、或者做一些构建后的统计上报done更合适。因为done回调里能拿到完整的stats对象里面包含了构建错误、警告、耗时等信息而且此时构建已经成功或失败你可以根据结果做不同处理。还有一个非常实用的组合watchRun钩子。在watch模式下每次文件变化触发重新构建之前watchRun会先执行。我之前的BuildInfoPlugin会在里面打印 正在重新构建... 的日志方便知道当前是不是一直在监听状态。run和watchRun的区别在于run只在第一次普通构建时触发而watchRun是watch模式下独有的。3.4 往compilation.assets里添加文件时要注意的细节往compilation.assets添加文件看似简单但有几个细节直接决定插件能不能用。第一个是文件名不要和已有资源冲突。如果你写的名字和某个entry输出的chunk重名webpack会发生覆盖而且没有警告行为非常隐蔽。建议在实际项目里加上时间戳或者hash片段。第二个是processAssets钩子的出现。webpack 5以后官方更推荐用compilation.hooks.processAssets配合Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL这类阶段常量来操作资源因为它能精确控制在资源处理的哪一个阶段插入。不过对大多数场景来说emit仍然可用兼容性也更好。如果你要在webpack 5上做更细致的资源操作优先查阅processAssets文档。4. 调试与测试给自定义loader和plugin装上安全网4.1 调试loader和plugin的两种有效姿势写loader和plugin最常犯的错是写完了往项目里一接构建报错然后只能靠printf调试法——到处console.log跑一遍构建再看终端输出。这种方式效率很低因为loader和plugin的执行时机在webpack内部console.log的位置不好放。更高效的方式是用Node.js的调试器。先在你自己的入口文件比如webpack.config.js所在的目录执行node --inspect-brk node_modules/webpack/bin/webpack.js --config webpack.config.js这个命令会在webpack启动的第一行就暂停然后你用Chrome浏览器打开chrome://inspect在对应进程上点击inspect就能打开DevTools逐行调试了。你可以在loader函数的第一行、plugin的apply方法、某个钩子的回调里分别打断点配合右侧的Call Stack能完整看到webpack是带着什么参数调用你的代码的。这个调试体验比console.log快了不止一个量级。我还发现一个很实用的技巧在loader里使用this.debug拿一个调试工具对象它能打印出当前文件路径、loader执行链等信息。配合webpack的--stats verbose参数可以看到每个loader的执行顺序和耗时分布。4.2 用内存文件系统写自动化测试开发过程中不可能每次都跑一条完整构建来验证loader和plugin。更靠谱的方式是写一个小的测试脚本用webpack的Node接口搭配内存文件系统快速跑一次微型构建。先配置一个测试用的webpack编译器// test/helper.js const webpack require(webpack); const MemoryFileSystem require(memory-fs); function runLoaderTest(entryFile, config) { const compiler webpack({ mode: development, entry: entryFile, output: { path: /dist, filename: bundle.js, }, module: { rules: [ { test: /\.js$/, use: [config.loader], }, ], }, plugins: config.plugins || [], }); compiler.outputFileSystem new MemoryFileSystem(); return new Promise((resolve, reject) { compiler.run((err, stats) { if (err) return reject(err); if (stats.hasErrors()) return reject(new Error(stats.toString())); const outputFs compiler.outputFileSystem; const files outputFs.readdirSync(/dist); const content outputFs.readFileSync(/dist/bundle.js, utf-8); resolve({ files, content }); }); }); }然后写具体用例// test/mark-replace-loader.test.js const assert require(assert); const { runLoaderTest } require(./helper); it(应该替换占位标记, async () { const { content } await runLoaderTest(./fixtures/input.js, { loader: { loader: path.resolve(__dirname, ../mark-replace-loader.js), options: { placeholder: VERSION, replacement: 1.2.3, }, }, }); assert(content.includes(1.2.3)); });用memory-fs的好处是整个测试不走真实磁盘速度非常快每次用例都开一个全新的compiler实例互不污染。我项目里把这类测试挂到npm test上每次改动loader或plugin都会自动跑一遍回归问题能第一时间被拦下来。测试用例至少覆盖三类情况正常转换、无匹配时的警告逻辑、异步loader的延迟callback。这三类各自都容易挂。5. 进阶细节与常见坑从能跑到可靠5.1 loader的执行顺序与pitch机制别再背错了loader执行顺序是面试和实际开发里都绕不开的点。如果你在rule里配置了多个loader比如{ test: /\.css$/, use: [style-loader, css-loader, sass-loader], }它们的执行顺序是从右往左的先sass-loader把SCSS编译成CSS再css-loader处理CSS里的url()、import等最后style-loader把CSS注入到DOM。很多人记成从左到右实际正好相反。你可以这么记每个loader输出的结果会成为传给左边下一个loader的输入。这个过程和函数嵌套求值非常像styleLoader(cssLoader(sassLoader(source)))。中间还有一个pitch机制。每个loader还有一个可选的pitch方法它的执行顺序是从左往右的和普通方法正好相反。如果我给上面的三个loader都加了pitch顺序就是style-loader.pitch→css-loader.pitch→sass-loader.pitch然后才开始从右往左执行普通方法。pitch到底有什么用最重要的一点是它可以在loader执行链的中途提前返回。如果某个loader的pitch直接返回了一段内容webpack就会跳过它后面所有loader把这个返回值当作最终结果。经典的例子是style-loader使用pitch来判断当前CSS是否要强制作为模块注入还是当作文本处理。我在自己写的mark-replace-loader里也用过这个技巧如果配置里有skip: truepitch直接返回source后面所有转换全部跳过这在某些条件编译场景下很实用。5.2 plugin钩子的常用组合套路plugin开发中钩子的选择直接决定功能能否实现。我把自己平时常用的几个钩子整理了一下钩子触发时机典型用途run普通构建启动初始化插件状态、打印日志watchRunwatch模式下重新构建计数、增量日志normalModuleFactory模块解析前修改模块解析规则emit资源即将写入磁盘往assets里添加文件processAssets资源处理阶段遍历、修改所有资源done构建完成构建报告、上报统计failed构建失败错误通知、日志落盘关于processAssets我要多说一句。webpack 5里资源处理被拆成了很多阶段比如PROCESS_ASSETS_STAGE_ADDITIONAL添加额外资源、PROCESS_ASSETS_STAGE_OPTIMIZE优化资源内容、PROCESS_ASSETS_STAGE_REPORT生成统计报告。如果你之后要写一个压缩器应该挂在PROCESS_ASSETS_STAGE_OPTIMIZE如果想生成一份资源清单应该挂在PROCESS_ASSETS_STAGE_REPORT。它比emit精细得多也推荐优先使用。常见的一个组合套路是normalModuleFactory钩子 compilation钩子联动。比如我想让某个目录下的模块在解析时自动被注入一个公共代码或者用自定义的解析器处理特殊后缀的文件就需要在normalModuleFactory里tap一个resolver事件。另一个和compilation的配合是监听compilation事件拿到这次的compilation对象然后在compilation.hooks.optimizeChunkAssets里做chunk级别的资源调整。这套组合对递归理解webpack内部机制很有帮助。5.3 我踩过的最值得提醒的五个坑第一个坑是箭头函数。写loader时如果用箭头函数定义主函数会导致this指向错误——不是说你拿不到this.getOptions()而是拿到的可能是undefined或者别的对象。同样在plugin里用箭头函数写apply方法内的回调也会导致this绑定的插件实例丢失。我早期写过一段apply: () { ... }的插件结果内部所有this都是undefined排查了半天才反应过来。规则很简单loader的导出函数、plugin的构造函数和apply方法都用普通函数。第二个坑是不注意异步信号。async loader如果既没有调用callback又没有returnwebpack会一直等下去整个构建卡住终端没有任何提示。同理tapAsync的插件如果忘了调用callback也会静默卡死。这种问题最难排查因为不是报错而是挂起。我的建议是在所有异步回调的第一行写一个强制计时器比如const t setTimeout(() console.warn(可能忘记调用callback了), 5000)正常结束后clearTimeout这样万一忘了5秒后你能收到警示。第三个坑是loader缓存未失效。这个前面提过外部配置变化不会导致缓存失效。我在多个项目里都遇到过改了某个配置文件重启dev server后构建还在用旧缓存。解决办法要么是this.cacheable(false)要么把依赖文件通过this.addDependency()注册进webpack的watch列表让webpack知道你依赖了它。第四个坑是坑了sourceMap。每一个修改代码的loader都必须尽可能把sourceMap往下传否则就是断了一环后面的loader再努力也补不回来完整map。尤其在开发模式下map不全会导致浏览器定位到的是编译后代码排查问题效率极低。注意sourceMap的传递不是把inputSourceMap原样返回就完事了如果loader修改了源码结构要执行source-map的SourceMapGenerator做一次映射转换。对于替换类loader通常直接传原map还好但涉及AST改写时就必须认真处理。第五个坑是文件名冲突。写入compilation.assets时如果文件名和已有资源冲突webpack会默默覆盖且不会给你任何警告。这会导致线上页面出现诡异的问题明明输出的文件内容是对的加载却始终是旧的。我自己遇到过build-info.json被某个静态资源同样命名覆盖的情况排查到崩溃。建议写插件时在文件名里加上构建时间戳或者使用compilation.getPath()模板变量避免通过硬编码名字和其他资源打架。最后分享一个我自己多年总结下来的经验写loader和plugin的时候把日志可见性放在跟功能同等重要的位置。一个可靠的自定义loader应当支持通过this.emitWarning和this.emitError把所有异常情况暴露到构建统计里一个可靠的自定义plugin应当通过stats和compilation.warnings完整记录它的行为。这样即便半年后有人再跑一条构建也能从终端日志里一眼看出你的扩展在做什么、有没有出问题。这比功能本身更能决定一个工具能不能被团队长期用下去。