不要从“我”开始
建议不要使用“本文”、“我”等第一人称视角作为开篇方式,切换到更客观、中立的叙述口吻会更好。
下面我调整一下,重写开头,给你一个更干净、更自然的版本。
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(异步回调)、tapPromise(Promise风格)三种注册方式。具体用哪一种,取决于你监听的是哪个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之间的关系从“配置使用者”变成了“工具设计者”。这种转变,才是这篇文章真正想给你的东西。