编写插件
编写插件
插件向第三方开发者展现了 webpack 引擎的全部潜力。通过分阶段的构建回调,开发者可以在 webpack 构建流程中引入自己的行为。编写插件比编写 loader 更高级一些,因为你需要理解 webpack 的一些底层内部机制才能挂载到它们上面。准备好阅读一些源代码吧!
创建一个插件
一个 webpack 插件由以下部分组成:
- 一个具名的 JavaScript 函数或 JavaScript 类。
- 在其原型上定义
apply方法。 - 指定一个要挂载的事件钩子。
- 操作 webpack 内部实例的特定数据。
- 在功能完成后调用 webpack 提供的回调。
js
// 一个 JavaScript 类。
class MyExampleWebpackPlugin {
// 在原型上定义 `apply` 方法,该方法接收 compiler 作为参数
apply(compiler) {
// 挂载到 compilation 阶段
compiler.hooks.thisCompilation.tap(
"MyExampleWebpackPlugin",
(compilation) => {
// 指定资源处理钩子
compilation.hooks.processAssets.tap(
{
name: "MyExampleWebpackPlugin",
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
console.log("这是一个示例插件!");
console.log(
"这里是 `compilation` 对象,它代表了单次构建的资源:",
compilation,
);
},
);
},
);
}
}
基础插件架构
插件是实例化对象,其原型上具有 apply 方法。在安装插件时,webpack 编译器会调用一次这个 apply 方法。apply 方法会获得底层 webpack compiler 的引用,从而可以访问 compiler 回调。一个插件的结构如下:
js
class HelloWorldPlugin {
apply(compiler) {
// 该钩子在构建过程完全结束时执行
compiler.hooks.done.tap(
"Hello World Plugin",
(
stats /* stats 作为参数在 done 钩子被触发时传入。 */,
) => {
console.log("Hello World!");
},
);
}
}
export default HelloWorldPlugin;
然后在你的 webpack 配置中的 plugins 数组中包含一个实例来使用该插件:
js
// webpack.config.js
import HelloWorldPlugin from "hello-world";
export default {
// ... 其他配置 ...
plugins: [new HelloWorldPlugin({ options: true })],
};
在 webpack 5.106 之前,一个常见的验证插件选项的方式是在构造函数中使用 schema-utils:
js
import { validate } from "schema-utils";
// options 对象的 schema
const schema = {
type: "object",
properties: {
test: {
type: "string",
},
},
};
export default class HelloWorldPlugin {
constructor(options = {}) {
validate(schema, options, {
name: "Hello World Plugin",
baseDataPath: "options",
});
}
apply(compiler) {}
}
从 webpack 5.106 开始,你可以将该验证移到 apply() 中,通过挂载 compiler.hooks.validate 并调用 compiler.validate(...) 来实现:
js
export default class HelloWorldPlugin {
constructor(options = {}) {
this.options = options;
}
apply(compiler) {
compiler.hooks.validate.tap("HelloWorldPlugin", () => {
compiler.validate(
() => require("./schema/hello-world-plugin.json"),
this.options,
);
});
}
}
W> 当 webpack 配置为 validate: false 时,此验证流程会被跳过。启用 experiments.futureDefaults 后,验证在开发模式下默认开启,在生产模式下默认关闭。
Compiler 与 Compilation
在开发插件时,最重要的两个资源是 compiler 和 compilation 对象。理解它们的角色是扩展 webpack 引擎的重要第一步。
js
class HelloCompilationPlugin {
apply(compiler) {
// 挂载到 compilation 钩子,该钩子会将 compilation 作为参数传给回调函数
compiler.hooks.compilation.tap("HelloCompilationPlugin", (compilation) => {
// 现在我们可以挂载到 compilation 上提供的各种钩子
compilation.hooks.optimize.tap("HelloCompilationPlugin", () => {
console.log("资源正在被优化。");
});
});
}
}
export default HelloCompilationPlugin;
有关 compiler、compilation 和其他重要对象上可用钩子的列表,请参阅插件 API 文档。
异步事件钩子
某些插件钩子是异步的。要挂载到它们,我们可以使用 tap 方法(将以同步方式运行),或者使用 tapAsync 方法或 tapPromise 方法(它们是异步方法)。
tapAsync
当我们使用 tapAsync 方法挂载插件时,需要调用作为函数最后一个参数传入的回调函数。
js
class HelloAsyncPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
"HelloAsyncPlugin",
(compilation, callback) => {
// 执行一些异步操作...
setTimeout(() => {
console.log("异步工作完成...");
callback();
}, 1000);
},
);
}
}
export default HelloAsyncPlugin;
tapPromise
当我们使用 tapPromise 方法挂载插件时,需要返回一个 promise,该 promise 在我们的异步任务完成时 resolve。
js
class HelloAsyncPlugin {
apply(compiler) {
compiler.hooks.emit.tapPromise(
"HelloAsyncPlugin",
(compilation) =>
// 返回一个 Promise,当我们完成时 resolve...
new Promise((resolve, reject) => {
setTimeout(() => {
console.log("异步工作完成...");
resolve();
}, 1000);
}),
);
}
}
export default HelloAsyncPlugin;
示例
一旦我们能够挂载到 webpack compiler 和每个单独的 compilation 上,引擎本身能做的事情就变得无穷无尽。我们可以重新格式化现有文件、创建派生文件,或者生成全新的资源。
让我们编写一个示例插件,生成一个名为 assets.md 的新构建文件,其内容将列出构建中的所有资源文件。这个插件可能看起来像这样:
js
class FileListPlugin {
static defaultOptions = {
outputFile: "assets.md",
};
// 任何选项都应传入插件的构造函数中,
//(这是你插件的公共 API)。
constructor(options = {}) {
// 将用户指定的选项覆盖默认选项
// 并让合并后的选项在插件方法中可用。
// 你还应该在这里验证所有选项。
this.options = { ...FileListPlugin.defaultOptions, ...options };
}
apply(compiler) {
const pluginName = FileListPlugin.name;
// webpack 模块实例可以从 compiler 对象中获取,
// 这确保了使用的是正确版本的模块
//(不要直接 require/import webpack 或其任何符号)。
const { webpack } = compiler;
// Compilation 对象为我们提供了对某些有用常量的引用。
const { Compilation } = webpack;
// RawSource 是应该在编译中用于表示资源来源的 "sources" 类之一。
const { RawSource } = webpack.sources;
// 挂载到 "thisCompilation" 钩子,以便在更早的阶段
// 进一步挂载到编译过程。
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
// 在特定阶段挂载到资源处理流水线。
compilation.hooks.processAssets.tap(
{
name: pluginName,
// 使用较晚的资源处理阶段之一,以确保
// 所有资源都已经由其他插件添加到 compilation 中。
stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(assets) => {
// "assets" 是一个包含 compilation 中所有资源的对象,
// 对象的键是资源的路径名,值是文件内容。
// 遍历所有资源并为我们的 Markdown 文件生成内容。
const content = `# 本次构建包含:\n\n${Object.keys(assets)
.map((filename) => `- ${filename}`)
.join("\n")}`;
// 向 compilation 中添加新资源,这样 webpack 就会自动
// 在输出目录中生成它。
compilation.emitAsset(
this.options.outputFile,
new RawSource(content),
);
},
);
});
}
}
export default FileListPlugin;
webpack.config.js
js
import FileListPlugin from "./file-list-plugin.js";
// 在 webpack 配置中使用这个插件:
export default {
// …
plugins: [
// 使用默认选项添加插件
new FileListPlugin(),
// 或者:
// 你也可以选择传入任何支持的选项:
new FileListPlugin({
outputFile: "my-assets.md",
}),
],
};
这将生成一个指定名称的 markdown 文件,内容如下:
markdown
# 本次构建包含:
- main.css
- main.js
- index.html
T> 在上面的示例中,我们使用同步的 tap() 方法来挂载 processAssets 钩子,因为我们不需要执行任何异步操作。然而,processAssets 钩子是一个异步钩子,如果你确实需要,也可以使用 tapPromise() 或 tapAsync()。
T> processAssets 钩子还支持 additionalAssets 属性,它允许你的插件不仅拦截其他插件在指定阶段之前添加的资源,还能拦截在更晚阶段添加的资源。这使得插件可以拦截 compilation 中所有的资源。不过在我们的示例中,使用 SUMMARIZE 阶段来捕获之前阶段生成的所有资源已经足够了(在一般情况下这应该涵盖所有资源)。
监听文件变化
当 webpack 以监听模式运行时(webpack --watch 或 webpack serve),它会对每次由文件变化触发的重新构建创建一次新的 compilation。
compiler.modifiedFiles Set 让你的插件能够知道哪些具体文件触发了重新构建,从而可以跳过与无关文件相关的昂贵操作。
这对于只需要响应特定文件变化的插件非常有用(例如,重新生成资源、重新处理模板或使缓存失效)。
js
class WatchNotifierPlugin {
apply(compiler) {
compiler.hooks.watchRun.tap("WatchNotifierPlugin", (compiler) => {
if (compiler.modifiedFiles) {
const changedFiles = [...compiler.modifiedFiles]
.map((file) => ` • ${file}`)
.join("\n");
console.log(`\n文件已更改:\n${changedFiles}`);
}
});
}
}
export default WatchNotifierPlugin;
注意:
compiler.modifiedFiles是一个Set,而不是数组- 在首次(冷)构建时它会是
undefined- 它只在监听模式的重新构建过程中被填充
添加自定义文件依赖
如果你的插件读取了 webpack 默认不跟踪的外部文件(配置文件、模板等),你必须告诉 webpack 去监听它们。
你可以告诉 webpack 监听不同类型的依赖:
-
compilation.fileDependencies用于跟踪你的插件所依赖的单个文件,这样 webpack 可以在这些文件变化时触发重新构建 -
compilation.contextDependencies用于监听目录,这样其中任何变化都会触发重新构建 -
compilation.missingDependencies用于跟踪当前缺失的文件,这样 webpack 可以在它们被创建时触发重新构建
js
import path from "node:path";
class TemplateWatchPlugin {
apply(compiler) {
compiler.hooks.compilation.tap("TemplateWatchPlugin", (compilation) => {
const templatePath = path.resolve(__dirname, "my-template.html");
// 确保 webpack 监听这个文件
compilation.fileDependencies.add(templatePath);
// 监听一个目录(上下文依赖)
const templatesDir = path.resolve(__dirname, "templates");
compilation.contextDependencies.add(templatesDir);
// 示例:标记一个缺失的依赖
const missingFile = path.resolve(__dirname, "missing-file.txt");
compilation.missingDependencies.add(missingFile);
});
}
}
export default TemplateWatchPlugin;
如果不调用 fileDependencies.add(),即使你的插件依赖某个文件,当该文件发生变化时 webpack 也不会触发重新构建。
不同的插件形态
插件可以根据其挂载的事件钩子进行分类。每个事件钩子都预先定义为同步、异步、瀑布流或并行钩子,并且通过 call/callAsync 方法在内部调用。支持的或可以挂载的钩子列表通常在 this.hooks 属性中指定。
例如:
js
this.hooks = {
shouldEmit: new SyncBailHook(["compilation"]),
};
这表示唯一支持的钩子是 shouldEmit,它是一个 SyncBailHook 类型的钩子,并且任何挂载 shouldEmit 钩子的插件都会收到唯一的参数 compilation。
支持的钩子类型有:
同步钩子
-
SyncHook
- 定义为
new SyncHook([params]) - 使用
tap方法挂载。 - 使用
call(...params)方法调用。
- 定义为
-
Bail 钩子
- 使用
SyncBailHook[params]定义 - 使用
tap方法挂载。 - 使用
call(...params)方法调用。
在这类钩子中,每个插件回调会按顺序被依次调用,并传入指定的
args。如果任何插件返回了除undefined以外的值,该值会由钩子返回,并且不再调用后续插件回调。许多有用的事件如optimizeChunks、optimizeChunkModules都是 SyncBailHook。 - 使用
-
Waterfall 钩子
- 使用
SyncWaterfallHook[params]定义 - 使用
tap方法挂载。 - 使用
call(...params)方法调用。
在这里,每个插件按顺序被调用,参数来自前一个插件的返回值。插件必须考虑其执行顺序。它必须接受前一个执行插件的参数。第一个插件的值是
init。因此,waterfall 钩子至少需要提供一个参数。这种模式用于与 webpack 模板相关的 Tapable 实例,如ModuleTemplate、ChunkTemplate等。 - 使用
异步钩子
-
Async Series Hook
- 使用
AsyncSeriesHook[params]定义 - 使用
tap/tapAsync/tapPromise方法挂载。 - 使用
callAsync(...params)方法调用。
插件处理函数会以所有参数以及一个签名如
(err?: Error) -> void的回调函数被调用。处理函数按照注册顺序被调用。当所有处理函数被调用后,callback被调用。这也是emit、run等事件常用的模式。 - 使用
-
Async Waterfall 插件将以异步瀑布流方式应用。
- 使用
AsyncWaterfallHook[params]定义 - 使用
tap/tapAsync/tapPromise方法挂载。 - 使用
callAsync(...params)方法调用。
插件处理函数会以当前值和一个签名如
(err: Error, nextValue: any) -> void的回调函数被调用。当调用nextValue时,它将成为下一个处理函数的当前值。第一个处理函数的当前值是init。在所有处理函数应用后,callback以最后一个值被调用。如果任何处理函数传入了err值,callback会以该错误被调用,并且不再调用更多处理函数。这种插件模式预期用于before-resolve和after-resolve事件。 - 使用
-
Async Series Bail
- 使用
AsyncSeriesBailHook[params]定义 - 使用
tap/tapAsync/tapPromise方法挂载。 - 使用
callAsync(...params)方法调用。
- 使用
-
Async Parallel
- 使用
AsyncParallelHook[params]定义 - 使用
tap/tapAsync/tapPromise方法挂载。 - 使用
callAsync(...params)方法调用。
- 使用
配置默认值
webpack 会在应用插件默认值之后应用配置默认值。这允许插件提供自己的默认值,并为创建配置预设插件提供了一种方式。
示例:AssetLoggerPlugin
以下示例展示了一个使用 webpack 日志接口记录所有生成资源的最小化 webpack 插件。
js
class AssetLoggerPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("AssetLoggerPlugin", (compilation) => {
const logger = compilation.getLogger("AssetLoggerPlugin");
compilation.hooks.processAssets.tap(
{
name: "AssetLoggerPlugin",
stage: compilation.constructor.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(assets) => {
logger.info("生成的资源:");
for (const assetName of Object.keys(assets)) {
logger.info(assetName);
}
},
);
});
}
}
export default AssetLoggerPlugin;
该插件挂载到 webpack compiler 的 processAssets 钩子,并将所有生成资源的名称打印到控制台。它演示了插件如何使用 webpack 钩子与编译过程交互。
例如,当运行一次 webpack 构建时,输出可能如下所示:
text
生成的资源:
main.js
vendor.js
styles.css
测试你的插件
测试插件类似于测试其他任何构建工具。推荐的方法是使用你的插件运行一次 webpack 编译,并验证编译产生了预期结果。
一个典型的插件测试应该:
- 创建一个包含你的插件的 webpack 配置。
- 使用 Node.js API 运行一次编译。
- 根据插件的行为,断言预期的编译输出、生成的资源、警告或错误。
例如,使用 Jest:
js
import webpack from "webpack";
import MyPlugin from "../src/MyPlugin.js";
test("成功运行插件", (done) => {
const compiler = webpack({
mode: "development",
entry: "./fixtures/index.js",
plugins: [new MyPlugin()],
});
compiler.run((err, stats) => {
expect(err).toBeNull();
expect(stats.hasErrors()).toBe(false);
// 添加验证你的插件行为的断言。
done();
});
});
具体的断言取决于你的插件做什么。例如,你可能会验证输出的资源、转换后的源代码、警告、错误或对 compilation 所做的更改。
有关插件测试的真实示例,可以浏览 webpack 仓库和 webpack 生态中的插件,了解它们如何编译 fixtures 并断言结果输出。
帮助我们改进文档
发现翻译问题或内容错误?请告诉我们。
