本篇是 Rspack 源码解析系列第三篇。前两篇我们已经从
rspack build走到了 Rust 侧的 22-Pass 编译流水线;这一篇开始进入第一个真正干活的 Pass:Build Module Graph。目标很明确:搞清楚一个入口文件是怎样被解析、构建,并最终长成一张依赖图的。
前言
上一篇最后留下了一个问题:run_passes() 的第一个 Pass 是 BuildModuleGraphPhasePass,那它究竟做了什么?
如果只从结果看,Rspack 读到了 src/index.ts,发现其中有:
ts
import { sum } from './math';
import('./lazy');
console.log(sum(1, 2));
最后构建出一张类似下面的图:
text
EntryDependency("./src/index.ts")
|
v
NormalModule(index.ts)
| |
| +-- import() --> NormalModule(lazy.ts)
v
NormalModule(math.ts)
不过,真实实现并不是一个简单的递归 build(index) -> build(math)。Rspack 需要处理 Loader、不同模块类型、插件钩子、循环依赖、错误诊断,以及 watch 模式下的增量重建。因此它把这个过程拆成一组任务:耗时的解析、读取和构建在后台执行;对共享模块图的修改集中回到主任务队列执行。
这也是这一篇最值得学习的 Rust 设计。
先建立四个概念
在跟代码之前,先把几个极容易混淆的概念分开。它们在 Rspack 中并不是同一个东西。
| 概念 | 可以把它理解成 | 典型例子 |
|---|---|---|
Dependency |
一次"我要引用谁"的声明 | import './math' |
Module |
这个请求最终对应的模块实体 | NormalModule(math.ts) |
ModuleGraphConnection |
一条已经解析完成的边 | index.ts --import--> math.ts |
ModuleGraph |
保存模块、依赖和边的总图 | 整个项目的依赖关系 |
注意:Dependency 不等于 Module。
同一个 math.ts 可以被多个文件导入,因此会有多个 Dependency,但通常只需要一个 Module。这也是后面 AddTask 要检查模块是否已经存在的原因。
此外,模块图还记录依赖的父级、模块的 issuer(引入者)、异步依赖块等信息。后面的 Tree Shaking、代码分割和生成 Chunk Graph 都要以它为输入,所以 Make 阶段并不是"读文件"这么简单,而是在构建一份供后续 Pass 消费的核心中间结果。
从 22 个 Pass 进入 Make
先回到上一篇结束的位置。Compiler::compile() 会调用 Compilation::run_passes():
rust
// crates/rspack_core/src/compilation/run_passes.rs
pub async fn run_passes(
&mut self,
_plugin_driver: SharedPluginDriver,
cache: &mut dyn Cache,
) -> Result<()> {
let passes: Vec<Box<dyn PassExt>> = vec![
Box::new(BuildModuleGraphPhasePass),
Box::new(FinishModulesPhasePass),
Box::new(SealPass),
// 后面还有优化、代码生成、产物生成等 Pass
];
for pass in &passes {
pass.run(self, cache).await?;
}
Ok(())
}
这里可以顺便观察一个很实用的 Rust 写法:Vec<Box<dyn PassExt>>。
PassExt是所有 Pass 共同实现的 trait;- 不同 Pass 的具体类型不同,不能直接放进同一个
Vec; Box<dyn PassExt>通过 trait object 把它们统一成"可以运行的 Pass";await?表示等待当前 Pass 完成,如果失败则立刻把Result中的错误向上传播。
PassExt::run() 并不直接执行业务逻辑,它先统一处理是否启用、日志计时和缓存钩子,再调用各 Pass 自己的实现:
rust
// crates/rspack_core/src/compilation/pass.rs(简化)
async fn run(&self, compilation: &mut Compilation, cache: &mut dyn Cache) -> Result<()> {
if !self.is_enabled(compilation) {
return Ok(());
}
self.before_pass(compilation, cache).await;
let result = self.run_pass_with_cache(compilation, cache).await;
if result.is_ok() {
self.after_pass(compilation, cache).await;
}
result
}
这是一种很值得借鉴的设计:把每个阶段共有的"框架逻辑"收敛在 trait 的默认方法中,让具体 Pass 只关心自己的工作。
Build Module Graph 不是一个步骤,而是四个子阶段
第一个 Pass 的实现位于 crates/rspack_core/src/compilation/build_module_graph/pass.rs:
rust
// crates/rspack_core/src/compilation/build_module_graph/pass.rs(简化)
async fn run_pass_with_cache(
&self,
compilation: &mut Compilation,
_cache: &mut dyn Cache,
) -> Result<()> {
let plugin_driver = compilation.plugin_driver.clone();
make_hook_pass(compilation, plugin_driver.clone()).await?;
build_module_graph_pass(compilation).await?;
finish_make_pass(compilation, plugin_driver.clone()).await?;
finish_module_graph_pass(compilation).await?;
Ok(())
}
因此完整顺序是:
text
make hook
-> build module graph
-> finish make hook
-> finish module graph
这里有一个细节很重要:make 并不等于"构建模块图"本身。
make hook:插件把入口依赖注册到Compilation;build module graph:由入口开始真正解析、构建并连接所有模块;finish make hook:插件还有最后一次补充 include 依赖的机会;finish module graph:再做一次整理,移除已经不可达的模块。
把插件扩展点和核心算法拆开,Rspack 才能在保持 webpack 兼容性的同时,让 Rust 内部的构图过程有清晰边界。
第一步:入口插件把入口变成 Dependency
入口不是 Compilation 天生就有的模块,而是 EntryPlugin 在 make hook 中添加进去的。
rust
// crates/rspack_plugin_entry/src/lib.rs(简化)
#[plugin_hook(CompilerCompilation for EntryPlugin)]
async fn compilation(
&self,
compilation: &mut Compilation,
params: &mut CompilationParams,
) -> Result<()> {
compilation.set_dependency_factory(
DependencyType::Entry,
params.normal_module_factory.clone(),
);
Ok(())
}
#[plugin_hook(CompilerMake for EntryPlugin)]
async fn make(&self, compilation: &mut Compilation) -> Result<()> {
compilation
.add_entry(self.inner.dependency.clone(), self.inner.options.clone())
.await
}
上面的代码完成了两件事:
- 为
DependencyType::Entry指定NormalModuleFactory。之后入口依赖需要被解析时,Rspack 就知道该找哪个工厂; - 在
make阶段调用add_entry(),把EntryDependency放入模块图和Compilation.entries。
EntryPlugin 中的依赖使用了 LazyLock<BoxDependency>:
rust
// crates/rspack_plugin_entry/src/lib.rs(简化)
dependency: LazyLock<BoxDependency, Box<dyn FnOnce() -> BoxDependency + Send>>,
它的作用不只是"延迟创建"。源码注释说明,这个入口依赖需要在多次构建时保持稳定,才能让增量构建判断它没有变化并复用已有结果。一个很小的类型选择,背后其实服务于 watch 模式的性能。
Compilation::add_entry() 先把依赖加入模块图,再按 entry name 写入 entries:
rust
// crates/rspack_core/src/compilation/mod.rs(简化)
pub async fn add_entry(&mut self, entry: BoxDependency, options: EntryOptions) -> Result<()> {
let entry_id = *entry.id();
let entry_name = options.name.clone();
self
.build_module_graph_artifact
.get_module_graph_mut()
.add_dependency(entry);
if let Some(name) = &entry_name {
self.entries.entry(name.to_owned()).or_default()
.dependencies.push(entry_id);
} else {
self.global_entry.dependencies.push(entry_id);
}
self.plugin_driver.compilation_hooks.add_entry.call(self, entry_name.as_deref()).await?;
Ok(())
}
此时入口只是一个还没解析的 DependencyId,而不是 NormalModule。真正从请求字符串找到文件并创建模块的工作,下一阶段才开始。
第二步:切除旧结果,找出需要重建的依赖
build_module_graph_pass() 会进入 build_module_graph():
rust
// crates/rspack_core/src/compilation/build_module_graph/mod.rs(简化)
pub async fn build_module_graph(
compilation: &Compilation,
mut artifact: BuildModuleGraphArtifact,
exports_info_artifact: ExportsInfoArtifact,
) -> Result<(BuildModuleGraphArtifact, ExportsInfoArtifact)> {
let mut params = Vec::new();
params.push(UpdateParam::BuildEntry(entry_dependency_ids));
params.push(UpdateParam::CheckNeedBuild);
if !compilation.modified_files.is_empty() {
params.push(UpdateParam::ModifiedFiles(compilation.modified_files.clone()));
}
if !compilation.removed_files.is_empty() {
params.push(UpdateParam::RemovedFiles(compilation.removed_files.clone()));
}
artifact.reset_temporary_data();
update_module_graph(compilation, artifact, exports_info_artifact, params).await
}
这里可以看出 Rspack 没有把"首次构建"和"增量构建"做成两套流程,而是把变化描述成 UpdateParam:
BuildEntry:确保入口被构建;CheckNeedBuild:处理不可缓存的模块;ModifiedFiles:处理受文件变更影响的模块和依赖;RemovedFiles:处理文件被删除后的失效关系。
接着 update_module_graph() 使用 Cutout 先从旧图中切出受影响区域,得到需要重新构建的 (DependencyId, Option<ModuleIdentifier>) 集合,然后交给 repair() 修复:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/mod.rs(简化)
pub async fn update_module_graph(...) -> Result<(BuildModuleGraphArtifact, ExportsInfoArtifact)> {
artifact.state = BuildModuleGraphArtifactState::Initialized;
let mut cutout = Cutout::default();
let build_dependencies = cutout.cutout_artifact(compilation, &mut artifact, params);
let revoked_modules = artifact.revoked_modules().copied().collect();
compilation.plugin_driver.compilation_hooks.revoked_modules
.call(compilation, &revoked_modules).await?;
let (mut artifact, exports_info_artifact) = repair(
compilation,
artifact,
exports_info_artifact,
build_dependencies,
).await?;
cutout.fix_artifact(&mut artifact);
Ok((artifact, exports_info_artifact))
}
可以把它理解成编辑一张已经存在的图:先擦掉受影响的枝干,再补上新枝干,最后修正 issuer、失败模块的 build meta 等关联信息。这样做是 Rspack 支持快速重编译的基础,也解释了为什么这里的模块图状态不直接全部挂在 Compilation 普通字段里,而是放进 BuildModuleGraphArtifact 这个阶段产物中。
首次 build 时旧图为空,效果就等同于"从入口开始新建整张图"。
第三步:任务循环驱动构图
repair() 先按照父模块对待构建依赖分组:入口依赖没有父模块,普通依赖则有来源模块。无论从哪里开始,最终都会生成 FactorizeTask:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/mod.rs(简化)
let init_tasks = grouped_deps.into_iter().flat_map(|(parent, dependencies)| {
if let Some(module_identifier) = parent {
vec![Box::new(ProcessDependenciesTask {
original_module_identifier: module_identifier,
dependencies,
from_unlazy: false,
}) as Box<dyn Task<TaskContext>>]
} else {
dependencies.into_iter().map(|dep_id| {
Box::new(FactorizeTask { dependencies: vec![dependency], .. })
as Box<dyn Task<TaskContext>>
}).collect()
}
}).collect();
let mut ctx = TaskContext::new(compilation, artifact, exports_info_artifact);
run_task_loop(&mut ctx, init_tasks).await?;
TaskContext 很值得注意。它将构图时需要的数据集中起来:编译配置、resolver、文件系统、插件驱动、模块工厂和当前 artifact。每一个 task 不需要持有整个 Compilation 的可变引用,这就避免了异步并发时最常见的借用冲突。
任务循环在 crates/rspack_core/src/utils/task_loop.rs:
rust
// crates/rspack_core/src/utils/task_loop.rs(简化)
pub enum TaskType {
Main,
Background,
}
pub trait Task<Ctx>: Debug + Send + Any + AsAny {
fn get_task_type(&self) -> TaskType;
async fn main_run(self: Box<Self>, context: &mut Ctx) -> TaskResult<Ctx>;
async fn background_run(self: Box<Self>) -> TaskResult<Ctx>;
}
整个图构建过程可以记为下面这个循环:
text
FactorizeTask (Background)
-> FactorizeResultTask (Main)
-> AddTask (Main)
-> BuildTask (Background)
-> BuildResultTask (Main)
-> ProcessDependenciesTask (Main)
-> 为子依赖创建更多 FactorizeTask
这里的职责分配很清楚:
- Background task:模块解析、Loader 执行、读取文件、语法分析等耗时操作;
- Main task :更新
ModuleGraph、去重模块、连接依赖边等共享状态操作。
这并不表示所有代码只运行在一个"主线程"。这里的 Main 更准确地说是任务循环中串行处理的队列;Background task 会通过 Tokio 任务运行,并把结果经 channel 发回。串行写图降低了并发修改复杂图结构的成本,同时保留了解析和构建的并行度。
第四步:Factorize,把请求变成模块
FactorizeTask 是"模块工厂化"的阶段。它从 Dependency 提取 request、context、issuer 和 resolve options,然后调用对应的 ModuleFactory:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/factorize.rs(简化)
async fn background_run(mut self: Box<Self>) -> TaskResult<TaskContext> {
let dependency = &self.dependencies[0];
let request = dependency
.as_module_dependency()
.map(|d| d.request().to_string())
.unwrap_or_default();
let mut create_data = ModuleFactoryCreateData {
context,
request,
dependencies: self.dependencies,
issuer: self.issuer,
issuer_identifier: self.original_module_identifier,
resolve_options: self.resolve_options,
// 还会收集 file/missing/context dependencies 和 diagnostics
..
};
let factory_result = self.module_factory.create(&mut create_data).await?;
Ok(vec![Box::new(FactorizeResultTask { factory_result, .. })])
}
factorize 这个词可以理解成"把抽象请求具体化":例如将 ./math 配合 issuer src/index.ts、resolve.extensions、alias 等配置,解析成具体资源,并创建对应类型的模块。
对于一般的 JavaScript/TypeScript 文件,使用的是 NormalModuleFactory。它的 create() 先触发 before_resolve,再执行 factorize,最后触发 after_factorize:
rust
// crates/rspack_core/src/normal_module_factory.rs(简化)
async fn create(&self, data: &mut ModuleFactoryCreateData) -> Result<ModuleFactoryResult> {
if let Some(result) = self.before_resolve(data).await? {
return Ok(result);
}
let mut result = self.factorize(data).await?;
if let Some(module) = &mut result.module {
self.plugin_driver.normal_module_factory_hooks
.after_factorize.call(data, module).await?;
}
Ok(result)
}
这层抽象的意义是:调用方只需要处理"某类依赖要一个模块",不用把 Normal Module、Context Module、External Module 等分支写死在构图循环中。不同依赖类型通过 dependency_factories 找到各自的 ModuleFactory。
如果解析失败且没有开启 bail,Rspack 不会立刻让整个构建崩掉。FactorizeTask 会把错误转换为 Diagnostic,继续完成能完成的构图,最终再统一报告。这也是打包工具比普通脚本更重视错误收集而非"第一个错误立即退出"的原因。
第五步:AddTask,把 Module 和边写入图中
后台解析完成后,FactorizeResultTask 产生 AddTask。它在串行主队列中处理两个很关键的事情:模块去重和依赖连边。
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/add.rs(简化)
async fn main_run(self: Box<Self>, context: &mut TaskContext) -> TaskResult<TaskContext> {
let module_identifier = self.module.identifier();
let module_graph = &mut context.artifact.module_graph;
if module_graph
.module_graph_module_by_identifier(&module_identifier)
.is_some()
{
set_resolved_module(
module_graph,
self.original_module_identifier,
self.dependencies,
module_identifier,
)?;
return Ok(vec![]);
}
module_graph.add_module_graph_module(*self.module_graph_module);
context.exports_info_artifact.new_exports_info(module_identifier);
set_resolved_module(
module_graph,
self.original_module_identifier,
self.dependencies,
module_identifier,
)?;
Ok(vec![Box::new(BuildTask { module: self.module, .. })])
}
这里先检查 ModuleIdentifier 是否已经存在。
例如 index.ts 和 other.ts 都导入 ./math 时,两个依赖都会分别经历 factorize,但写图时发现 math.ts 的模块已经存在,Rspack 只需要补上新的 connection,不应重复构建一份 math.ts。
set_resolved_module() 是这张图真正"长边"的地方:
rust
fn set_resolved_module(
module_graph: &mut ModuleGraph,
original_module_identifier: Option<ModuleIdentifier>,
dependencies: Vec<BoxDependency>,
module_identifier: ModuleIdentifier,
) -> Result<()> {
for dependency in dependencies {
module_graph.set_resolved_module(
original_module_identifier,
*dependency.id(),
module_identifier,
)?;
module_graph.add_dependency(dependency);
}
Ok(())
}
因此,一条边需要三个信息:来源模块 original_module_identifier、边本身的 DependencyId、目标模块 module_identifier。入口没有来源模块,所以它的 original_module_identifier 是 None。
第六步:BuildTask,Loader 和 Parser 在这里发生
新模块第一次加入图后才需要进入 BuildTask。这是一个 Background task:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs(简化)
async fn background_run(self: Box<Self>) -> TaskResult<TaskContext> {
let mut module = self.module;
self.plugin_driver.compilation_hooks.build_module
.call(self.compiler_id, self.compilation_id, &mut module).await?;
let result = module.build(
BuildContext {
compiler_options: self.compiler_options,
resolver_factory: self.resolver_factory,
plugin_driver: self.plugin_driver.clone(),
fs: self.fs,
// ...
},
None,
).await?;
Ok(vec![Box::new(BuildResultTask { build_result: Box::new(result), .. })])
}
对 NormalModule 来说,build() 中的核心顺序是:
text
before_loaders hook
-> run_loaders(...)
-> 生成 Source
-> Parser 分析源码
-> 得到 dependencies 与 AsyncDependenciesBlock
其中 Loader 先把源文件转成适合模块类型的内容,例如 TypeScript 可能经 Loader 处理为 JavaScript;随后 parser 才从处理后的源码中找出 import、require、import() 等依赖。BuildResult 正是把这个结果带回主队列的载体:
rust
// crates/rspack_core/src/module.rs
pub struct BuildResult {
pub module: BoxModule,
pub dependencies: Vec<BoxDependency>,
pub blocks: Vec<Box<AsyncDependenciesBlock>>,
pub optimization_bailouts: Vec<String>,
}
这里的 BoxModule 和前面的 BoxDependency 同理,都是为了在运行时统一承载不同具体类型的 trait object。Rust 的静态类型并没有丢失,只是封装在 Box<dyn Module> 后面,通过 trait 方法统一调用。
第七步:收集子依赖,再把循环推下去
BuildResultTask 在主队列中接收构建结果,记录文件依赖、错误和优化信息,然后把模块解析出的依赖写回 ModuleGraph:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs(简化)
for dependency in build_result.dependencies {
let dependency_id = *dependency.id();
module.add_dependency_id(dependency_id);
module_graph.set_parents(
dependency_id,
DependencyParents {
module: module.identifier(),
block: None,
index_in_block,
},
);
module_graph.add_dependency(dependency);
}
module_graph.add_module(module);
Ok(vec![Box::new(ProcessDependenciesTask {
dependencies: dependency_ids,
original_module_identifier: module_identifier,
from_unlazy: false,
})])
AsyncDependenciesBlock 也会在此处被遍历和登记。它代表动态导入等异步边,后续 BuildChunkGraphPass 会根据这些 block 把同步模块和异步模块组织成不同的 Chunk。我们先记住:Make 阶段负责识别并记录异步边,真正分 chunk 是后面的事。
接下来是 ProcessDependenciesTask。它不会自己解析文件,而是按 resource_identifier 将等价依赖合并,然后为每一组创建新的 FactorizeTask:
rust
// crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/process_dependencies.rs(简化)
async fn main_run(self: Box<Self>, context: &mut TaskContext) -> TaskResult<TaskContext> {
let mut sorted_dependencies = HashMap::default();
for dependency_id in dependencies {
let dependency = module_graph.dependency_by_id(&dependency_id);
let key = dependency.resource_identifier();
sorted_dependencies.entry(key).or_default().push(dependency.clone());
}
for dependencies in sorted_dependencies.into_values() {
tasks.push(Box::new(FactorizeTask {
original_module_identifier: Some(module.identifier()),
dependencies,
module_factory,
// ...
}));
}
Ok(tasks)
}
至此又回到了 Factorize。只要新模块还能解析出新的依赖,任务循环就会持续产生下一批任务;直到没有任务可执行,整张从入口可达的模块图才构建完成。
用我们的开头示例把链路连起来,就是:
text
EntryPlugin
-> EntryDependency("./src/index.ts")
-> FactorizeTask
-> AddTask(index.ts)
-> BuildTask(index.ts): Loader + Parser
-> BuildResultTask: 收集 ./math 与 ./lazy
-> ProcessDependenciesTask
-> FactorizeTask(math.ts / lazy.ts)
-> ...直到没有新的 Dependency
最后的收尾:finish make 与清理不可达模块
模块图初步构建完毕后,finish_make_pass() 会调用插件的 finish_make hook。这个阶段允许插件通过 add_include() 补充 include 依赖;源码还专门用 in_finish_make: AtomicBool 限制该 API 只能在此时调用。
最后,finish_module_graph_pass() 会调用 finish_build_module_graph(),内部以 UpdateParam::BuildEntryAndClean 再运行一次更新:
rust
// crates/rspack_core/src/compilation/build_module_graph/mod.rs(简化)
pub async fn finish_build_module_graph(...) -> Result<...> {
update_module_graph(
compilation,
artifact,
exports_info_artifact,
vec![UpdateParam::BuildEntryAndClean(entry_dependency_ids)],
).await
}
和前面的 BuildEntry 相比,BuildEntryAndClean 多了清理动作:只保留从当前入口仍然可达的模块,移除因入口变更或依赖更新而失去连接的旧模块。至此,Make 阶段才真正关闭,后续 Pass 可以把模块图当作稳定输入使用。
这一篇应该带走什么
到这里,我们不需要记住每个文件名,但应该建立起下面这条主线:
text
配置 entry
-> EntryPlugin 在 make hook 中添加 EntryDependency
-> Cutout 根据入口和变更文件确定重建范围
-> Task Loop 交替执行:
后台 Factorize / Build
主队列 Add / ProcessDependencies / 写 ModuleGraph
-> finish_make 允许插件补充依赖
-> BuildEntryAndClean 清理不可达模块
-> 得到稳定的 ModuleGraph
从 Rust 学习角度看,这里至少有四个值得反复体会的点:
- trait object 管理异构任务和异构模块 :
Box<dyn Task<_>>、Box<dyn Module>让运行时可扩展,具体行为仍由 trait 约束; - 所有权换并发 :后台任务不直接持有
&mut ModuleGraph,只产出结果;主任务串行合并结果,因此不用为整张图套一层粗粒度锁; - 阶段 Artifact :
BuildModuleGraphArtifact把本阶段的可变状态聚合起来,既适合缓存,也让后续 Pass 的输入输出更明确; - 更新而非重建 :
Cutout + repair将首次构建和增量重建统一在同一套算法中。
写在最后
现在我们已经知道 Rspack 是怎样从入口构建 Module Graph 的了。下一步自然就是:有了模块图之后,Rspack 怎么根据同步依赖、动态 import()、入口配置和 splitChunks 规则,把模块组织成 Chunk Graph?
下一篇我会继续沿着 BuildChunkGraphPass 往下读,重点看 Chunk、ChunkGroup、Entrypoint 这些 Rust 数据结构是如何协作的,以及 import() 为什么会带来一个异步 Chunk。