Rspack 源码解析(十五):Loader Runner 与 JS Loader 桥接

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、依赖收集、错误转换、调度让出和生命周期清理。

这一篇应该带走什么

  1. Loader 位于 resolve 之后、parse 之前,决定 parser 看到的源码;
  2. Rspack 通过 JS loader runner 兼容 webpack loader 生态;
  3. loader 本身也要通过 resolveLoader 解析;
  4. LoaderContext 承载 source、map、依赖、asset、diagnostics 等信息;
  5. loader yield 用于处理 loader 内部再次触发编译时的调度问题。

写在最后

到这里,我们已经看过普通 JS 模块、JS 插件、resolver 和 loader。下一篇换一个角度:当产物不只是 JavaScript 时,Rspack 如何通过不同插件产出 CSS、Asset、Wasm 等多种资源,并统一汇入 Compilation.assets。

相关推荐
YIAN1 小时前
从 SSE 流式到结构化输出:LangChain 三大 OutputParser 与 ToolCall 方案全实战
前端·langchain·node.js
YIAN2 小时前
从 SSE 流式原理到 LangChain 结构化输出:打字机效果与 JSON 解析全方案实战
前端·langchain
Moment2 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试
gnip2 小时前
Flutter 原生插件开发实战指南
前端·flutter
晓得迷路了2 小时前
栗子前端技术周刊第 148 期 - Turborepo 2.11、Chrome 154 iframe、Node.js 26...
前端·javascript·css
IT_陈寒3 小时前
Vite热更新失效?你可能漏了这个配置项
前端·人工智能·后端
10年前端老司机3 小时前
Next.js+LangGraph.js+ 简历工具AI Agent完整落地
前端·langchain·agent
IT_陈寒8 小时前
Java中equals方法比了个寂寞?原来这才是正确的重写姿势
前端·人工智能·后端
默_笙8 小时前
🚓 分诊台与拆题术:让 RAG 学会判断和规划
前端·javascript