背景
在上一篇文章《Rspack 原理:webpack,我为什么不要你》中,我们了解到 Rspack 的核心编译流程是通过 JavaScript 调用 Rust 封装的 JsCompiler 方法完成的。尽管底层由高性能的 Rust 实现驱动,但 Rspack 却依然兼容传统的 JavaScript 插件生态,像 HtmlWebpackPlugin、AssetsWebpackPlugin 这样的常用插件也能正常运行。更多兼容插件可以前往 Plugin 兼容 页面进行查看。
好奇你的估计会疑问,Rspack 是如何让这些 JS 插件在 Rust 引擎中正常工作的?
细心的你估计发现在 JsCompiler 方法调用时,传参里有一个非常关键的参数,就是第四个参数 this.#registers,该参数是 this.#createHooksRegisters 方法的返回值。那么 this.#registers 的作用是啥?它在 Rspack 插件架构里扮演着什么样的角色?该参数传到 Rust 层又是如何被处理?
要回答这些问题,就要了解这背后的关键:Rspack 的插件架构设计。本篇文章将为你一一解答,另外你还能进一步了解:
- Rspack 插件架构是如何实现,和 webpack 的
Tapable在实现上有什么不一样? - Rust 是怎么定义和执行 hook 的?
Tapable和 Rust Hook 的钩子都有哪些特点?- JavaScript 插件是如何注册进来的?
- 当 Rust 层编译流程触发
compiler.hooks.make.call()时,底层发生了什么? - 为什么能同时执行 JavaScript 和 Rust 的插件?顺序是怎么保证的?
本篇文章涉及的代码片段可能相对比较多,如果你在阅读中感觉压力比较大的话,可以跳过这些代码片段,直接看解释,我会针对每段代码都做通俗的解释。
什么是插件
我们先理解一下插件的基本概念,在构建工具中, 插件不仅是一种扩展模块 ,更是一种基于钩子机制或生命周期扩展点的架构模式,这种架构的核心思想是:在核心流程的关键节点上暴露一系列可注册的钩子(Hooks),允许外部插件注入自定义逻辑。这些钩子支持多种执行方式:同步/异步、串行/并行,从而实现灵活而强大的流程控制能力。
这本质上借鉴了发布/订阅模式 的思想,但它比单纯的事件系统更加可控:每次钩子触发时都会携带当前上下文(如 compiler、compilation),插件可以通过操作这些上下文对象来影响后续编译行为。
以 webpack Tapable 的经典插件为例:
js
class MyPlugin {
apply(compiler) {
// 注册 hooks
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
// 可以访问 compilation 对象,添加资源、修改模块等
})
}
}
在这个例子中:
apply(compiler)是插件入口,接收编译器实例;compiler.hooks.compilation是一个钩子;.tap()表示在此钩子上注册一个同步回调;- 回调函数接收
compilation上下文,用于干预构建过程。
Rspack 并不是直接使用 webpack 的 Tapable 库,而是自己实现了一套轻量化的 LiteTapable,并与 Rust 层的 Hook 系统进行双向桥接。这套架构分为两层:
- JS 层 :使用 TypeScript 实现的
LiteTapable,处理 JS 插件注册,收集 Hooks 回调; - Rust 层:使用 Rust 实现的自研 Hook 系统,驱动编译流程,在关键节点触发 Hook。
这两者之间通过一个桥梁机制连接,而这正是开篇我们提到的 this.#createHooksRegisters 方法的使命。该方法执行赋值给 this.#registers 作为 JsCompiler 的第四个参数:

this.#createHooksRegisters 的作用是:将 JavaScript 层所有已注册的插件钩子封装成一种能被 Rust 识别的数据格式,然后传递给 Rust 底层编译器。
换句话说,this.#createHooksRegisters 是一个钩子注册表,记录了所有 JS 插件希望监听的生命周期事件。当 Rust 核心在编译过程中走到某个阶段(比如 make),它会查询这个注册表,并通知 JS 层执行对应的回调。
这样一来,即便核心流程运行在 Rust 中,也能准确地调用 JavaScript 编写的插件逻辑。那么 this.#createHooksRegisters 方法实现是怎样的呢?
#createHooksRegisters 实现
js
#createHooksRegisters(): binding.RegisterJsTaps {
const ref = new WeakRef(this);
const getCompiler = () => ref.deref()!;
const createTap = this.#createHookRegisterTaps.bind(this);
const createMapTap = this.#createHookMapRegisterTaps.bind(this);
return {
...createCompilerHooksRegisters(getCompiler, createTap, createMapTap),
...createCompilationHooksRegisters(getCompiler, createTap, createMapTap),
// ... 更多注册器函数
};
}
#createHooksRegisters 方法最终返回的是一个符合 binding.RegisterJsTaps 接口的对象,可以理解为一个专为 Rust 编译器准备的钩子注册表。
每个 create***HooksRegisters 负责某一类钩子的注册。例如:
createCompilerHooksRegisters:负责Compiler对象的钩子;createCompilationHooksRegisters:负责Compilation对象的钩子;- 其他还包括
ModuleFactory、NormalModule等不同组件的钩子,上面没有列出来。
我们看到 create***HooksRegisters 工厂函数接收三个通用参数:
getCompiler:获取当前compiler实例;createTap:用于注册普通钩子上的插件回调;createMapTap:用来注册那些按 key 动态分发的钩子(比如rule、loader)上的回调。
createTap 和 createMapTap 这两个参数可能有点不好理解,反正我们知道它们的作用是把 JS 插件的回调包装成 Rust 能调用的形式就可以了。
接下来,我们以 createCompilerHooksRegisters 为例,看看这些函数到底是怎么工作的:
js
export const createCompilerHooksRegisters: CreatePartialRegisters<'Compiler'> = (
getCompiler,
createTap
) => {
return {
registerCompilerMakeTaps: createTap(
binding.RegisterJsTapKind.CompilerMake,
// 第二个参数: 获取 JS 层对应的 Hook 实例
function () {
return getCompiler().hooks.make;
},
// 第三个参数: 包装回调函数,支持异步执行
function (queried) {
return async function () {
return await queried.promise(
getCompiler().__internal__get_compilation()!
);
};
}
),
};
};
以上展示了 Compiler 的 make 钩子的注册逻辑,这里的关键在于 createTap 接收了两个函数式参数(重点看第二和第三个参数):
- 第二个参数:返回一个 Hook ,延迟获取 JS 层的
hooks.make实例; - 第三个参数:将原始 tap 回调包装成一个可被 Rust 调用的异步函数,并注入当前
compilation上下文。
这里的
queried.promise(...)并非原生 Promise,而是 Rspack 自定义的一种待执行任务表示方式,用于桥接到 Rust 的异步运行时。
createTap 绑定的函数是 #createHookRegisterTaps:
js
#createHookRegisterTaps(registerKind, getHook, createTap) {
return function getTaps(stages: number[]): JsTap[] {
const hook = getHook();
if (!hook.isUsed()) return [];
// 1. 构建阶段断点 [min, stage1, stage2, ..., max]
const breakpoints = [minStage, ...stages.sort(), maxStage];
const jsTaps = [];
// 2. 按区间遍历,查询每个 stage 范围内的 taps
for (let i = 0; i < breakpoints.length - 1; i++) {
const from = breakpoints[i];
const to = breakpoints[i + 1];
// 查询 [from, to) 区间内注册的所有插件
const queried = hook.queryStageRange([from, to]);
if (queried.taps.length === 0) continue;
jsTaps.push({
function: createTap(queried), // 包装为可调用函数
stage: from + 1 // 对齐执行优先级
});
}
return jsTaps;
};
}
看完上面的代码和注释是不是好累,没关系,我们主要记住它是做了几件关键的事情就行:
- 支持 Stage 分段查询 :Rust 传入
[10, 20]表示希望触发该阶段的插件; - 按序排列,精准控制执行流程 :所有返回的
JsTap按stage排序,确保插件严格按照注册时声明的优先级运行; - 懒加载 + 延迟求值 :
getHook()和回调包装都在实际触发时才执行,避免提前访问未初始化的上下文。
上述所有操作的前提,JS 层必须有一套钩子系统来支撑插件注册与查询。Rspack 使用的是自研的 @rspack/lite-tapable 库:
js
import * as liteTapable from "@rspack/lite-tapable";
this.hooks = {
initialize: new liteTapable.SyncHook([]),
shouldEmit: new liteTapable.SyncBailHook(["compilation"]),
make: new liteTapable.AsyncParallelHook(["compilation"]),
// ...
};
可以看到,API 设计几乎完全兼容 webpack 的 Tapable,不过从名字我们可以知道,它是一个更加轻量的 Tapable。接下来我们深入 @rspack/lite-tapable 的源码,看看它相比 webpack 的 Tapable 到底做了哪些优化。
LiteTapable 架构
LiteTapable 和 webpack 的 Tapable 一样,都是基于订阅/发布模式的设计思想,这意味着你熟悉的 .tap()、.call()、.callAsync() 等写法都可以正常运行。不同的是 LiteTapable 针对 JS 与 Rust 跨语言协作做了特殊优化。LiteTapable 提供如下钩子类型:
同步钩子(Sync Hooks)
同步钩子有如下几种类型:
- SyncHook:最基础的同步钩子,依次执行所有回调,不传返回值,也不中断;
- SyncBailHook :熔断式钩子,一旦某个回调返回非
undefined值,立即停止后续执行。常用于获取结果类场景; - SyncWaterfallHook:瀑布流模式,前一个回调的返回值作为下一个回调的第一个参数,形成链式传递。
我们来看个示例,SyncHook 的典型用法:
js
const { SyncHook } = require('@rspack/lite-tapable');
const syncHook = new SyncHook();
syncHook.tap('first', () => console.log('第一个'));
syncHook.tap('second', () => console.log('第二个'));
syncHook.tap('third', () => console.log('第三个'));
syncHook.call();
输出:
js
// 第一个
// 第二个
// 第三个
示例中,所有回调按注册顺序执行,互不影响,这种钩子适合做初始化或通知类操作。
异步钩子(Async Hooks)
异步钩子常常用于处理涉及 I/O、网络请求或延迟执行等复杂场景。有如下几种类型:
- AsyncSeriesHook :异步串行执行,前一个完成后再执行下一个,支持
callback和Promise风格; - AsyncParallelHook:所有回调并行执行,全部完成后触发结束回调,适合资源并行加载等场景;
- AsyncSeriesBailHook :异步版熔断钩子,任意一个回调返回非
undefined值即终止后续执行; - AsyncSeriesWaterfallHook :异步瀑布流,前一个
Promise的返回值传递给下一个。
同样看个示例,AsyncSeriesHook 实现任务串行化:
js
const { AsyncSeriesHook } = require('@rspack/lite-tapable');
const asyncHook = new AsyncSeriesHook();
asyncHook.tapAsync('step1', (callback) => {
console.log('步骤1: 开始');
setTimeout(() => {
console.log('步骤1: 完成');
callback();
}, 100);
});
asyncHook.tapAsync('step2', (callback) => {
console.log('步骤2: 开始');
setTimeout(() => {
console.log('步骤2: 完成');
callback();
}, 50);
});
asyncHook.callAsync((err) => {
if (!err) console.log('所有步骤已完成!');
});
输出:
js
// 步骤1: 开始
// 步骤1: 完成
// 步骤2: 开始
// 步骤2: 完成
// 所有步骤已完成!
可以看到,即使 step2 耗时更短,也必须等 step1 结束后才开始执行,实现了真正的串行控制。
指定阶段范围查询
针对 JavaScript 与 Rust 交互需求,LiteTapable 进行了特定优化,这其中关键的特性就是 queryStageRange 和 QueriedHook。为什么要这两个功能?我们回想一下 Rspack 的架构:
- JS 层注册插件;
- Rust 层驱动编译流程;
- 当 Rust 触发某个 Hook 时,需要知道哪些 JS 插件应该被调用。
如果每次都把整个 Hook 上的所有 taps 全量传给 Rust,效率极低。于是 LiteTapable 引入了按需筛选机制:queryStageRange(stageRange),按区间提取 taps。
js
// 查询 stage 在 [0, 10) 区间内的所有插件
const queried = hook.queryStageRange([0, 10]);
该方法返回一个 QueriedHook 实例,只包含落在指定阶段范围 [from, to) 内的插件(左闭右开)。QueriedHook 的结构如下:
js
class QueriedHook {
stageRange: StageRange; // 查询区间
hook: HookBase; // 原始 hook
tapsInRange: FullTap[]; // 已筛选出的 tap 列表
}
QueriedHook 的 call() / callAsync() 方法只会执行 tapsInRange 中的回调,避免重复筛选。这里的设计是非常巧妙的,因为筛选是发生在 JS 层,所以执行信息会以精简结构传给 Rust,极大减少了跨语言调用的开销。
Rust Hook 架构
前面我们讲到,Rspack 通过 createCompilerHooksRegisters 等工厂函数,将 JS 插件的注册信息打包成一个对象(如 RegisterJsTaps),传递给 Rust 层:
js
{
registerCompilerMakeTaps: () => [...],
registerThisCompilationTaps: () => [...],
// 更多 hooks...
}
但这只是数据传递。真正的问题是:
- Rust 是如何接收这些函数,并在合适时机调用它们的?
- Rust 自己写的插件和 JS 插件,是怎么一起工作的?
这个涉及到了 Rspack Rust Hook 架构的核心设计:基于宏的 Hook 系统 + 拦截器(Interceptor)模式。
define_hook! 宏
在 Rust 侧,所有钩子都通过 define_hook! 宏声明。例如:
宏(macro) 是 Rust 的一种编译时代码生成机制 ,可以在编译时自动生成重复或复杂的代码。这里的
define_hook!宏会在编译阶段生成完整的 Hook 类型系统,从而让 Hook 的定义更简洁高效。
js
// 定义 CompilerMake hook
define_hook!(CompilerMake: Series(compilation: &mut Compilation));
这行代码的意思是:
- 定义一个名为
CompilerMake的 hook; - 执行模式为
Series(串行执行); - 接收参数:一个可变引用的
compilation对象。
这行代码在编译期会自动生成三部分代码:
1、Trait:插件的行为契约
js
pub trait CompilerMake {
async fn run(&self, compilation: &mut Compilation) -> Result<()>;
fn stage(&self) -> i32 { 0 } // 默认阶段
}
任何想监听 CompilerMake 的插件,都必须实现这个 trait。
trait 类似于 JavaScript 中的 interface。
2、Hook 结构体:统一管理两类插件
每个 Hook 都对应一个结构体,用来存储两类监听者:
js
pub struct CompilerMakeHook {
taps: Vec<Box<dyn CompilerMake + Send + Sync>>, // Rust 原生插件
interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // JS 插件拦截器
}
taps:存放直接用 Rust 实现的插件;interceptors:拦截器,用于动态获取 JavaScript 插件。
为什么需要拦截器?因为 JS 插件可能在任意时刻注册,Rust 无法在启动时预知所有回调。所以采用懒加载策略,只在真正触发 Hook 时,才去 JS 层查询当前有效的插件列表。
3、call 方法:统一执行入口
当某个阶段需要触发 Hook 时(如 make),Rspack 会调用:
js
compiler.hooks.make.call(&mut compilation).await?;
这会进入宏生成的执行逻辑:
js
impl CompilerMakeHook {
pub async fn call(&self, compilation: &mut Compilation) -> Result<()> {
let mut all_taps = Vec::new();
// 1. 通过拦截器获取 JS 插件
for interceptor in &self.interceptors {
let js_taps = interceptor.call(self).await?;
all_taps.extend(js_taps);
}
// 2. 加入 Rust 插件
all_taps.extend(&self.taps);
// 3. 按 stage 排序
all_taps.sort_by_key(|tap| tap.stage());
// 4. 串行执行(Series 模式)
for tap in all_taps {
tap.run(compilation).await?;
}
Ok(())
}
}
看到这里你会发现,无论来源是 JS 还是 Rust,最终都被统一成相同的类型,按 stage 排序后依次执行。
如何编写一个 Rust 插件
在 Rspack 编写一个 Rust 插件是使用到了两个过程宏:
#[plugin]:标记插件结构体;#[plugin_hook(HookName for Plugin)]:自动生成符合 Hook 约定的回调,还记得上面我们提到的插件的行为契约吗?#[plugin_hook]宏会自动为你生成run方法(以及完整的 trait 实现)。
示例:监听 compilation 钩子:
js
#[plugin]
#[derive(Debug)]
pub struct MyPlugin;
// 实现构造函数
impl MyPlugin {
pub fn new() -> Self {
Self::new_inner()
}
}
#[plugin_hook(CompilerCompilation for MyPlugin)]
async fn compilation(
&self,
_compilation: &mut Compilation,
_params: &mut CompilationParams,
) -> Result<()> {
// 插件逻辑
println!("compilation 阶段执行");
Ok(())
}
impl Plugin for MyPlugin {
fn name(&self) -> &'static str {
"MyPlugin"
}
fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
// 注册 hooks
ctx.compiler_hooks.compilation.tap(compilation::new(self));
Ok(())
}
}
不同类型 Hook 的执行模式
define_hook! 支持多种执行模式,对应 Tapable 的经典模式:
| 执行模式 | 类比 Tapable | 说明 |
|---|---|---|
| Series | AsyncSeriesHook | 串行执行,全部完成后结束 |
| SeriesBail | AsyncSeriesBailHook | 串行执行,遇到 Some(value) 即终止并返回 |
| SeriesWaterfall | AsyncSeriesWaterfallHook | 串行执行,每个 tap 的返回值作为下一个 tap 的输入 |
| Parallel | AsyncParallelHook | 并发执行,等待全部完成 |
关键桥梁:JsHooksAdapterPlugin
前面提到,每个 Hook 结构体都包含一个 interceptors 字段,用于在运行时动态获取 JavaScript 插件。那么,这些在 JS 中通过 .tap() 注册的回调,是如何被转换为 Rust 可调用的拦截器(interceptor)的呢?
关键正是 JsHooksAdapterPlugin ,它负责将 JavaScript 插件注册信息适配为 Rust 侧的 Interceptor 实例,并注入到对应 Hook 的 interceptors 列表中。
初始化:接收 JS 注册函数
Rust 在创建 JsCompiler 时,它会接收到一组来自 JavaScript 侧的插件注册信息,这些信息由 register_js_taps 对象承载:
js
let js_hooks_plugin = JsHooksAdapterPlugin::from_js_hooks(env, register_js_taps)?;
plugins.push(js_hooks_plugin.clone().boxed());
这个 JsHooksAdapterPlugin 的核心职责是:为每一个支持的 Hook(如 make、compilation 等)生成一个对应的 Interceptor 实例:

register_js_taps.register_compiler_make_taps 被包装成 RegisterCompilerMakeTaps 结构体,该结构体实现了 Interceptor<CompilerMakeHook> trait。
注册为拦截器
在插件系统的 apply 阶段,JsHooksAdapterPlugin 会将这些拦截器注册到对应的 Hook 上:
js
impl Plugin for JsHooksAdapterPlugin {
fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
// 将 JS 的 make 钩子注册为 CompilerMakeHook 的 interceptor
ctx.compiler_hooks.make.intercept(self.register_compiler_make_taps.clone());
// 同样处理 compilation、emit 等其他钩子
ctx.compiler_hooks.compilation.intercept(self.register_compilation_taps.clone());
// ...
Ok(())
}
}
这里的 .intercept(...) 方法会把拦截器存入 Hook 结构体的 interceptors 字段中:
js
pub struct CompilerMakeHook {
taps: Vec<Box<dyn CompilerMake + Send + Sync>>,
interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // 注意这里
}
完整调用流程:以 compiler.hooks.make 为例
看完上面的逻辑估计你会觉得有点乱,我们以 compiler.hooks.make.call() 为例,走一遍完整的调用链。当 Rspack 执行到 Compiler::compile 中的 make 阶段时,会发生以下步骤:
1、触发 make hook

该方法会进入 CompilerMakeHook::call() 方法。前面我们已经介绍过 CompilerMakeHook 的 call 方法。
2、call 方法会遍历所有拦截器,查询 JS 插件
js
for interceptor in &self.interceptors {
let js_taps = interceptor.call(self).await?; // 关键看这里的 call 方法
all_taps.extend(js_taps);
}
注意上面的 interceptor.call(),它实际上是调用了 RegisterCompilerMakeTaps::call()。
3、调用 JavaScript 的函数
js
async fn call(&self, _hook: &CompilerMakeHook) -> Result<Vec<CompilerMakeTap>> {
let used_stages = hook.used_stages(); // 获取当前活跃的 stage 范围
// 调用 JS 函数,拿到该 Hook 下的所有 taps
let js_taps = self.register.call_with_sync(used_stages).await?;
// 包装成 Rust 可执行的 Tap 对象
let rust_taps = js_taps.into_iter()
.map(|t| Box::new(CompilerMakeTap::from_js_tap(t)))
.collect();
Ok(rust_taps)
}
其中 self.register 就是你传进来的那个 registerCompilerMakeTaps 函数。
4、JavaScript 层查询 LiteTapable
还记得我们在 JS 层有一个轻量级的 LiteTapable 吗?它的作用就在这里:
js
function registerCompilerMakeTaps(stages: number[]) {
const hook = compiler.hooks.make;
if (!hook.isUsed()) return [];
const taps = hook.queryStageRange(stages); // 查询指定 stage 范围内的插件
return taps.map(wrapTapAsFunction); // 包装成函数数组
}
这个设计很巧妙:只有在真正调用 Hook 时才去查询 JS 插件,避免了提前加载的开销,也支持动态注册。
5、合并与排序
收集完 JS 插件后,和本地的 Rust 插件合并:
js
all_taps.extend(&self.taps); // 加入 Rust 插件
all_taps.sort_by_key(|tap| tap.stage()); // 按 stage 升序排列
6、统一执行
最后按照执行模式逐一调用:
js
for tap in &all_taps {
tap.run(compilation).await?;
}
所有插件,无论是 JS 还是 Rust,都被当作同一个抽象类型来处理。
总结
到目前为止,我们已经完整地介绍了 Rspack 的插件架构原理,恭喜你能坚持看到这里,Rspack 的插件架构比 webpack 的插件架构要复杂得多,从 Tapable 到 Rust Hook 的架构设计,Rspack 不仅能完全兼容 JavaScript 的插件体系,还构建了自身高性能的 Rust Hook 插件系统。值得强调的是,Rspack 并不是简单地用 Rust 重写 Tapable,而是一次非常用心的架构设计。
这实现过程中,Rspack 插件架构融合了多个优秀的设计思想。例如,通过统一执行模型来抹平语言差异;通过延迟查询实现性能与兼容兼顾;通过 interceptors 拦截器的引入,体现了对扩展开放,对修改封闭的设计原则等。