1. 为什么必须吃透loader和plugin:webpack的构建流水线里它们各管哪一段
1.1 从一次打包说起:webpack真正做了哪几件事
先放下自定义loader和plugin这件事,回到最基础的场景:你执行npm run build,webpack到底在后台干了什么?如果你只是会配置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实操:手写一个能按需替换代码占位标记的loader
2.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.callback:
module.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与compilation:plugin的两个核心对象
写plugin之前,必须分清compiler和compilation这两个对象,这是很多人糊涂的根源。
compiler是webpack的主控对象,一次完整的构建过程对应一个compiler实例。它暴露的事件包括run(开始编译)、watchRun(watch模式下重新编译)、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忘写size,webpack在计算hash或者压缩时就会报错。
第二,回调函数callback()必须被调用。如果你用的是tapAsync,忘记调用callback会导致整个构建卡死,而且终端没有任何报错。同样是异步场景,如果你更喜欢Promise的写法,可以用tapPromise:
compiler.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: true,pitch直接返回source,后面所有转换全部跳过,这在某些条件编译场景下很实用。
5.2 plugin钩子的常用组合套路
plugin开发中,钩子的选择直接决定功能能否实现。我把自己平时常用的几个钩子整理了一下:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
run | 普通构建启动 | 初始化插件状态、打印日志 |
watchRun | watch模式下重新构建 | 计数、增量日志 |
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又没有return,webpack会一直等下去,整个构建卡住,终端没有任何提示。同理,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完整记录它的行为。这样,即便半年后有人再跑一条构建,也能从终端日志里一眼看出你的扩展在做什么、有没有出问题。这比功能本身更能决定一个工具能不能被团队长期用下去。