
不要从“我”开始建议不要使用“本文”、“我”等第一人称视角作为开篇方式切换到更客观、中立的叙述口吻会更好。下面我调整一下重写开头给你一个更干净、更自然的版本。webpack作为前端打包工具里的老大哥几乎每个项目都绕不开它。但很多人对它的认知停留在“会用配置”层面一旦遇到定制化需求就开始发怵。其实webpack最核心的扩展点就是两个loader和plugin。写清楚这两块基本就摸到了webpack的骨架。这篇文章不打算系统地讲webpack的全部原理而是聚焦在“如何自己动手写一个loader和plugin”这件事上。内容包含两个角色的定位差异、各自的开发方式、调试技巧、真实场景中的踩坑记录以及一套完整可运行的demo。适合已经用过webpack、但没写过扩展的开发者也适合想深入理解webpack构建机制的人。如果你觉得loader和plugin是“大佬才玩的”那这篇文章就是想告诉你它们没那么神秘掌握几个关键点你也能写。1. 先搞清楚loader和plugin到底分工是什么很多人写webpack配置没问题但一说到loader和plugin的区别就开始含糊。这不是概念问题是理解构建流程的问题。1.1 loader是“翻译官”plugin是“操盘手”webpack本身只认识JavaScript模块其他类型的文件CSS、图片、字体、markdown、SVG等都得通过loader转换成webpack能处理的模块。loader的定位就是“转换器”。它接收一个文件内容字符串或Buffer经过处理后把结果返回给webpack。这个过程是链式的多个loader可以串联每个loader只处理上一步的结果像流水线一样。比如处理scss文件时常见组合是sass-loader - css-loader - style-loader每一级只做自己的事。plugin的定位完全不同。它不直接处理文件内容而是通过webpack在整个构建生命周期中暴露的钩子hooks介入去影响构建行为。比如在打包前清理目录、在生成文件后做压缩、在产物中注入环境变量、分析包体积等操作都是plugin干的活。简单打个比方loader是在流水线上加工零件plugin则是在流水线关键节点安装传感器、机械臂去监控、干预甚至改变整个流水线的运行方式。1.2 为什么说plugin能做的事比loader多得多因为plugin可以访问compiler和compilation对象这两个对象几乎暴露了webpack运行时的全部关键信息。从解析模块、生成chunk、优化产物、输出文件到监听构建结束每一步都有对应的hook可以介入。loader只能处理单个模块维度的转换它拿不到“全项目”的视角。plugin则站在更高维度可以拿到整个构建的所有模块信息、chunk信息、产物信息甚至可以改变最终输出结果。这也是为什么loader复用于文件转换而plugin负责所有“流程级”的逻辑。理解了这个分工你写扩展的时候才不会用错工具。文件内容需要变选loader构建流程需要变选plugin。2. 手写loader从零开始做一个markdown转HTML的loader写loader其实门槛很低本质就是一个函数。但要把loader写得稳、写得专业还是有不少要点。2.1 loader的基础结构loader是一个导出为函数的Node模块webpack会通过loader-utils和schema-utils这两个工具包提供解析参数和校验参数的能力。基础结构如下// md-loader.js const { getOptions } require(loader-utils); const { validate } require(schema-utils); const schema { type: object, properties: { excerpt: { type: boolean } } }; module.exports function (source) { const options getOptions(this); validate(schema, options, md-loader); // 这里是一段简化的markdown转HTML逻辑 const html source .replace(/^###### (.*)$/gm, h6$1/h6) .replace(/^##### (.*)$/gm, h5$1/h5) .replace(/^#### (.*)$/gm, h4$1/h4) .replace(/^### (.*)$/gm, h3$1/h3) .replace(/^## (.*)$/gm, h2$1/h2) .replace(/^# (.*)$/gm, h1$1/h1); return module.exports ${JSON.stringify(html)}; };注意看这个函数不能写成箭头函数因为webpack调用loader时会把this指向一个包含许多上下文属性和方法的对象比如this.callback、this.async、this.emitFile、this.resourcePath、this.query等。用箭头函数会丢失这些能力。2.2 函数签名里的“this”是核心很多loader新手用不好就是因为没搞懂this上挂了什么。这里整理几个最常用的this.callback比return更灵活可以同时返回多个结果比如内容加sourceMapthis.async标记loader为异步处理那些需要读文件、请求接口的场景this.emitFile在产物中额外生成一个文件this.resourcePath当前模块的绝对路径this.getOptions()获取配置中传入的options我最早写loader时一直搞不懂为什么有时候要return有时候要调callback。后来才明白如果你不需要sourceMap直接return内容就行如果你需要返回sourceMap或meta信息就必须用callback。// 带sourceMap的loader写法 module.exports function (source, map, meta) { const options this.getOptions(); // 处理source内容 const result doTransform(source, options); this.callback(null, result, map, meta); return undefined; };2.3 同步loader和异步loader的选择默认loader是同步的但并非所有场景都适合同步处理。比如某个loader里需要读取多个文件才能生成最终结果同步写法会阻塞整个构建这时就要用异步loader。// 异步loader示例 module.exports function (source) { const callback this.async(); setTimeout(() { const result source.split().reverse().join(); callback(null, result); }, 100); };异步loader的核心是this.async()它会返回一个回调函数。调用后webpack会暂停等待直到回调被调用才继续往下走。这个机制对处理IO密集型的loader特别重要。2.4 loader里如何处理文件路径和资源有些loader不只是转换文本还要生成辅助文件。比如一个把CSS样式提取成独立文件的loader就需要用到this.emitFile。// 一个简单的style-extract-loader示例 module.exports function (source) { const filename styles/output.css; this.emitFile(filename, source); return module.exports ${JSON.stringify(filename)}; };用这种方式构建后的产物目录里就会多出一个styles/output.css文件。要注意的是this.emitFile的路径是相对于webpack配置的output.path的。2.5 千万不要做的几件事写loader的过程中有几个坑我踩过不止一次第一不要使用箭头函数。原因前面提过箭头函数会丢失webpack注入的this。第二不要在loader里写复杂业务逻辑。loader应该保持单一职责复杂的解析逻辑应该拆成独立的工具函数。第三不要直接修改传入的source。最好 copy一份再进行修改避免副作用。第四不要忽略this.cacheable()。webpack默认会尝试缓存loader的结果但如果你依赖了外部文件应该自行判断是否可缓存否则可能出现修改了文件但构建结果没更新的诡异问题。// 标记loader结果可被缓存 module.exports function (source) { this.cacheable(); // 处理逻辑... return transformedSource; };第五返回JS模块字符串时记得用JSON.stringify处理防止内容里的引号、换行符破坏模块语法。3. 手写plugin从零开始做一个打包时间插件loader写起来相对直白plugin则要看懂webpack的hook机制。下面用一个最实用的场景来拆解写一个在构建开始和结束时打印时间的插件。3.1 plugin的基本形态一个plugin就是一个带有apply方法的JavaScript对象或者说是构造函数实例。webpack会调用这个apply方法并把compiler对象传进去。// build-time-plugin.js class BuildTimePlugin { apply(compiler) { compiler.hooks.compile.tap(BuildTimePlugin, () { this.startTime Date.now(); console.log(编译开始); }); compiler.hooks.done.tap(BuildTimePlugin, (stats) { this.endTime Date.now(); console.log(编译完成耗时: ${this.endTime - this.startTime}ms); }); } } module.exports BuildTimePlugin;tap是注册hook监听的方式。webpack内部几乎所有hook都支持tap同步、tapAsync异步回调、tapPromisePromise风格三种注册方式。具体用哪一种取决于你监听的是哪个hook以及你想怎么处理异步逻辑。3.2 compiler和compilation的关系这是新手最容易混淆的一对概念。compiler是webpack的“大管家”一个构建过程对应一个compiler对象从启动到结束都是同一个对象。它上面的hooks管理的是整个构建生命周期environment、afterEnvironment、initialize、beforeRun、run、compile、make、emit、afterEmit、done等。compilation则是“一次构建”的代表。当检测到文件变化触发增量构建时会重新生成一个compilation对象。它上面的hooks管理的是模块编译、优化、产出的阶段moduleAsset、chunkAsset、optimizeChunkAssets、processAssets等。简单理解compiler管的是“全局”compilation管的是“这次打包”。3.3 在两个阶段分别可以拿到什么afterPlugins所有plugin注册完成后触发适合做一些初始化compile编译开始前触发可以在这里做计时或清理工作make开始构建模块依赖图是分析依赖关系的好时机emit即将生成产物文件但还没写入磁盘可以在这里修改产物afterEmit文件已经写入磁盘done整个构建流程结束在各个hook里你能拿到的数据级别完全不同。比如在emit阶段可以通过compilation.assets拿到所有待生成的资源这也是很多静态资源处理插件介入的时机。3.4 修改产物的插件示例下面这个插件演示了如何在打包产物中注入一段自定义注释// banner-plugin.js class BannerPlugin { constructor(options) { this.banner options.banner || ; } apply(compiler) { compiler.hooks.emit.tapAsync(BannerPlugin, (compilation, callback) { const assets compilation.assets; Object.keys(assets).forEach((filename) { if (filename.endsWith(.js)) { const originalContent assets[filename].source(); const newContent /* ${this.banner} */\n originalContent; assets[filename] { source: () newContent, size: () newContent.length }; } }); callback(); }); } } module.exports BannerPlugin;这里有个关键点修改compilation.assets时不能只替换字符串还要保证对象有source和size方法因为webpack内部在输出文件时会调用这两个方法。3.5 异步hook怎么处理如果你监听的hook支持异步你有两种方式可以处理。一种是tapAsync在回调里调用传入的函数另一种是tapPromise返回一个Promise。下面的代码展示了tapPromise的用法compiler.hooks.emit.tapPromise(MyPlugin, (compilation) { return new Promise((resolve, reject) { setTimeout(() { // do something resolve(); }, 1000); }); });要注意如果一个hook已经有人用tapAsync方式注册了你再同时混用多种注册方式可能会出问题。尽量在同一类操作中保持一致的注册方式。3.6 实战一个完整的“构建信息展示”插件把上面的知识点组合起来我来写一个实用性较强的插件它会在构建完成后把产物的chunk数、模块数、总大小等信息打印在一张表格里。// stats-plugin.js class StatsPlugin { apply(compiler) { compiler.hooks.done.tap(StatsPlugin, (stats) { const { hash, compilation } stats; const { chunks, modules } compilation; console.log(构建hash:, hash); console.log(chunk数量:, chunks.length); console.log(模块数量:, modules.size); console.log(耗时:, stats.endTime - stats.startTime, ms); }); } }这类插件调试内部信息时很有用可以快速定位构建瓶颈。4. 在webpack配置中接入loader和plugin写好了扩展接下来要在config里注册。4.1 loader的接入方式loader的接入有几种路径写法我推荐用path.resolve来写避免平台差异和相对路径错乱。// webpack.config.js const path require(path); module.exports { mode: development, entry: ./src/index.js, output: { path: path.resolve(__dirname, dist), filename: bundle.js }, module: { rules: [ { test: /\.md$/, use: [ { loader: path.resolve(__dirname, loaders/md-loader.js), options: { excerpt: true } } ] } ] } };options会通过this.getOptions()在loader里获取。这里注意loader-utils的getOptions和webpack5里默认的this.getOptions()两种方式都行但推荐直接用后者省得额外装包。4.2 plugin的接入方式plugin的接入就简单得多只要在plugins数组里new一个实例就行。const BuildTimePlugin require(./plugins/build-time-plugin); module.exports { plugins: [ new BuildTimePlugin(), new StatsPlugin() ] };如果插件需要传参在构造函数里接收options即可。4.3 localLoader和npm包两种模式的取舍自己写的loader一般放在项目里用相对路径引用。但如果你想把它发布到npm并在多个项目间复用建议做一套完整的开发规范导出函数的命名、options的schema校验、单元测试都得配上。发布到npm的loader在文章开头提过要避免loader-utils的版本冲突尽量让webpack解析方式保持一致。一个标准的loader包一般长这样md-loader/ ├── src/ │ ├── index.js │ └── transform.js ├── test/ ├── package.json └── README.mdpackage.json里通过main字段指向src/index.js这样webpack在resolve loader时就能正确加载。5. 调试自定义loader和plugin的实战技巧写扩展的时候最怕的就是报错信息看不懂、不知道代码有没有被调用。下面分享几个我平时调试的实用手段。5.1 使用node调试模式在package.json里加一个脚本用node --inspect启动webpack然后配合Chrome DevTools进行断点调试。{ scripts: { debug: node --inspect ./node_modules/webpack/bin/webpack.js --config webpack.config.js } }然后在代码里打debugger语句浏览器打开chrome://inspect就能像调试前端代码一样调试loader了。5.2 在loader和plugin里打日志loader里的this.getLogger()会返回一个带上下文信息的logger比console.log更干净。module.exports function (source) { const logger this.getLogger(md-loader); logger.info(开始处理文件:, this.resourcePath); // ... return transformedSource; };日志会带上前缀在多loader链式调用时很好排查。5.3 验证hook是否被触发如果怀疑hook没执行最简单的验证方式是直接在一个同步hook里写一个会输出到文件的操作。比如compiler.hooks.emit.tap(debug-plugin, (compilation) { const fs require(fs); fs.writeFileSync(/tmp/webpack-debug.log, emit triggered at ${Date.now()}); });如果构建结束后文件存在说明hook确实被执行了。5.4 一个排查顺序建议报错时按这个顺序排查能少走弯路先确认loader或plugin有没有被webpack加载再看传参和options是否正确再检查hook名称是否拼写正确最后才怀疑转换逻辑本身。其中hook名称拼写错误属于“静默失败”webpack不会报错只是你的代码不执行。这种情况更容易让人摸不着头脑。我的经验是每次写完新hook先随便打一条日志确认能触发再继续写逻辑。6. 总结到这里但实际还没结束写loader和plugin最大的障碍其实是心态。总觉得webpack是别人写的底层工具我们顶多配配参数。但真自己动手写一个loader之后你会发现webpack的扩展机制设计得相当规整loader只管文件内容plugin只管构建流程边界清晰hook命名也很直观属于“工具门槛高但扩展门槛低”的典型。从个人经验说写loader更像写一个纯函数script是关于改造字符串、校验参数、处理异步的算法题写plugin更像写事件驱动代码关键是找到正确的hook而不是硬编码逻辑。如果你从来没写过这两个东西我建议从copy一个最简单的loader开始比如把foo替换成bar。先用console.log验证执行再不断往里面加逻辑。一旦第一条loader跑通后面的plugin也就能自然上手了。真正理解loader和plugin的分工之后再回到webpack的配置很多东西就通透了——哪些问题该用loader解决哪些问题该用plugin解决你和webpack之间的关系从“配置使用者”变成了“工具设计者”。这种转变才是这篇文章真正想给你的东西。