Rspack 源码解析(十五):Loader Runner 与 JS Loader 桥接
本篇是 Rspack 源码解析系列第十五篇。第十四篇讲到 NormalModuleFactory 会把 request 解析成 NormalModule,但模块源码并不一定直接来自文件。它可能先经过
babel-loader、sass-loader、vue-loader等 loader 转换。本文沿JsLoaderRspackPlugin看 Rspack 如何在 Rust Core 中调用 JavaScript loader。
前言:Loader 是 webpack 生态的核心资产
很多项目的构建能力都依赖 loader:
js
module.exports = {
module: {
rules: [
{ test: /\.tsx$/, use: ['babel-loader'] },
{ test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] }
]
}
}
这些 loader 大多是 JavaScript 函数。Rspack 的核心编译流程在 Rust 中,因此必须解决:
text
Rust NormalModule build
如何调用 JS loader runner?
这就是 rspack_binding_api 中 loader 桥接层要做的事情。
源码入口:JsLoaderRspackPlugin
JS loader 桥接的核心源码在:
text
crates/rspack_binding_api/src/plugins/js_loader/mod.rs
crates/rspack_binding_api/src/plugins/js_loader/resolver.rs
crates/rspack_binding_api/src/plugins/js_loader/scheduler.rs
JsLoaderRspackPlugin 结构体里有几个关键字段:
rust
pub(crate) struct JsLoaderRspackPlugin {
compiler_id: OnceCell<CompilerId>,
pub(crate) runner_getter: JsLoaderRunnerGetter,
pub(crate) runner: Mutex<Arc<OnceCell<JsLoaderRunner>>>,
pub(crate) loaders_without_pitch: RwLock<FxHashSet<String>>,
}
runner_getter 用来从 JS Compiler 对象上取 _runLoader;runner 用 OnceCell 缓存 runner,避免每个模块都重新跨语言获取;loaders_without_pitch 用来减少没有 pitch 的 loader 的跨语言调度。
Loader 位于 resolve 之后、parse 之前
一个普通模块的构建顺序可以简化为:
text
resolve request
-> 得到 resource
-> 匹配 rules
-> 解析 loader
-> 执行 loader chain
-> 得到 transformed source
-> parser 解析依赖
-> 进入 ModuleGraph
所以 loader 的输出会直接决定 parser 看到的源码是什么。
例如:
text
index.tsx
-> babel-loader
-> JavaScript source
-> JS parser
或者:
text
style.scss
-> sass-loader
-> css-loader
-> CSS/JS module
JsLoaderRspackPlugin 做什么
绑定层通过 JsLoaderRspackPlugin 注册 loader 相关 Hook。职责可以分成三类:
| 能力 | 作用 |
|---|---|
| resolve_loader | 解析 loader 本身的路径 |
| loader runner | 调用 JS 侧 _runLoader |
| yield 调度 | 处理 loader 中再次触发编译的场景 |
这说明 loader 桥接不是简单调用一个 JS 函数,而是解析、执行、调度和生命周期管理的组合。
apply 方法把这些能力挂到真实 Hook 上:
rust
ctx.normal_module_factory_hooks.resolve_loader.tap(resolver::resolve_loader::new(self));
ctx.normal_module_hooks.loader_should_yield.tap(scheduler::loader_should_yield::new(self));
ctx.normal_module_hooks.loader_yield.tap(scheduler::loader_yield::new(self));
ctx.compiler_hooks.emit.tap(done::new(self));
也就是说,loader 桥接参与了三件事:解析 loader、判断是否让出到 JS、真正调用 JS runner。
loader 本身也要解析
配置里写的是:
js
use: ['babel-loader']
真正执行时需要知道它对应哪个文件:
text
/project/node_modules/babel-loader/lib/index.js
因此 loader request 也要走 resolver,只不过使用的是 resolveLoader 配置。
这和第十四篇形成对应:
text
normal resolver:解析业务模块
loader resolver:解析 loader 模块
两者机制相似,但配置入口不同。
JS loader runner
绑定层会从 JS Compiler 对象上获取 _runLoader,并把它包装成线程安全的可调用对象。
源码里 napi_js_callback 会从 JS Compiler 对象读取 _runLoader:
rust
let run_loader = compiler_object
.get_named_property::<Function<JsLoaderContext, Promise<JsLoaderContext>>>("_runLoader")?;
let ts_fn: JsLoaderRunner = run_loader
.build_threadsafe_function::<JsLoaderContext>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<0>()
.build()?;
这里得到的 JsLoaderRunner 类型是:
rust
ThreadsafeFunction<JsLoaderContext, Promise<JsLoaderContext>, ...>
所以 Rust 侧不是直接调用 JS 函数,而是通过 N-API ThreadsafeFunction 调用,并等待 JS 返回 Promise。
调用模型可以理解为:
text
Rust 构造 JsLoaderContext
-> 调 JS _runLoader
-> JS 执行 pitch / normal loader
-> 返回新的 JsLoaderContext
-> Rust 读取结果并继续构建
传入和返回的核心都是 LoaderContext。因为 loader 不只是转换 source,还会通过 this 访问大量 API。
LoaderContext 里有什么
一个 JS loader 可能这样写:
js
module.exports = function(source, map, meta) {
const callback = this.async();
this.addDependency('config.json');
callback(null, transform(source), map, meta);
};
这要求 LoaderContext 至少能表达:
text
resource
loaders
当前 loader index
source
source map
metadata
dependencies
build dependencies
emitted files
diagnostics
cacheable 状态
JS loader 执行完后,这些结果必须回写到 Rust 的模块构建信息中。
pitch 与 normal loader
webpack loader 有两个阶段:
text
pitch:从左到右
normal:从右到左
例如:
js
use: ['style-loader', 'css-loader', 'sass-loader']
执行顺序大致是:
text
pitch:
style-loader.pitch
css-loader.pitch
sass-loader.pitch
normal:
sass-loader
css-loader
style-loader
某个 pitch 如果返回结果,还可能短路后续流程。
Rspack 为了兼容 webpack loader 生态,需要保留这些语义。因此 Rust 侧不重写 loader runner 的所有行为,而是把 JS loader runner 接入编译流程。
scheduler.rs 里的 loader_should_yield 会根据当前 loader 状态判断是否交给 JS:
rust
match loader_context.state() {
LoaderState::Pitching => { ... }
LoaderState::Normal => Ok(Some(!loader_context.current_loader().request().starts_with(BUILTIN_LOADER_PREFIX))),
...
}
内置 loader 不需要跨到 JS;普通 JS loader 在 normal 阶段需要 yield;pitch 阶段则结合 loaders_without_pitch 做优化。
loader 的副产物
Loader 的结果不只是 source。
它还可能产生:
text
file dependency
context dependency
missing dependency
build dependency
emitted asset
source map
warning / error
cacheable 标记
例如:
js
this.addDependency('theme.config.js')
意味着 watch 模式下,这个配置文件变更也应该触发相关模块重建。
这和前面讲过的 Mutation / Cache 直接相关。Loader 收集到的依赖会进入增量构建失效判断。
this.emitFile 与 assets
某些 loader 会调用:
js
this.emitFile('xxx.png', content)
这类文件会成为模块构建阶段产生的 asset,后续在 CreateModuleAssetsPass 中写入 Compilation.assets,并可能挂到对应 Chunk 的 auxiliary files 上。
因此 loader 不只影响模块源码,也可能影响最终输出文件集合。
loader_should_yield:避免跨语言调度死锁
复杂 loader 可能在执行过程中触发 importModule 一类能力,也就是 loader 内部请求 Rspack 再构建某个模块。
这会形成嵌套调用:
text
Rust 正在等待 JS loader
-> JS loader 请求 Rust 构建子模块
-> Rust 需要继续调度编译任务
如果调度设计不好,就可能死锁。Rspack 因此需要 loader yield 相关 Hook,让 loader 执行过程在必要时让出控制权,允许 Rust 编译任务继续推进。
真正调用 JS runner 的 loader_yield 大致流程是:
rust
let runner = self.runner.lock().unwrap().clone();
let runner = runner.get_or_try_init(|| async {
let compiler_id = self.compiler_id.get().unwrap();
self.runner_getter.call(compiler_id).await
}).await?;
let new_cx = runner
.call_async(loader_context.try_into()?)
.await?
.await?;
merge_loader_context(loader_context, new_cx)?;
这几行把桥接过程说透了:Rust LoaderContext 转成 JsLoaderContext,调用 JS _runLoader,等待 Promise,再把结果 merge 回 Rust。
这是跨语言异步编译中很现实的问题。
runner 为什么需要复用
一个项目中可能有大量模块,每个模块都可能执行 loader。如果每次都重新从 JS 侧获取 _runLoader 并包装,会造成不必要开销。
因此绑定层会懒加载并复用 runner:
text
第一次执行 loader
-> 获取并包装 JS _runLoader
后续执行 loader
-> 复用 runner
compilation 生命周期结束
-> 清理 runner 状态
这和第十三篇的 JS tap 缓存思路一致:跨语言对象要按生命周期复用,同时避免跨 compilation 泄漏。
从 .scss 到输出文件
以 Sass 为例:
text
import './style.scss'
|
v
NormalModuleFactory resolve
|
v
匹配 module.rules
|
v
resolve_loader:
sass-loader / css-loader / style-loader
|
v
JS loader runner 执行 loader chain
|
v
得到 transformed source
|
v
parser 解析依赖
|
v
CodeGeneration
|
v
CSS 或 JS 插件 render_manifest
|
v
Compilation.assets
Loader 发生在早期,但它的输出影响后续所有阶段。
Rust 角度:把生态复杂性限制在边界层
Rspack 没有把所有 loader 重写成 Rust。它选择保留 JS loader runner,并通过 LoaderContext 与 Rust Core 交换数据。
这样做的收益是:
text
兼容现有 loader 生态
Rust Core 保持高性能图构建和代码生成
跨语言复杂性集中在 binding_api
代价是绑定层必须处理 N-API、Promise、source map、依赖收集、错误转换、调度让出和生命周期清理。
这一篇应该带走什么
- Loader 位于 resolve 之后、parse 之前,决定 parser 看到的源码;
- Rspack 通过 JS loader runner 兼容 webpack loader 生态;
- loader 本身也要通过
resolveLoader解析; - LoaderContext 承载 source、map、依赖、asset、diagnostics 等信息;
- loader yield 用于处理 loader 内部再次触发编译时的调度问题。
写在最后
到这里,我们已经看过普通 JS 模块、JS 插件、resolver 和 loader。下一篇换一个角度:当产物不只是 JavaScript 时,Rspack 如何通过不同插件产出 CSS、Asset、Wasm 等多种资源,并统一汇入 Compilation.assets。