前言:为啥要自己写 Loader 和 Plugin?
用 Webpack 打包时,我们早就习惯了各种现成的 Loader 和 Plugin,比如babel-loader 转 ES6,ts-loader 处理 TypeScript,HtmlWebpackPlugin 生成 HTML……那为啥还要自己写呢?
我们先说 Loader。
Webpack 默认只会把 JS/JSON 当模块处理,其他像 .vue、.less、.ts、甚至公司内部的自定义格式(比如某种带特殊标记的模板),都要靠 Loader 转成 JS 或 CSS后,打包才能用。现成的 loader 虽然能覆盖大部分通用场景,但总会遇到「没有现成的」或「现成的总差一点」的情况:
比如你们项目用了一套自己的文档/配置格式,想 import config from './xxx.our-format' 直接用;比如要在源码里自动注入构建时间、版本号、环境变量等;比如要对某类文件做一次性的清洗、脱敏或校验。这时候写一个只做「一种文件 => 一段 JS 字符串」的小函数,往往比满世界找轮子或硬塞进别的工具更简单、更可控。
So, 自定义 Loader 就是为了让任意类型的「文件」都能按你的规则变成 Webpack 能理解的模块。
再说 Plugin吧。
Loader 解决的是「单个文件怎么转」的问题;但是构建流程里还有很多「和具体文件无关」的事,比如在打包结束时自动上传产物到 CDN、根据本次构建生成一份资源清单或埋点配置、在输出目录里多塞几个自动生成的文件、或者把构建信息打进某个内部系统等等……
这些都是在「构建的某个时间点」插一脚,而不是对某一种后缀做转换。用现成 Plugin 能搞定一大部分,但公司流程、内部工具、特殊需求多一些,就会遇到「没有现成插件」或「要改源码才能满足」的情况。你有碰到过吗?
自定义 Plugin 就是为了在 Webpack 的编译生命周期里挂上自己的逻辑,想在某一时刻干啥就干啥。
而两者配合,就能在不动 Webpack 源码的前提下,把构建流程扩展成项目真正需要的样子。
下面我将结合官方文档,讲讲怎么写自定义的Loader 和 Plugin,顺便理一理常用钩子和好用的库。
一、自定义 Loader
Loader 到底是啥?
说白了,Loader 就是一个导出的函数。Webpack 处理文件的时候会调你这个函数,把「文件内容」或者「上一个 loader 吐出来的结果」传给你。你干完活,最后要还给它一段 字符串(或 Buffer),也就是 JS 源码;要是想带 SourceMap,也可以一起还。
总而言之一句话:就是一个文件进来,你把它转成 webpack 能用的 JS 字符串,再送出去。
那么写一个 Loader 要几步呢?
- 想清楚要干啥
比如:只处理某种后缀、做代码检查、或者顺便生成个 source map 之类的。 - 建个 .js 文件
一般会单独建个loaders文件夹,专门放自己写的 loader。 - 写函数
函数会收到(content, map, meta)三个参数(后两个可以不用),在里面做你的转换逻辑。 - 在 webpack 里使用
在webpack.config.js的module.rules里,指定「哪些文件」使用「编写的自定义loader」。
函数长什么样?this 能干啥?
你的 loader 函数大概长这样:
/**
* content:源文件内容(字符串或 Buffer)
* map:SourceMap,可选
* meta:随便传的元数据,可选
*/
function webpackLoader(content, map, meta) {
// 你的逻辑
}
函数里的 this 是 webpack 给你塞好的,上面有两个特别常用的方法:
this.callback(err, content, sourceMap?, meta?)
当你要「不只返回一串字符串」的时候使用(比如还要带上map、meta)。
第一个参数是错误(没有就传null),
第二个是内容,后面两个可选。this.async()
你要做异步操作(比如读文件、调接口)时,先调一下this.async(),它会给你一个 callback。你处理完了再调这个 callback 把结果还回去就行;注意:调完就别再 return 了。
几种常见写法
情况 1:同步,只返回一段字符串
直接 return 就行。
module.exports = function (content, map, meta) {
return someSyncOperation(content);
};
情况 2:同步,但要带 map、meta
必须用 this.callback,用完之后记得 return。
module.exports = function (content, map, meta) {
this.callback(null, someSyncOperation(content), map, meta);
return;
};
情况 3:异步,只返回一段字符串
先 const callback = this.async(),在异步回调里把结果交给 callback。
module.exports = function (content, map, meta) {
const callback = this.async();
someAsyncOperation(content, (err, result) => {
if (err) return callback(err);
callback(null, result, map, meta);
});
};
情况 4:异步,还要带 sourceMap、meta
一样用 this.async(),在回调里把 content、sourceMaps、meta 都传给 callback。
module.exports = function (content, map, meta) {
const callback = this.async();
someAsyncOperation(content, (err, result, sourceMaps, meta) => {
if (err) return callback(err);
callback(null, result, sourceMaps, meta);
});
};
下面演示一个简单示例方便大家理解:写一个给文件加注释头的 Loader
下面是一个可直接运行的自定义 Loader:给每个被处理的源文件内容前面加一行注释,写上「文件名」和「处理时间」。注意理解「content 进、字符串出」和 this.resourcePath 的用法。
1. 首先在项目里建文件 loaders/comment-header.loader.js:
/**
* 给源文件内容前面加一行注释头
* 格式:// [文件名] processed at [时间]
*/
function commentHeaderLoader(content) {
const filename = this.resourcePath.split(/[/\\]/).pop();
const header = `// ${filename} processed at ${new Date().toISOString()}\n`;
return header + content;
}
module.exports = commentHeaderLoader;
- 入参
content:当前文件的内容(上一个 loader 的产出或原始文件内容)。 this.resourcePath:当前资源的绝对路径,webpack 会挂到 loader 的this上,这里用来取文件名。- 返回值:必须是一段字符串,这里把「注释头 + 原内容」拼好直接 return,是最简单的
同步、单结果写法啦
2. 在 webpack 里使用
在 webpack.config.js 的 module.rules 里加一条,让某种文件(比如 .js)先走这个 loader(注意用 path.resolve 指向本地 的loader 路径,否则 webpack 会去 node_modules 里找):
const path = require('path');
module.exports = {
// ...
module: {
rules: [
{
test: /\.(js|mjs|ts)$/,
use: [
{
loader: path.resolve(__dirname, 'loaders/comment-header.loader.js'),
},
// 后面可以再接 ts-loader、babel-loader 等
],
},
],
},
};
补充一点,loader的执行顺序大家了解吗?是先右后左,或者先下后上哦(逆序执行)
3. npm run build 后是个啥
打包完成后,打开产物里的 JS,会在文件最上面看到多出来的一行,例如:
// index.js processed at 2025-02-26T12:00:00.000Z
(function(modules) { ...
说明你的 loader 已经对「匹配到的文件」做了一次转换,这就是一个完整的自定义 Loader 从写到用的流程啦,是不是还是很简单的!
常用loader有哪些?
| 类别 | 例子 | 干啥的 |
|---|---|---|
| 文件/依赖 | val-loader、ref-loader | 把代码当模块执行、手动画依赖关系 |
| JSON | cson-loader | 读 CSON 并转成 JS 能用的 |
| 语法转换 | babel-loader、ts-loader、esbuild-loader | 把 ES6+、TS 等转成浏览器能跑的 |
| 模板 | html-loader、pug-loader、markdown-loader、handlebars-loader | 各种 HTML/模板/Markdown |
| 样式 | style-loader、css-loader、less-loader、sass-loader、postcss-loader | CSS 和预处理器 |
| 框架 | vue-loader、angular2-template-loader | Vue、Angular 单文件组件 |
二、自定义 Plugin
Plugin 又是啥?
Plugin 就是一个带 apply 方法的对象(或类)。Webpack 一启动就会调你的 apply(compiler),把 compiler 传给你。有了 compiler,你就能在整个打包过程里插一脚啦。
简单理解就是:Loader 是「一个文件一个文件地转」,Plugin 是「在打包的各个时间点搞事情」。
同样,写一个 Plugin 要几步呢?
- 写一个类(或函数),名字随意。
- 在这个类上写
apply(compiler),compiler 就是 webpack 传进来的那个。 - 在 apply 里「挂钩子」:比如
compiler.hooks.emit.tap(...),意思就是「到 emit 这个时间点,执行我这段代码」。 - 在钩子回调里干活:可以看 compilation、改资源、加文件等等。
- 如果是异步钩子,干完记得调一下 webpack 给你的那个 callback,不然构建会卡住。
上个简单例子开开胃:构建一开始就打一句 log
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, () => {
console.log('webpack 构建正在启动!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
用异步钩子的例子(比如 emit 阶段)
有的钩子会给你一个 callback,你搞完了要调一下,不然 webpack 会一直等。
class MyExampleWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
'MyExampleWebpackPlugin',
(compilation, callback) => {
console.log('这是一个示例插件!');
console.log('当前这次编译的对象 compilation:', compilation);
// 这里可以改 compilation.assets、加文件等
callback(); // 别忘了调,否则构建不结束
}
);
}
}
来个完整的例子:生成资源清单的 Plugin
下面是一个可直接运行的自定义 Plugin:在「快要往磁盘写文件」(emit 阶段)时,遍历本次构建的所有产物,生成一个 asset-list.txt 清单文件(文件名 + 大小),并把它塞进 compilation.assets,这样最后打包结果里就会多出这个文件。
这个例子可以清楚看到「挂钩子 → 拿 compilation → 改 assets → 调 callback」的完整流程。
1. 在项目里建文件 plugins/AssetListWebpackPlugin.js:
const pluginName = 'AssetListWebpackPlugin';
class AssetListWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const assets = compilation.assets;
const lines = ['# 本次构建产物清单\n', `生成时间: ${new Date().toISOString()}\n\n`];
for (const [name, source] of Object.entries(assets)) {
const size = source.size ? source.size() : (source.source().length || 0);
lines.push(`${name} ${size} bytes\n`);
}
const content = lines.join('');
compilation.assets['asset-list.txt'] = {
source: () => content,
size: () => Buffer.byteLength(content, 'utf8'),
};
callback();
});
}
}
module.exports = AssetListWebpackPlugin;
apply(compiler):webpack 启动时调用,只有这里能拿到compiler。compiler.hooks.emit.tapAsync:在 emit 阶段挂一个异步钩子,此时compilation.assets里已经是即将要输出的所有文件,可以读可以改。compilation.assets:键是文件名,值是一个至少包含source()和size()的对象。这里我们往里面加了一项asset-list.txt,所以最终输出目录里会多出这个文件。callback():emit 是异步钩子,处理完必须调一次,否则 webpack 会一直等。
2. 在 webpack 里使用
在 webpack.config.js 的 plugins 数组里 new 一下(记得在文件顶部 require):
const path = require('path');
const AssetListWebpackPlugin = require('./plugins/AssetListWebpackPlugin.js');
module.exports = {
// ...
plugins: [
new AssetListWebpackPlugin(),
],
};
3. run一次构建
构建完成后,在输出目录(比如 dist/)里会多一个 asset-list.txt,打开大概长这样:
# 本次构建产物清单
生成时间: 2025-02-26T12:00:00.000Z
main.js 12345 bytes
asset-list.txt 256 bytes
说明你的插件在 emit 阶段拿到了所有资源、生成了新内容并写进了本次构建,这就是一个完整的自定义 Plugin 从写到用的流程。
这个是不是有点难度啦?钩子都有啥?该挂哪儿?
Webpack 的钩子很多,不用全记,知道「大概在什么阶段」就行,用的时候查文档或搜源码里的 hooks.xxx.call。(想进一步了解的同学可以看一下tapable)
Compiler 上的钩子(整次构建的生命周期)
从开始到结束,大致顺序是:environment → entryOption → run → compile → compilation → make → afterCompile → emit → afterEmit → done。还有 failed、invalid、watchClose 等。
我们常挂的大概是:run(开始跑)、emit(要往磁盘写文件了)、done(全部搞定)。
Compilation 上的钩子
一次编译里会:加载模块、封存、优化、分 chunk、算 hash、生成资源等等。对应的有 buildModule、succeedModule、finishModules、seal、optimizeChunks、processAssets、chunkHash 等。
用法一样:compilation.hooks.xxx.tap(...),有的钩子支持 tapAsync / tapPromise。
别的还有:NormalModuleFactory(造模块)、ContextModuleFactory(require.context)、JavascriptParser(解析 JS 的 AST)、Resolver(解析路径)等,都有各自的 hooks。做深入定制时再查就行。
官方和社区有哪些好用的 Plugin?
| 来源 | 插件名 | 干啥的 |
|---|---|---|
| 官方自带 | BannerPlugin | 加注释头 |
| 官方自带 | DefinePlugin | 编译期常量 |
| 官方自带 | HtmlWebpackPlugin | 生成 HTML |
| 官方自带 | MiniCssExtractPlugin | 抽 CSS |
| 官方自带 | HotModuleReplacementPlugin | 热更新 |
| 官方自带 | CopyWebpackPlugin | 拷贝静态资源 |
| 官方自带 | CompressionWebpackPlugin | gzip 压缩 |
| 官方自带 | TerserPlugin | 压缩 JS |
| 官方自带 | ProgressPlugin | 打进度 |
| 官方自带 | SplitChunksPlugin | 代码分割,抽公共 chunk(通过 optimization.splitChunks 配置) |
| 社区 | Bundle Analyzer | 看打包体积 |
| 社区 | Fork TS Checker Webpack Plugin | 单独进程跑 TS 检查 |
| 社区 | Duplicate Package Checker | 重复依赖提醒 |
| 社区 | Circular Dependency Plugin | 循环依赖检测 |
| 社区 | Prerender SPA / PWA Manifest / Imagemin 等 | 按需选用 |
三、Loader 和 Plugin 有啥不一样?咋配合?
简单对比一下:
| 对比项 | Loader | Plugin |
|---|---|---|
| 干啥的 | 针对单个文件做转换(转成 JS/CSS 等) | 在构建的各个阶段做任意操作 |
| 输入是啥 | 文件内容 content(加可选的 map、meta) | compiler、compilation 等对象 |
| 输出是啥 | 返回字符串/Buffer(和可选的 SourceMap) | 不返回值,靠钩子 + callback 和 webpack 交互 |
| 啥时候跑 | 按规则匹配到文件时,在「链」里一个一个跑 | 在 compiler 的各个钩子触发时跑 |
配合起来用:Loader 负责「这类文件我帮你转成 JS/CSS」;Plugin 负责「打包到一半/要写文件了/打包完了我帮你做点别的事」(比如生成 HTML、压缩、分析、拷贝文件)。一个管「内容怎么变」,一个管「流程里插什么逻辑」。
四、小结
- 自定义 Loader:导出一个函数,参数是
(content, map, meta),用return或this.callback/this.async()把结果返还给 webpack,再在module.rules里配好「谁用这个 loader」。 - 自定义 Plugin:写一个带
apply(compiler)的类,在compiler.hooks.xxx(或 compilation、parser 等)上挂钩子,在回调里更改构建数据,异步钩子记得调用callback。
把这两块搞明白,需要的时候自己写一个 Loader 或 Plugin来扩展 Webpack时就会很顺手啦。