夜雨聆风学习资料网

ARTICLE · 1229319

Rspack 源码解析 (二) —— 从 rspack build 到输出 dist,完整编译链路详解

Rspack 源码解析 (二) —— 从 rspack build 到输出 dist,完整编译链路详解

本篇是Rspack源码解析系列第二篇,重点在从rspack build的全流程编译链路梳理和说明,感兴趣的替同学可以点赞、收藏、评论您的问题和收获,不甚感激🤣😄🤣

前言

在上一篇当中,我们从宏观角度解释了Rspack的三层世界:Node + NAPI + Rust,虽然本系列的源码解析重点会放在Rust相关代码的解析,俗话说磨刀不误砍柴工,在正式进入Rust世界之前,我们要先微观层面的梳理和理解一下从输入repack build 到输出dist,整个完整的链路都包含哪些步骤,虽然我们不纠结在非Rust相关的内容,但不能知道整个编译流程是如何运转的不然后面讲解Rust源码的时候会有很强的割裂感,好,废话不多说,开始今天的分享。

CLI侧:从命令行rspack build到 compiler.run()

当你敲下 rspack build 命令行的时候,实际是调用的packages/rspack-cli/bin/rspack.js, 先看为什么是这个文件,再看文件内容 首先我们打开rspack-cli文件夹下面的package.json,可以看到bin配置项: 用于注册命令行行可执行文件

"bin": {"rspack": "./bin/rspack.js"},

然后我们在找到packages/rspack-cli/bin/rspack.js该文件,其实里面的内容很简单:

// packages/rspack-cli/bin/rspack.jsimport { RspackCLI } from '../dist/index.js';async function runCLI() {// 初始化命令行对象const cli = new RspackCLI();// 执行run方法await cli.run(process.argv);}// 执行方法runCLI();

这样我们就知道了:当敲下rspack build 命令行的时候实际上是调用了RspackCLI中的run方法

下面我们在来找一下run方法,定义在packages/rspack-cli/src/cli.ts(跟上面rspack.js中引入的路径不一样是因为,我这边找的是源码地址,引入的是打包后的地址):

// packages/rspack-cli/src/cli.tsexport class RspackCLI {colors: RspackCLIColors;program: CAC;// 构造函数constructor() {// cac 是一个注册命令行程序的包 体积小 快 简洁// 注册一个名字叫做 rspack的命令行程序,后面会注册 build serve preview三个字命令const program = cac('rspack');// 给输出的命令行增加颜色标记,使用 picocolors,不展开讲,感兴趣自己百度。this.colors = this.createColors();this.program = program;        program.help();        program.version(RSPACK_CLI_VERSION);    }    ...async run(argv: string[]) {// 注册命令行await this.registerCommands();// 切换环境this.program.parse(argv);    }async registerCommands() {// 三个子命令:build serve previewconst builtinCommands = [new BuildCommand(),new ServeCommand(),new PreviewCommand(),        ];// 遍历执行apply方法,其实整体风格还是按照webpack的代码风格来编写的for (const command of builtinCommands) {await command.apply(this);        }    }}

通过上面的代码我们看到run方法的逻辑其实是很简单的,就是注册命令,切换环境,现在我们就基于build 命令来看下 命令行是如何执行的:

这样我们来看下 BuildCommand类,定义在packages/rspack-cli/src/commands/build.ts中,核心的代码也是比较简单的:

// packages/rspack-cli/src/commands/build.tsexport class BuildCommand implements RspackCommand {async apply(cli: RspackCLI): Promise<void> {// 定义命令名称和描述 以及命令行的别名const command = cli.program      .command('', 'run the Rspack build')      .alias('build')      .alias('bundle')      .alias('b');// 注册执行回调 当用户在终端输入匹配该命令的字符串时,这个回调才被触发。    command.action(async (options: BuildOptions) => {// 真正的执行构建的函数await runBuild(cli, options);    });  }}

好,我们跟随着代码的定义逻辑,找到了其实执行命令行最终执行的函数就是runBuild

那我们来看下这个函数中到底执行了什么东西,定义在同文件定义在packages/rspack-cli/src/commands/build.ts下面:

// packages/rspack-cli/src/commands/build.tsasync function runBuild(cli: RspackCLI, options: BuildOptions): Promise<void> {// 合并和整理用户的配置项 其中cli就是我们之前看过的 RspackCLIconst userOption = await cli.buildCompilerConfig(options, 'build');// 创建一个编译器对象const compiler = await cli.createCompiler(userOption, errorHandler);// 执行编译器的run方法,其实跟webpack一样的逻辑  compiler.run((error: Error | null, stats: Stats | MultiStats | undefined) => {    compiler.close((closeErr) => {if (closeErr) {        logger.error(closeErr);      }errorHandler(error, stats);    });  });}

上面的话,我们也看到了核心的runBuild方法的执行逻辑,其实核心的话就是三个方法

  • cli.buildCompilerConfig: 构建build参数,基于用户自定义以及默认的一些参数拼接成完整的参数
  • cli.createCompiler:初始化一个构建对象,用于后续的项目构建,其中也会包含着对底层Rust的桥接
  • compiler.run: 也是执行一个run方法,具体的内容我们在后面源码解析的时候会重点讲解。其实可以看下现在很多的基础库或者包都是这种代码执行方式,都会定义一个run方法用于相关逻辑的执行。

好的,至此的话我们的命令行rspack build到 compiler.run()步骤已经全部讲解完成了,是不是很轻松,下面我就继续开始第二个步骤的讲解内容

惰性初始化:JS与Rust的连接点

这一部分的话我们上一篇也已经讲过了,其实Rspack的一个特点就是惰性初始化,也就是当执行new Compiler()的时候,Rust的核心还没有执行,只有当调用compiler.run()方法的时候才是真正的执行。

好,接上文我们讲到了执行命令行之后最后实际调用的是cli.createCompiler、compiler.run(),在真正进入run方法之前 我们简单看一下cli.createCompiler做了哪些事情:

// packages/rspack-cli/src/cli.tsimport { rspack } from '@rspack/core';export class RspackCLI {// 其他的逻辑    ...// cli.createCompiler方法定义async createCompiler() {let compiler: MultiCompiler | Compiler | null;try {// 核心就是调用rspack 生成一个构建器对象          compiler = rspack(config, isWatch ? callback : undefined);        } catch (e) {// 异常处理            ...        }// 最后返回一个构建器对象return compiler;   }}

我们继续按照思路追踪的话,就到了我们这一步的桥接层,也就是rspack函数到底是用来干啥的?,其实上一章我们也已经简单讲过了,这里的话我们也过下具体的内容,方便大家理解:

我们可以看到 rspack() 是引用自@rspack/core包,上一篇已经讲过它对应的文件夹是哪个,这样我们一路追踪就可以找到这个方法定义的地方了,rspack()定义在packages/rspack/src/rspack.ts这个文件下面:

// packages/rspack/src/rspack.tsimport { Compiler } from './Compiler';function rspack(  options: MultiRspackOptions | RspackOptions,  callback?: Callback<Error, MultiStats> | Callback<Error, Stats>,) {// 处理配置项,校验、合并、格式化等等操作,这里我们不做详细介绍,感兴趣的同学自行查阅if (isMultiRspackOptions(options)) {for (const option of options) {validateRspackConfig(option);      }    } else {validateRspackConfig(options);    }// 定义了一个create函数,用于创建构造器对象const create = () => {        ...// 调用createCompiler方法生成构造器 其实就是执行 new Compiler()方法,然后把相关插件绑定在构造器对象上面const compiler = createCompiler(options);const watch = options.watch;const watchOptions = options.watchOptions || {};return { compiler, watch, watchOptions };     };// 调用create方法 生成构造器const { compiler, watch } = create();return compiler;}// 我们再来简单看下 createCompiler函数的定义function createCompiler(userOptions: RspackOptions): Compiler {   ...// 核心逻辑const compiler = new Compiler(options.context, options);// 执行plugin 相关逻辑if (Array.isArray(options.plugins)) {for (const plugin of options.plugins) {if (typeof plugin === 'function') {        (plugin as RspackPluginFunction).call(compiler, compiler);      } else if (plugin) {// 为什么定义插件的时候 要定义apply方法 这里告诉我们答案        plugin.apply(compiler);      }    }  }// 初始化各种钩子函数,方便后续进行调用  compiler.hooks.environment.call();  compiler.hooks.afterEnvironment.call();new RspackOptionsApply().process(compiler.options, compiler);  compiler.hooks.initialize.call();// 最后返回构造器对象return compiler;}

好的,上面的rspack()方法的代码其实也是比较简单的,它核心要做的事情就是 new Compiler(),实例化一个构造器对象,那我们来继续追踪Compiler对象,定义在packages/rspack/src/Compiler.ts文件中。

// packages/rspack/src/Compiler.ts(简化)export class Compiler {  #instance?: binding.JsCompiler// Rust 实例的引用run(callback) {this.hooks.beforeRun.callAsync(...)  // ① 构建前钩子this.hooks.run.callAsync(...)        // ② 运行钩子this.compile(onCompiled)             // ③ 核心编译  }compile(callback) {this.hooks.beforeCompile.callAsync(...)this.hooks.compile.call(...)this.#build(callback)               // 触发 Rust 初始化  }  #build(callback) {const instance = this.#getInstance(callback)    instance.build(callback)            // 调用 Rust 的 build  }  #getInstance() {// 第一次调用时加载 Native 绑定const binding = require('@rspack/binding')    // .node 二进制this.#instance = new binding.JsCompiler(       // 创建 Rust 实例      rawOptions,         // JS 配置 → Rust 配置      builtinPlugins,     // 内置插件列表      hookRegisters,      // JS 钩子注册表(跨语言回调)      fs                  // 文件系统实现    )  }}

其实这里的话我们已经可以看到整个Compiler类中已经开始调用底层Rust的一些方法了,用到的是require('@rspack/binding') 这个库中的一些对象和方法,其实这个里面就是我们上一篇讲到的NAPI 桥梁」的物理载体 ,

我们再继续追踪@rspack/binding定义在 crates/node_binding/ 目录下。这是一个特殊的包——它本身没有 JS 源码,而是通过 napi-rs 将 Rust 代码编译成 .node 原生二进制,然后通过 binding.js 加载。

crates/node_binding/├── package.json          ← "name": "@rspack/binding"├── binding.js            ← "main" 入口:根据平台加载对应的 .node 文件├── binding.d.ts          ← TypeScript 类型声明├── src/                  ← Rust 源码(#[napi] 宏暴露的 JsCompiler 等)├── scripts/build.js      ← 编译脚本(cargo build → .node)└── Cargo.toml

跨平台机制:package.json 的 optionalDependencies 列出了各平台的预编译包:

"optionalDependencies": {"@rspack/binding-darwin-arm64": "workspace:*",  // macOS ARM (M 系列芯片)"@rspack/binding-darwin-x64": "workspace:*",    // macOS Intel"@rspack/binding-linux-x64-gnu": "workspace:*",  // Linux x64"@rspack/binding-win32-x64-msvc": "workspace:*", // Windows x64  // ...}

每个平台包(如 npm/darwin-arm64/)里只有一个 .node 文件——M1 Mac 就是 rspack.darwin-arm64.node。

加载过程:

require('@rspack/binding')  → packages/rspack 通过 workspace:* 链接到 crates/node_binding  → binding.js 检测 process.platform + process.arch  → 加载对应的 .node 文件(macOS ARM → @rspack/binding-darwin-arm64)  → 返回 JsCompiler 等由 #[napi] 导出的 Rust 类

这就是第一篇讲过的「NAPI 桥梁」的物理载体——所有 #[napi] 标注的 Rust 代码最终编译成 .node 文件,供 JS 侧直接 require 使用。

那现在的话我们已经清楚了「NAPI 桥梁」是如何链接JS和Rust世界的,现在的话让我们把目光正式转向Rust世界,首先我们应该还记得再Compiler类中的#instance是如何生成的:是通过new binding.JsCompiler()来生成的,所以下面我们来把眼光正式的转向Rust世界:

在 Rust 侧,#[napi] 宏将 JsCompiler 暴露给 JS:

// crates/rspack_binding_api/src/lib.rs(简化)#[napi(custom_finalize)]struct JsCompiler {  compiler: ManuallyDrop<Compiler>,  // 持有真正的 Rust Compiler}#[napi]impl JsCompiler {#[napi(constructor)]pub fn new(env: Env, options: RawOptions, ...) -> Result<Self> {let compiler_options: rspack_core::CompilerOptions = options.try_into()?;let rspack = rspack_core::Compiler::new(compiler_options, ...);Ok(Self { compiler: ManuallyDrop::new(Compiler::from(rspack)) })  }}

编译之心:Rust Compiler 的 build() 方法

让我们继续回忆一下再JS世界中实际上调用的是Compiler类中的#instance对象的build方法,当 JS 调用 JsCompiler.build() 时,一路转发到 crates/rspack_core/src/compiler/mod.rs:

// crates/rspack_core/src/compiler/mod.rs(简化)pub async fn run(&mut self) -> Result<()> {self.build().await?;Ok(())}pub async fn build(&mut self) -> Result<()> {match within_compiler_context(self.compiler_context.clone(), self.build_inner()).await {Ok(_) => {self.plugin_driver.compiler_hooks.done.call(&self.compilation).await?;Ok(())        }Err(e) => {self.plugin_driver.compiler_hooks.failed.call(&self.compilation).await?;Err(e)        }    }}async fn build_inner(&mut self) -> Result<()> {// 1. 清理过期的 resolver 缓存// 2. 创建全新的 Compilation 实例(每次 build 都是新的!)fast_set(&mut self.compilation, Compilation::new(self.id, self.options.clone(),        plugin_driver.clone(), buildtime_plugin_driver.clone(),        resolver_factory.clone(), loader_resolver_factory.clone(),        ...    ));// 3. 尝试从持久化缓存恢复(热启动加速)self.cache.before_compile(&mut self.compilation).await;// 4. 核心编译!self.compile().await?;// 5. 输出产物self.compile_done().await?;// 6. 写入持久化缓存self.cache.after_compile(&self.compilation).await;Ok(())}

关键点:

  • 每次 build() 创建一个全新的 Compilation:这意味着一次构建就是一个 Compilation 生命周期,多次热更新(HMR)会产生多个 Compilation
  • compile() 负责构建逻辑,compile_done() 负责输出文件
  • 持久化缓存包裹在 compile 前后:先尝试回读,执行完毕后写回

后面的话就是完全进入到了Rust世界中了,不知道各位有没有准备好... 哈哈哈 当然我也没准备好,所以后面的步骤的话 只是浅尝辄止,后面我会对详细的代码进行分析和解析,欢迎继续追更

22-Pass 流水线:编译的阶段划分

compile() 方法的核心只有一件事:

// crates/rspack_core/src/compiler/mod.rs(简化)async fn compile(&mut self) -> Result<()> {let params = self.new_compilation_params();  // 创建 NormalModuleFactory + ContextModuleFactory// 触发 this_compilation 钩子 → JS 侧创建 Compilation 包装对象self.plugin_driver.compiler_hooks.this_compilation.call(...).await?;// 触发 compilation 钩子 → 插件可以在这里注册自己的逻辑self.plugin_driver.compiler_hooks.compilation.call(...).await?;// 启动 22 个 Passself.compilation.run_passes(params).await?;Ok(())}

run_passes() 定义在 crates/rspack_core/src/compilation/run_passes.rs,把 22 个 Pass 组成一个顺序执行的流水线:

// crates/rspack_core/src/compilation/run_passes.rs(简化)pub async fn run_passes(&mut self, ...) -> Result<()> {let passes: Vec<Box<dyn PassExt>> = vec![Box::new(BuildModuleGraphPhasePass),  // ① Make:构建模块依赖图Box::new(FinishModulesPhasePass),      // ② 模块收尾Box::new(SealPass),                    // ③ Seal 开始Box::new(OptimizeDependenciesPass),    // ④ 依赖优化Box::new(BuildChunkGraphPass),         // ⑤ 分包(Code Splitting)Box::new(OptimizeModulesPass),         // ⑥ 模块优化Box::new(OptimizeChunksPass),          // ⑦ Chunk 优化Box::new(OptimizeTreePass),            // ⑧ Tree ShakingBox::new(OptimizeChunkModulesPass),    // ⑨ Chunk 内模块优化Box::new(ModuleIdsPass),               // ⑩ 模块 ID 分配Box::new(ChunkIdsPass),                // ⑪ Chunk ID 分配Box::new(AssignRuntimeIdsPass),        // ⑫ Runtime ID 分配Box::new(OptimizeCodeGenerationPass),  // ⑬ 代码生成优化Box::new(CreateModuleHashesPass),      // ⑭ 模块 Hash 计算Box::new(CodeGenerationPass),          // ⑮ 代码生成Box::new(RuntimeRequirementsPass),     // ⑯ Runtime 需求处理Box::new(CreateHashPass),              // ⑰ 编译 Hash 计算Box::new(CreateModuleAssetsPass),      // ⑱ 模块资源创建Box::new(CreateChunkAssetsPass),       // ⑲ Chunk 资源渲染Box::new(ProcessAssetsPass),           // ⑳ 资源处理(压缩等)Box::new(AfterProcessAssetsPass),      // ㉑ 资源处理收尾Box::new(AfterSealPass),               // ㉒ Seal 结束    ];for pass in &passes {        pass.run(self, cache).await?;  // 每个 Pass 按序执行    }Ok(())}

为什么是 22 个 Pass? 这是对 webpack 编译阶段的显式建模。每个 Pass 都是一个独立单元,有 before_pass、run_pass、after_pass 回调,你可以把它理解为一个「迷你钩子」。这种设计的好处是:

  • 增量构建:某个 Pass 的结果被缓存后,下次热更新时可以跳过
  • 可观测性:每个 Pass 的耗时可以被独立统计
  • 可组合性:将来可以方便地插入新 Pass

Emit 阶段:写入磁盘

22 个 Pass 执行完毕后,控制权回到 Compiler::compile_done():

// crates/rspack_core/src/compiler/mod.rs(简化)async fn compile_done(&mut self) -> Result<()> {// 1. shouldEmit 钩子(插件可以阻止输出)if !self.plugin_driver.compiler_hooks.should_emit.call(...)? {return Ok(());    }self.emit_assets().await}async fn emit_assets(&mut self) -> Result<()> {// 2. 解析 output.path// 3. 清理输出目录(根据 output.clean 配置)run_clean_options();// 4. emit 钩子(最后一次修改产物的机会)self.plugin_driver.compiler_hooks.emit.call(&mut self.compilation);// 5. 逐个写入文件for (filename, asset) in self.compilation.assets() {// 增量:版本未变则跳过// immutable:hash 命名的文件已存在则跳过self.output_filesystem.write(target_path, asset.source.buffer());// assetEmitted 钩子self.plugin_driver.compiler_hooks.asset_emitted.call(...);    }// 6. afterEmit 钩子self.plugin_driver.compiler_hooks.after_emit.call(...);}

回到 JS 侧:

// 最终回调compiler.hooks.done.callAsync(stats, (err) => {callback(err, stats)  // stats:包含整个构建的统计信息})

此时,你的 dist/ 目录下就会出现打包好的 main.js、lazy_xxxx.js 等文件。

总结

本篇我们追踪了从 rspack build 到产物文件输出的完整链路:

rspack build  → CLI 加载配置、标准化、初始化 JS Compiler  → compiler.run() → 惰性初始化 Rust Binding  → Rust Compiler::build_inner()      → compile() → run_passes()(22 个 Pass 顺序执行)      → compile_done() → emit_assets() → 写入磁盘  → done hook → callback(null, stats)

写在最后

到这里的话其实我们已经进入到了Rust的世界当中了,上面最后的两个步骤我自己也是半AI半自己的理解进行的,本身本系列的目的就是为了找一个优秀的Rust项目学习对应的源码,所以我后面的重点会放在对Rust世界中详细的代码的解析中来,整个Rspack的结构以及整个项目的设计理念这种大的宏观的东西前两篇介绍的也已经够了,虽然自己啥也不懂。。哈哈哈哈,反正是最后,估计没多少人能坚持到最后,所以如果你看到最后的话辛苦您点赞、收藏和关注,因为我想升四级,哈哈哈 谢谢您嘞!!!🙇🙇🙇

相关学习资料