上一篇讲了 loader 的本质是「把源码转换成模块的函数」。但实际上,loader 不止一种形态:有的是简单同步函数,有的要处理异步任务,有的接收二进制数据,还有的能在「正式处理之前」抢先介入。本文按能力把 loader 分成四类:同步、异步、raw、pitching。
一、同步 loader
最常见的形态。loader 函数直接处理并 return 结果:
// loaders/replace-loader.js
module.exports = function (source) {
return source.replace(/__VERSION__/g, '1.0.0');
};
如果还需要传递 Source Map 或额外的元信息,可以使用 this.callback(同样是同步调用):
module.exports = function (source) {
// callback(err, content, sourceMap, meta)
this.callback(null, source.toUpperCase(), null, { upper: true });
};
特点
- 实现简单,适合纯文本替换、拼接、简单转换。
- 返回值就是下一个 loader 的输入。
this.callback和return二选一,不要同时用。
适用场景:文本替换、注入 banner、简单语法转换。
二、异步 loader
当处理过程涉及异步操作(读文件、调用异步编译器、请求接口)时,必须用异步方式。通过 this.async() 拿到回调:
const fs = require('fs');
const path = require('path');
module.exports = function (source) {
const callback = this.async();
fs.readFile(path.resolve(__dirname, 'template.txt'), 'utf-8', (err, template) => {
if (err) {
callback(err); // 出错时把错误传给 callback
return;
}
callback(null, template + source);
});
};
关键规则
- 必须先调用
this.async()获取 callback,之后所有逻辑通过它返回结果。 - callback 只能被调用一次,且签名是
(err, content, sourceMap, meta)。 - 调用
this.async()后不要再return内容,返回值会被忽略,webpack 也会给出警告。 - 必须保证 callback 一定被调用,否则构建会一直挂起。
适用场景:babel-loader、sass-loader 这类内部有异步编译过程的 loader。
三、raw loader
默认情况下,loader 拿到的 source 是字符串。但图片、字体、压缩包等二进制文件不能用字符串安全表示——这时需要声明 raw:
// loaders/image-size.js
module.exports = function (source) {
// 当 raw = true 时,source 是一个 Buffer
const size = source.length;
return `module.exports = ${size};`;
};
module.exports.raw = true;
特点
- 设置
module.exports.raw = true后,source变成Buffer。 - 相应地,loader 返回的也应是
Buffer(或交给下一个 loader 处理的内容)。 - 不设置时,webpack 会把内容按 UTF-8 转成字符串。
适用场景:处理图片、字体、二进制协议、需要按字节操作的场景。
四、pitching loader
除了主函数(normal 阶段),loader 还可以导出一个可选的 pitch 函数。它在 normal 阶段之前执行,用来「抢先介入」。
module.exports = function (source) {
// normal 阶段
return source;
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
// pitch 阶段
};
pitch 接收三个参数:
| 参数 | 含义 |
|---|---|
remainingRequest | 当前 loader 右侧剩余 loader 和资源的请求串 |
precedingRequest | 当前 loader 左侧已执行 loader 的请求串 |
data | 在 pitch 与 normal 之间共享的数据对象 |
1. 执行时机
回顾 loader 链的两阶段:
pitch: a ──▶ b ──▶ c
normal: a ◀── b ◀── c
pitch 从左到右,normal 从右到左。所以 pitch 是「进入正式处理前」的钩子。
2. 提前返回,跳过后续 loader
如果 pitch 返回了一个非 undefined 的值,右侧所有 loader 的 pitch 和 normal 都会被跳过,这个值直接作为结果交给左边:
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
if (process.env.SKIP === '1') {
// 直接返回结果,不再执行右侧 loader
return 'module.exports = "skipped";';
}
};
这是 pitching loader 最强大的能力,常用于「短路」某些处理链。
3. 短路时的完整执行顺序(示例)
用一个具体例子把顺序看清楚。配置三个 loader:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['a-loader', 'b-loader', 'c-loader'],
},
],
},
};
每个 loader 都打印日志:
// a-loader.js
module.exports = function (source) {
console.log('a normal');
return `/* a */ ${source}`;
};
module.exports.pitch = function () {
console.log('a pitch');
};
// b-loader.js —— 它的 pitch 会返回一个值,触发短路
module.exports = function (source) {
console.log('b normal');
return source;
};
module.exports.pitch = function () {
console.log('b pitch');
return 'console.log("from b.pitch");';
};
// c-loader.js
module.exports = function (source) {
console.log('c normal');
return source;
};
module.exports.pitch = function () {
console.log('c pitch');
};
情况一:没有任何 pitch 返回值(正常链)
a pitch → b pitch → c pitch → 读取文件 → c normal → b normal → a normal
情况二:b.pitch 返回了值(短路)
a pitch → b pitch(返回)→ a normal(以 b.pitch 的返回值为输入)
对比表:
| 阶段 | 不短路 | b.pitch 短路 |
|---|---|---|
| pitch | a → b → c | a → b(返回) |
| 读文件 | 读 | 不读 |
| normal | c → b → a | a |
也就是说,一旦某个 loader 的 pitch 返回了值:
- 它右侧所有 loader(含它自己)的 pitch 和 normal 都被跳过;
- 资源文件不会被读取;
- 该值直接交给它左侧的 loader 继续 normal 处理(示例中即 a 的 normal)。
用图表示:
不短路:
pitch a ──▶ b ──▶ c
读文件
normal a ◀── b ◀── c
b.pitch 返回:
pitch a ──▶ b(return)
normal a ← source = b.pitch 的返回值,c 与 b 均不执行、文件不读取
这也是 style-loader 能借助 pitch 工作的原因:它可以在 pitch 阶段直接产出一段代码并短路后续处理,从而避免重复的编译工作。
4. 在 pitch 和 normal 之间传数据
pitch 里的 data 对象会传给同一个 loader 的 normal 阶段:
module.exports = function (source) {
return `/* ${this.data.msg} */\n${source}`;
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
data.msg = 'generated-by-pitch';
};
适用场景:需要提前拦截、短路处理链、或为后续处理准备上下文的 loader(如 style-loader 就借助 pitch 机制工作)。
说明:网上有时把这一类写成「patching loader」,正确的叫法是 pitching loader——因为它对应的是 loader 的
pitch阶段。
五、四种能力可以组合
这四个分类是维度不同的,一个 loader 可以同时具备多种:
| 组合 | 含义 |
|---|---|
| 同步 + pitch | 主处理是同步的,同时定义了 pitch |
| 异步 + raw | 异步处理二进制数据 |
| raw + pitch | pitch 中即可决定是否跳过二进制处理 |
| 全部 | 灵活但通常没必要 |
例如一个「异步读取并处理二进制图片」的 loader:
module.exports = function (source) {
const callback = this.async(); // 异步
// source 是 Buffer(raw)
doSomethingAsync(source).then((res) => callback(null, res));
};
module.exports.raw = true;
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
data.startedAt = Date.now(); // pitch
};
六、对照总结
| 类型 | 标志 | 输入/输出 | 适用 |
|---|---|---|---|
| 同步 | 直接 return / this.callback | 字符串 | 简单文本处理 |
| 异步 | this.async() | 字符串 | 异步编译、IO |
| raw | module.exports.raw = true | Buffer | 图片、二进制 |
| pitching | module.exports.pitch | —(预处理钩子) | 拦截、短路、传数据 |
七、编写 loader 的注意事项
- 一个 loader 只做一件事,需要多步时拆成多个串联,便于复用和测试。
- 异步 loader 必须且只能调用一次 callback,否则构建挂起或报错。
- 不要在异步 loader 里
return内容,也不要混用同步与异步返回。 - raw 要成对考虑:设了
raw = true就成了 Buffer 世界,输出也要匹配。 - pitch 返回值意味着「短路」,调试时若发现后续 loader 没执行,先检查 pitch。
- loader 应保持无副作用,写文件、改全局这类操作交给 plugin。
小结
loader 的四种形态,本质是在回答四个问题:
- 同步还是异步? —— 处理逻辑是否涉及异步操作。
- 文本还是二进制? —— 用
raw切换字符串/Buffer。 - 要不要提前介入? —— 用
pitch在 normal 之前拦截、短路、传数据。
理解这四点,再看社区里那些复杂的 loader(如 style-loader、sass-loader、thread-loader),就能明白它们为什么长成那样,也更容易写出自己的 loader。