本篇是 Rspack 源码解析系列第二十篇。前面几乎每一篇都出现了
define_hook!、#[plugin]、#[plugin_hook]和.tap(...),但一直没解释它们背后是什么。本文进入rspack_hook和rspack_macros,看 Rspack 如何用过程宏生成一套类型安全的插件系统。
前言:为什么需要 typed Hook
webpack 的 Tapable 是 JavaScript 的,tap 时传一个函数,参数靠约定。Rust 不同:如果插件传错参数类型,编译就该报错。Rspack 的答案是 typed Hook:
text
每个 Hook 声明时固定参数类型
插件 tap 时必须匹配这个签名
Series / SeriesBail 等语义由类型表达
这套能力集中在两个 crate:rspack_hook(运行时接口)和 rspack_macros(编译期宏)。
源码入口:rspack_hook 的两个 trait
crates/rspack_hook/src/lib.rs 里定义了核心 trait:
rust
#[async_trait]
pub trait Interceptor<H: Hook> {
async fn call(&self, _hook: &H) -> Result<Vec<<H as Hook>::Tap>> {
unreachable!("Interceptor::call should only used in async hook")
}
fn call_blocking(&self, _hook: &H) -> Result<Vec<<H as Hook>::Tap>> {
unreachable!("Interceptor::call_blocking should only used in sync hook")
}
}
pub trait Hook {
type Tap;
fn used_stages(&self) -> FxHashSet<i32>;
fn intercept(&mut self, interceptor: impl Interceptor<Self> + Send + Sync + 'static)
where
Self: Sized;
}
Hook::Tap 是关联类型------每个 Hook 自己决定"tap 进去的是什么"。used_stages() 返回当前 Hook 已经使用了哪些 stage,这个信息会被 JS 插件桥接层(第十三篇)用来按需查询 JS tap。
define_hook!:声明一个 Hook 类型
define_hook! 是一个过程宏,入口在 rspack_macros/src/lib.rs:
rust
#[proc_macro]
pub fn define_hook(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = syn::parse_macro_input!(input as hook::DefineHookInput);
match input.expand() {
syn::Result::Ok(tt) => tt,
syn::Result::Err(err) => err.to_compile_error(),
}
.into()
}
它把 hook::DefineHookInput 解析后交给 expand()。我们在各 crate 里看到的:
rust
define_hook!(NormalModuleFactoryBeforeResolve: SeriesBail(data: &mut ModuleFactoryCreateData) -> bool);
define_hook!(NormalModuleFactoryResolve: SeriesBail(data: &mut ModuleFactoryCreateData) -> NormalModuleFactoryResolveResult);
会被展开成一组结构体和实现。SeriesBail 表示"串行执行,某个 tap 返回 bail 值就短路",参数类型和返回类型都写死在声明里。
不同 Hook 语义由宏区分
从前面文章看到的 Hook 命名可以归纳出几种语义:
text
Series:串行执行,都跑完
SeriesBail:串行执行,遇到 Some(...) 就短路
SeriesBail + 返回 bool:Some(true) 表示"改变了,重跑"
例如 OptimizeModulesPass 反复执行 optimize_modules 直到 Some(true) 消失,靠的就是 SeriesBail 的 bool 语义。宏把这些语义固化,插件作者不用手写遍历逻辑。
#plugin:给结构体生成插件骨架
rspack_plugin_split_chunks 里是这样写的:
rust
#[plugin]
pub struct SplitChunksPlugin {
cache_groups: Box<[CacheGroup]>,
fallback_cache_group: FallbackCacheGroup,
hide_path_info: bool,
}
#[plugin] 宏(plugin::expand_struct)会为这个结构体生成一个 new_inner 构造函数和必要的辅助方法。注意 SplitChunksPlugin::new 里调用的是:
rust
Self::new_inner(
options.cache_groups.into(),
options.fallback_cache_group,
options.hide_path_info.unwrap_or(false),
)
new_inner 不是手写的,而是宏生成的。这样插件作者只需要声明字段,构造函数由宏统一生成。
#plugin_hook:把一个函数变成 tap
#[plugin_hook] 是使用最频繁的宏。看 SplitChunksPlugin 的注册:
rust
#[plugin_hook(CompilationOptimizeChunks for SplitChunksPlugin, stage = Compilation::OPTIMIZE_CHUNKS_STAGE_ADVANCED)]
async fn optimize_chunks(&self, compilation: &mut Compilation) -> Result<Option<bool>> {
// ...
}
宏(plugin::expand_fn)做了几件事:
- 生成一个实现了对应 Hook trait 的 wrapper 类型;
- 让
optimize_chunks::new(self)返回这个 wrapper; - 把函数参数映射到 Hook 声明的参数类型。
所以 apply 里能写:
rust
ctx.compilation_hooks.optimize_chunks.tap(optimize_chunks::new(self));
这里的 optimize_chunks::new 也是宏生成的,self 被包装进 tap 对象。
宏的完整展开链路
从宏入口 rspack_macros/src/lib.rs 可以看到它导出的全部宏:
rust
#[proc_macro_attribute] pub fn impl_runtime_module(...) -> ... { ... }
#[proc_macro_attribute] pub fn impl_source_map_config(...) -> ... { ... }
#[proc_macro_attribute] pub fn plugin(...) -> ... { ... }
#[proc_macro_attribute] pub fn plugin_hook(...) -> ... { ... }
#[proc_macro] pub fn define_hook(...) -> ... { ... }
#[proc_macro_derive(MergeFrom, attributes(merge_from))]
pub fn merge_from_derive(...) -> ... { ... }
这些宏分别对应 mod hook、mod plugin、mod runtime_module、mod source_map_config、mod merge。整套插件系统就是这些过程宏组合出来的。
为什么用宏而不是纯 trait
有人会问:直接用 trait 和泛型不也能实现 Hook 吗?Rspack 用宏的原因有几个:
- 减少样板:每个 Hook 都要手写 trait、实现、tap 类型,宏能一次性生成;
- 类型安全:Hook 参数在声明处固定,插件 tap 时编译器会检查;
- 统一约定 :
Series、SeriesBail、stage、interceptor 的语义集中在宏里,不会各写各的; - 可扩展:新增一种 Hook 语义只需在宏里加一个分支。
代价是宏的展开逻辑比较难读,但换来的是上层插件代码非常干净。
interceptor 与 JS 桥接的关系
回到 rspack_hook 的 Interceptor trait。第十三篇讲的 JS 插件桥接,本质上就是给 Rust Hook 挂一个 interceptor:
text
Rust Hook 准备执行
-> interceptor.call(hook) 拿到 JS taps
-> 包装成 Rust tap
-> 按 stage 执行
used_stages() 让 interceptor 知道当前 Hook 有哪些 stage 已被使用,从而决定要不要跨 N-API 去查询 JS 侧 tap。宏生成的 Hook 结构体内部维护了 stages 集合,used_stages() 就是读取它。
一个 Hook 从声明到执行的完整流程
text
define_hook! 声明 Hook 类型
|
v
#[plugin_hook] 把 async fn 包装成 tap 对象
|
v
Plugin::apply 里 .tap(tap_obj)
|
v
Compilation Pass 调用 .call(...)
|
v
Hook 按 stage 顺序执行 taps
|
v
(可选) interceptor 从 JS 侧补充 taps
每一步都由宏或类型系统约束,插件作者只需要关心"我在哪个阶段、拿到什么参数、要做什么"。
Rust 角度:过程宏是 API 的一部分
对 Rust 初学者来说,#[plugin]、#[plugin_hook] 这些宏不是"语法糖装饰",而是生成真实代码的编译器插件。阅读 Rspack 源码时,遇到宏先不要试图脑补完整展开,而是记住它的语义:
text
#[plugin] -> 生成结构体构造骨架
#[plugin_hook] -> 生成 tap wrapper + new 函数
define_hook! -> 生成 Hook 类型 + 调用语义
需要看细节时再去 rspack_macros/src/hook.rs 和 plugin.rs 里追展开逻辑。
这一篇应该带走什么
- Rspack 用 typed Hook 保证插件参数类型安全;
define_hook!声明 Hook 类型和 Series/SeriesBail 语义;#[plugin]生成结构体构造骨架,#[plugin_hook]生成 tap wrapper;Hook::Tap关联类型让每个 Hook 自定义 tap 形态;Interceptor+used_stages是 JS 插件桥接的运行时基础。
写在最后
Hook 系统让插件能接入编译流水线,但插件之间的"数据边界"和"阶段顺序"还需要更明确的契约------这就是 ApplyContext 和 Pass 体系。不过在那之前,我们还要看一个更底层的问题:Rspack 如何把编译结果序列化到磁盘。下一篇进入 rspack_cacheable 和 rspack_storage,看持久化缓存如何工作。