Skip to content
webpack loader 与 plugin 的工作原理
概述
webpack 把一切资源都看作“模块”,但自身的模块解析只能处理 JavaScript 与 JSON 文件。要让 .css、.png、.ts 这些文件进入依赖图,就必须在构建流水线中插入转换逻辑——这些转换逻辑在 webpack 里由 Loader 与 Plugin 分担。
Loader 的工作位置在模块加载阶段,它拿到原始文件内容,加工后交给 webpack 继续处理。Plugin 的工作范围更宽,它监听整个编译生命周期中的事件,能在生成资源、优化 chunk、输出文件这些环节中改变构建结果。
Loader 的内部流水线
每个 Loader 就是一个导出的函数。webpack 按照配置中 module.rules 里 use 数组的顺序,从右到左(或者说从下到上)依次调用这些函数。文件内容先经过最后一个 loader,处理结果传给倒数第二个,依此类推,最终返回的必须是 JavaScript 代码(或者能够被后续 loader 理解的中间格式)。
style-loader
css-loader对于 import './style.css',实际执行顺序是:css-loader 解析 CSS 中的 @import / url(),把结果合并成一个字符串;style-loader 再把这个字符串包装成一段会插入 <style> 标签的 JavaScript 模块。
这整个链式传递过程中,每个 loader 只拿到上一个 loader 的返回值(称 content),以及一个 source map。但 webpack 还提供了一个更早的阶段:pitching 阶段。
pitching 阶段按“从左到右”的顺序执行。如果一个 loader 在 pitch 方法里显式返回了非 undefined 值,链条会提前中止,后续的正常阶段不再执行,而是直接将返回值反向传递给前面的 loader。这个设计让一些需要提前“占位”或“短路”的场景成为可能——比如 style-loader 的 pitch 方法就做了样式的注入逻辑,让 css-loader 的结果不必再经普通阶段。
js
// 一个简单 loader 的 pitch 方法
module.exports = function loader(content) {
return content;
};
module.exports.pitch = function (remainingRequest) {
// 返回非 undefined 值后会跳过后续 loader 并反转流程
};Loader 上下文:this.callback 与 this.async
Loader 函数内的 this 上下文由 webpack 注入,挂载了当前模块路径、options、emitFile 等方法。对于转换结果只有一个值的简单 loader,直接 return 即可。但当需要同时返回 source map 或其他元数据时,必须使用 this.callback。
js
module.exports = function (content) {
this.callback(null, content, sourceMap);
// 调用 this.callback 后不可再 return
};同步 loader 要么 return,要么调 this.callback 结束。异步 loader 需要先通过 this.async() 获取一个回调,只有调用这个回调时 webpack 才知道 loader 完成。
js
module.exports = function (content) {
const callback = this.async();
fs.readFile(someFile, (err, data) => {
if (err) return callback(err);
// 处理数据后调用 callback 通知 webpack
});
};this.async() 返回的函数带错误优先语义,第一个参数传递 Error 表示失败,后续参数和 this.callback 相同。忘记调用 callback,对应的模块就会一直“悬挂”在构建图中,最终可能导致构建超时报错。
编写自定义 Loader:同步与异步
写一个自定义 loader 最简形式就是一个 Function。假设我们希望把 .txt 文件作为字符串直接导出,就可以定义一个同步 loader:
js
// txt-loader.js
module.exports = function (source) {
// source 是文件内容的 UTF‑8 字符串
const escaped = JSON.stringify(source);
return `module.exports = ${escaped};`;
};在配置中通过 resolveLoader.alias 或直接在 use 中用绝对路径引用它:
js
// webpack.config.js
const path = require('path');
module.exports = {
module: {
rules: [
{
test: /\.txt$/,
use: path.resolve(__dirname, 'txt-loader.js')
}
]
}
};现在 import text from './readme.txt' 得到的 text 就是该文件的内容。
如果 loader 内需要做异步操作(比如读文件、请求数据),必须用 this.async()。下面这个异步 loader 会给每个文件末尾追加一个时间戳注释:
js
// timestamp-loader.js
module.exports = function (source) {
const callback = this.async();
// 模拟异步操作
setImmediate(() => {
const updated = source + '\n// Built at: ' + new Date().toISOString();
callback(null, updated);
});
};执行结果是可预期的:无论是同步还是异步,webpack 都会等待当前 loader 完成再进入下一个。异步 loader 通过 callback 或 Promise 显式发出完成信号,webpack 会等到收到信号后才将结果传递给链条中的下一个 loader,因此链式执行顺序始终被保持。
Plugin 的工作机制:Tapable 与 apply
webpack 内部大量使用 Tapable 库来管理事件流。Tapable 提供了一系列钩子类型,对应不同的触发策略:SyncHook(同步串联)、AsyncSeriesHook(异步顺序)等。编译器对象(Compiler)和编译过程对象(Compilation)上挂载了大量钩子,Plugin 通过在这些钩子上注册回调来介入构建流程。
写一个 Plugin,本质上就是定义一个带有 apply 方法的类(或对象)。apply 接收 Compiler 实例,然后注册钩子:
js
class ExamplePlugin {
apply(compiler) {
compiler.hooks.done.tap('ExamplePlugin', (stats) => {
console.log('构建完成');
});
}
}tap 方法对应同步钩子。对于支持异步的钩子(例如 AsyncSeriesHook),可以使用 tapAsync 或 tapPromise。以 compiler.hooks.emit 为例,它是异步可串行的,一个常见的用法是在它上面挂载一个 tapAsync 回调,在生成输出资源之前修改 compilation.assets。
js
compiler.hooks.emit.tapAsync('Name', (compilation, callback) => {
// 修改 compilation.assets …
callback();
});必须调用 callback(或者在 Promise 钩子中返回 promise resolve),否则整个构建会卡住。这一点和 loader 的 this.async() 机制类似,只不过现在是在“事件处理”维度上同步完成变为异步等待。
Compiler 与 Compilation 生命周期及常用钩子
Compiler 对象代表了全局的 webpack 环境,它在启动后被创建,并且贯穿整个构建生命期。重要的钩子包括:
environment/afterEnvironment— 环境就绪前后compile/compilation— 编译开始与新 Compilation 创建make— 模块解析的起点(make钩子触发后开始从 Entry 递归构建模块图)emit/afterEmit— 输出资源到 output 目录前 / 后done— 构建结束failed— 构建失败
Compilation 对象代表了单次构建的上下文,每次重新构建(如 watch 模式下的文件变更)都会生成新的 Compilation 实例。其钩子负责更细粒度的控制:
buildModule/succeedModule/failedModule— 模块构建的生命周期optimize/optimizeChunks/optimizeTree— 优化阶段processAssets— 处理资源(webpack 5 引入的新钩子,替代部分已废弃的钩子)
在编写 Plugin 时,大多数逻辑都集中在 compiler.hooks.compilation.tap 那里拿到 Compilation 对象,再进一步注册 Compilation 的钩子:
js
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
// 直接操作 assets
}
);
});这里要注意的是 processAssets 的 stage 参数控制了插件插入的时机,如果不指定或者选择了不合适的 stage,可能会与其它 plugin 产生意外顺序——比如尚未生成源码映射的时候就去修改已生成的 JS 资源。
编写自定义 Plugin:输出文件列表与注入资源
输出构建产物列表是一个经典的应用场景,适合用 done 钩子实现。这个钩子是同步的(SyncHook),传入一个 Stats 对象。我们从 compilation 中可以拿到最终的 assets 信息。
js
class FileListPlugin {
apply(compiler) {
compiler.hooks.done.tap('FileListPlugin', (stats) => {
const { assets } = stats.compilation;
const assetKeys = Object.keys(assets);
console.log('生成的文件列表:');
assetKeys.forEach((name) => console.log(` - ${name}`));
});
}
}如果希望在产物里新增一个文件(比如生成版本文件或注入环境信息),应该在 emit 钩子(或 processAssets)中操作 compilation.assets:
js
compiler.hooks.emit.tapAsync('InjectVersionPlugin', (compilation, callback) => {
const version = JSON.stringify({ version: '1.0.0', buildTime: Date.now() });
compilation.assets['version.json'] = {
source: () => version,
size: () => version.length,
};
callback();
});这里 compilation.assets 是一个键值对,key 就是输出文件名,value 需要提供 source() 和 size() 方法。webpack 会根据这个 map 将内容写到磁盘。
对于 webpack 5 来说,更推荐的方式是使用 compilation.hooks.processAssets 配合相应的 stage,这是因为 emit 钩子在资源写入前触发虽然可以使用,但 processAssets 能精确控制资源加工的环节,避免和其它 plugin 产生执行顺序上的冲突。
Loader 与 Plugin 的职责边界
Loader 和 Plugin 在职责上有一个非常清晰的界限:Loader 只负责转换模块内容;Plugin 负责所有其它构建流程的干预。
一旦意识到某个需求是“某种文件需要经过某一套转换才能成为 webpack 能处理的模块”,就应当用 loader。比如把 TypeScript 编译为 JavaScript、把 SCSS 编译为 CSS 并导出为字符串模块,这些都是 loader 链的职责。
而一旦需求变成了“我需要修改输出目录里的文件结构”、“我要在构建结束时做一些清理工作”、“我需要根据依赖图生成一份清单”,这些就属于 Plugin 的范畴。
在实际选择时,可以先问两个问题:
- 这个操作发生的时间是在单个模块的转换过程里,还是在整体构建流程的某个阶段?
- 操作是否需要接触文件内容本身的语法树或源码文本?如果是,那么 loader 几乎就是唯一入口。
Loader 可以拿到源文件内容并返回新内容,它不直接接触编译状态。Plugin 则可以深入到 Compiler / Compilation 内部,修改模块、chunk、资源列表,甚至动态增加 Entry。这种能力上的区别导致 loader 的输入输出一定是逐文件可顺序化的,而 plugin 的逻辑天然带有全局状态。
注意点与限制
Loader 的纯函数假设
虽然 webpack 不强制要求 loader 无副作用,但在 watch 模式或缓存(cache)场景下,loader 函数的确定性对增量构建的正确性影响很大。如果在 loader 中依赖了外部可变状态,需要在this.addDependency或this.addContextDependency中标记依赖,否则变更不会被检测到。异步 loader 必须发出完成信号
无论是用this.async()还是返回 Promise,对应的回调不调用或者 Promise 不 resolve,webpack 会等待直到超时(默认无超时,但永远不会完成)。对于 Node.js 回调风格的callback(err, result),如果错误参数为 Truthy,构建会因该模块失败而终止。this.callback与return互斥
一旦调用了this.callback,函数内的return语句就不再有意义;反过来,如果同步 loader 直接return了一个值,再调用this.callback会导致无效请求状态(因为 webpack 已经认为 loader 完成)。这种冲突在实际使用中偶尔会出现,例如错误分支返回了,主路径却仍然调用了 callback。Plugin 钩子的注册时机
所有对 Compiler 钩子的注册必须在apply函数内完成,因为那时的 Compiler 还未启动。如果在构建已经启动之后再尝试compiler.hooks.done.tap,这些注册不会参与当前构建周期。Tapable 钩子类型不可混淆
为SyncHook使用tapAsync会导致运行时异常。阅读 webpack 源码或官方钩子文档是确认钩子类型最可靠的手段,通过console.log(compiler.hooks.emit)打印钩子构造函数名也可以辅助判断。多个 plugin 的执行顺序
Plugin 的apply按注册顺序调用,但钩子内的回调执行顺序取决于钩子本身的策略(例如SyncHook按注册顺序同步调用,而AsyncParallelHook会同时触发)。如果一个 plugin 的输出需要被另一个 plugin 处理,应当通过合适的钩子阶段(stage)或把逻辑拆分到不同的钩子来解决时序依赖,而不是假设插件实例化顺序。module.rules 中的
use顺序
在配置里写use: ['a-loader', 'b-loader']时,执行顺序是b-loader→a-loader。但在 pitch 阶段顺序相反,是a-loaderpitch →b-loaderpitch。理解这个双重顺序对于 debug 链式 loader 问题非常关键。
