Rspack 源码解析(二十):Typed Hook 系统如何靠宏生成

本篇是 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)做了几件事:

  1. 生成一个实现了对应 Hook trait 的 wrapper 类型;
  2. 让 optimize_chunks::new(self) 返回这个 wrapper;
  3. 把函数参数映射到 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 用宏的原因有几个:

  1. 减少样板:每个 Hook 都要手写 trait、实现、tap 类型,宏能一次性生成;
  2. 类型安全:Hook 参数在声明处固定,插件 tap 时编译器会检查;
  3. 统一约定 :Series、SeriesBail、stage、interceptor 的语义集中在宏里,不会各写各的;
  4. 可扩展:新增一种 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 里追展开逻辑。

这一篇应该带走什么

  1. Rspack 用 typed Hook 保证插件参数类型安全;
  2. define_hook! 声明 Hook 类型和 Series/SeriesBail 语义;
  3. #[plugin] 生成结构体构造骨架,#[plugin_hook] 生成 tap wrapper;
  4. Hook::Tap 关联类型让每个 Hook 自定义 tap 形态;
  5. Interceptor + used_stages 是 JS 插件桥接的运行时基础。

写在最后

Hook 系统让插件能接入编译流水线,但插件之间的"数据边界"和"阶段顺序"还需要更明确的契约------这就是 ApplyContext 和 Pass 体系。不过在那之前,我们还要看一个更底层的问题:Rspack 如何把编译结果序列化到磁盘。下一篇进入 rspack_cacheable 和 rspack_storage,看持久化缓存如何工作。

相关推荐
程序员大阳1 小时前
副队长CSS教程(29)–CSS Grid 实战:实现通用图文混排布局
前端·css·grid·图文混排
对空六课1 小时前
无头浏览器访问对跳出率的过滤规则
服务器·前端·算法·数据分析
小番茄夫斯基1 小时前
Nginx 介绍
前端
小番茄夫斯基2 小时前
如果我一个前端页面要被用户看到,原理是什么,用户怎么通过网址就看到页面的,这背后要怎么弄
前端
迅猛龙办公室2 小时前
Python实现获取星期字符串
java·前端·python
小小荧2 小时前
🚀 Node.js 之父离开 Deno,带着整个团队加入了 Cloudflare
前端·node.js
IllegalCreed2 小时前
人中吕布,卡中 Blackwell:我用三国志14的数值给 687 个大模型排了座位
前端
troy1282 小时前
Java Web 常见面试题(2026 最新整理)
java·前端·hive