JavaScript API 架构
Rspack 通过 @rspack/core 提供兼容 webpack 的 JavaScript API,而大部分编译工作运行在 Rust 中,两层之间通过 Node-API binding 通信。
什么是 native-backed 对象
普通 JavaScript 对象的数据直接保存在 JavaScript 引擎中。例如读取 { name: 'main' } 的 name 属性时,JavaScript 可以直接得到结果,不需要调用 Rust。
Rspack 的 Compilation、Module、Chunk 和 graph 等对象有所不同。它们的编译数据主要保存在 Rust 中,JavaScript 对象通常只保存用于找到 Rust 数据的标识或内部句柄;当插件读取某些 getter 或调用 method 时,Rspack 会通过 Node-API binding 访问 Rust,再把结果转换成 JavaScript 值。这类对象称为 native-backed 对象。
两种对象看起来可能使用完全相同的 JavaScript 语法,但行为不同:
一个 native-backed 对象可以同时包含两类属性:
- 字符串、数字等不可变数据可能在创建对象时就复制到 JavaScript,之后读取不需要访问 Rust;
- getter 和 method 可能在每次调用时访问 Rust,以获得当前编译数据或执行操作。
不能只根据 JavaScript 对象是否仍然存在,判断它仍持有创建时的数据。rebuild 后,getter 或 method 可能会从最新的 Rust compilation 重新获取数据,也可能因为目标已经不存在而报错。
这种实现方式带来两个直接影响:
- 生命周期:native-backed 对象的含义通常只有在产生它的 compiler、compilation 或 hook 执行范围内才是可靠的。
- 性能:读取 getter 或调用 method 可能发生 JavaScript 和 Rust 之间的数据转换。在循环中重复访问同一属性,成本会累积。
如果需要在之后使用数据,应在 native-backed 对象所表示的构建仍然活跃时,提取字符串、数字或普通 JavaScript 对象,而不是长期保存这个对象。
Compiler 生命周期
创建 @rspack/core 的 Compiler 时,Rspack 不会立即创建对应的 native compiler。配置规范化和插件 apply 首先在 JavaScript 侧完成;直到第一次调用 run()、watch() 等构建 API 时,Rspack 才会按需创建 Rust compiler。
native compiler 创建完成后,下面这些信息已经固定并传递到 Rust:
- 规范化后的配置和 builtin plugin;
- JavaScript hook 与 native hook adapter 之间的注册函数;
- 用于跨语言调用的文件系统和 resolver factory
不要假设 native compiler 创建后,对 compiler.options 的任意修改仍会同步到 Rust。配置修改和插件应用应在第一次调用 run() 或 watch() 前完成。
同一个 Compiler 实例在任意时刻只存在一个活跃的 native Compilation。每次构建,包括 watch rebuild,都会替换这个 native compilation。
在开发模式下,一些 webpack 插件会在一次构建的 done hook 结束后,继续使用该构建的 JavaScript Compilation。这些后续操作执行时,watch rebuild 可能已经开始,从而出现插件持有上一次构建 Compilation 的竞态。为了兼容这种常见写法,Rspack 将所有 JavaScript Compilation 都作为 compiler 当前 native compilation 的视图。因此,旧对象上的 getter 和 method 可能会读取或修改最新一次构建,而不是报错。但保留这个对象并不能保留上一次构建的数据。
watch 模式下,应使用当前构建 hook 提供的 Compilation 和 native-backed 对象。如果需要保存历史数据,应在对应构建期间将其复制为普通 JavaScript 值。
不再使用 compiler 时,应调用 close() 并等待 callback 完成。这会等待当前编译结束并释放 compiler 持有的 native 资源和 JavaScript callback。
Compilation 和对象生命周期
每次构建,包括 watch rebuild,都会创建新的 Compilation。从 Compilation 获取的 Module、Chunk 和 graph 等对象,并不会把全部数据复制到 JavaScript。读取其中一些属性或调用方法时,Rspack 仍需要通过 N-API 从当前 Rust compilation 获取数据。
建议遵守以下规则:
- 只在当前构建的 hook 和回调中使用对应的
Compilation、Module、Chunk、graph、dependency 和 block。 - 除非 API 文档明确说明可以长期使用,否则不要把这些对象保留到 hook 结束后或下一次构建。
- 如果稍后仍需要某些信息,应提前保存 identifier、name、path、hash 等字符串或数字,或者转换成普通 JavaScript 对象。
- watch rebuild 后,应从新的
Compilation重新获取对象,不要复用上一次构建保存的对象。
Hook 和回调执行
Rust compiler 可以使用多个线程,同时执行多个彼此独立的编译任务。但是,JavaScript hook、loader 和其他回调不能直接在这些 Rust 线程上运行,它们必须被调度回创建 Rspack compiler 的 JavaScript 环境。
同一个 JavaScript 环境中的用户代码由一个 JavaScript 线程执行。即使多个 Rust 线程同时触发回调,这些回调也需要先进入队列,再由 JavaScript 线程逐个执行:
hook 和回调的成本不只有 Node-API 数据转换和线程调度。更重要的是,它们会形成一个串行执行点:原本可以并行运行的多个 Rust 任务,可能同时等待 JavaScript 队列。回调越频繁、执行时间越长,Rust 并行带来的收益就越容易被抵消。回调之前和之后的 Rust 工作仍然可以并行,但依赖 JavaScript 结果的这一段会受到单线程吞吐量限制。
Loader 为什么尤其容易成为瓶颈
Rspack 可以同时处理多个文件,但 JavaScript loader 需要在 JavaScript 线程上执行。当多个文件同时等待 loader 处理时,对应的 JavaScript loader 会排队;等待 loader 结果的 Rust 编译任务也会停在这里。因此,针对每个文件调用的 JavaScript loader 越重、调用次数越多,整体构建就越接近串行执行。
如果功能相同,应优先使用 Rspack 的 builtin loader。例如:
- 使用
builtin:swc-loader执行 JavaScript 和 TypeScript 转换; - 使用
builtin:lightningcss-loader执行 CSS 转换。
Builtin loader 可以在 Rust 侧执行,减少 JavaScript 排队和跨语言数据转换,并更好地利用 Rspack 的并行能力。
Loader 源码的所有权
原生 loader runner 用 Box 持有 LoaderContext。loader 链内分别保存 Content::String 或 Content::Buffer 内容以及 source map,保留每个 loader 接收到的内容类型。NormalModule 在 loader 链执行结束后构造最终 Source。
执行切换到 JavaScript 时,原生 JsLoaderContext class 在本次调用期间直接持有 Option<Box<LoaderContext>>。只有 JavaScript runner 读取对应 getter 时,才转换内容和 source map。JavaScript 封装在本次调用中缓存各 getter 的结果,包括空值;下次进入 JavaScript 时,同一封装会重置读取缓存。JavaScript 产生的输出优先于缓存的输入。
JavaScript 侧的 loader-context 封装读取一个普通的 JsLoaderContextState 对象(#[napi(object)]),通过 JavaScript getter/setter 访问其中的可变字段,并在返回前将同一个 state 对象整体传回。loader 元数据只在创建封装时读取一次,各 loader 的 data 和执行标记随 state 往返。复用的 loader 对象会在每次内置 loader 执行后读取最新 state。只有 JavaScript 产生输出时,state 才携带可选的 output。pitch 没有产生输出时,会保留原生内容和 source map。
每个原生 loader context 持有 rspack_napi 提供的带类型标记的 LifecycleGuard,由 guard 分配进程内唯一的自增 ID。ThreadLocalReference 在所属 JavaScript 线程按 ID 缓存对应的 JsLoaderContext,并隔离不同 N-API 环境。hook 使用同一个身份关联实例,无需移动 Box。JavaScript WeakMap 将 class 与封装和兼容 webpack 的 this 对象关联,因此它们不再随可变 state 对象往返。
原生 context 被消耗并转成 loader result 时,guard 析构,向已注册的 JavaScript 环境发送引用清理通知。通知会批量处理,并主动唤醒 JavaScript 线程,即使不再有 loader 调用也能清理。环境退出时也会释放缓存引用。移除引用使对象可以被垃圾回收;用户代码保留的 class 仍受原生访问窗口检查约束。
loader 执行前,Rust hook 借用 &mut LoaderContext。JavaScript loader hook 接收 JsLoaderHookContext 快照,只返回 state;Box 的所有权始终留在原生 runner。只有 JavaScript loader runner 会接收并归还 Box,同时返回 loader 的执行结果。hook 安装的属性和闭包通过共享的 JavaScript 封装跨内置及 JavaScript loader 保留,每次进入 JavaScript 时都会更新封装捕获的执行状态。
JavaScript 无论成功还是捕获错误,都会返回同一个 class 实例。返回值转换在 JavaScript 线程取走装箱的上下文,再将其 move 回原生 runner。实例中留下 None,原生访问随即失效,直到下次 loader runner 调用重新放入 Box;不需要共享所有权槽位,也不需要为每次访问加锁。这是内部桥接机制;loader 仍然使用兼容 webpack 的 this 上下文、字符串或 Buffer 内容、source-map 对象以及回调。
高效用法
只读取需要的资源
compilation.assets 看起来像普通 JavaScript 对象,但它的资源内容由 native compilation 按需提供。Object.keys(assets) 只获取资源名称;读取 assets[name] 时,Rspack 才会通过 binding 从 Rust 获取对应的 Source。
如果只需要部分资源,下面的写法效率较低:
Object.entries(assets) 会在执行循环前读取所有资源的 Source。即使循环最终只使用少量资源,
也会产生不必要的跨语言通信和内存开销。
应该先使用 Object.keys() 筛选资源名称,再读取需要的 Source:
如果确实需要读取所有资源,使用 Object.entries() 或 Object.values() 不会造成额外的无效读取。
在循环中只读取一次
对于需要从 Rust 获取的数据,应在一次遍历中读取并保存结果:
module.size() 需要通过 binding 从 Rust 获取数据。将 size 保存在局部变量中,可以避免后续逻辑重复调用 module.size()。

