本篇是 Rspack 源码解析系列第四篇。上一篇我们已经得到了一张稳定的
ModuleGraph:它回答的是"模块依赖谁"。但浏览器并不认识 ModuleGraph,它最终要加载的是main.js、src_lazy_ts.js这样的文件。本篇就来追踪 Rspack 怎样把模块依赖图转换为 Chunk、ChunkGroup 和 ChunkGraph,并回答一个最常见的问题:import()为什么会生成异步 Chunk?
前言
先复用上一篇的例子:
ts
// src/index.ts
import { sum } from './math';
import('./lazy');
console.log(sum(1, 2));
在 Build Module Graph 结束后,Rspack 已经知道:
text
index.ts
|-- import ------> math.ts
|
+-- import() ----> lazy.ts
但是此时还没有 main Chunk,也没有一个专门装 lazy.ts 的异步 Chunk。ModuleGraph 保存的是"模块之间的语义依赖";代码分割阶段需要进一步决定"模块该进入哪个加载单元,以及这些加载单元之间如何加载"。
构图后的结果可以大致看成:
text
Entrypoint(main)
|
v
Chunk(main)
index.ts + math.ts
|
| 动态加载关系
v
ChunkGroup(lazy)
|
v
Chunk(lazy)
lazy.ts
这一步的核心不是"按文件切分",而是根据入口、同步依赖、异步依赖块、运行时条件和父 Chunk 已经拥有的模块,计算一张新的图。
先区分五个概念
Chunk 相关的概念很多,先建立一张表。
| 概念 | 可以理解成 | 例子 |
|---|---|---|
ModuleGraph |
源码模块之间的依赖图 | index.ts -> math.ts |
Chunk |
需要一起生成/加载的一组模块 | main.js 中的模块集合 |
ChunkGroup |
一组 Chunk 的加载关系和运行时元数据 | entrypoint 或动态导入形成的组 |
Entrypoint |
特殊的初始 ChunkGroup | 配置中的 main entry |
ChunkGraph |
记录 Module、Chunk、异步 Block 与 ChunkGroup 的关联 | math.ts 属于 main Chunk |
这里最容易犯的错误是把 Chunk 和 ChunkGroup 当作同一个对象。
Chunk主要关心"里面装了哪些模块";ChunkGroup主要关心"这些 Chunk 的父子加载关系、入口、运行时和来源";- 一个 ChunkGroup 可以包含一个或多个 Chunk;
import()的AsyncDependenciesBlock会关联到一个异步 ChunkGroup,而不是直接只连到某个 Chunk。
BuildChunkGraphPass 在整个流水线中的位置
当前的 Pass 顺序位于 crates/rspack_core/src/compilation/run_passes.rs:
text
BuildModuleGraphPhasePass
-> FinishModulesPhasePass
-> SealPass
-> OptimizeDependenciesPass
-> BuildChunkGraphPass
-> OptimizeModulesPass
-> OptimizeChunksPass
-> ...
-> CodeGenerationPass
上一篇结束时的 ModuleGraph 已经完成;在 Chunk Graph 之前还有三个很短但语义重要的阶段:
FinishModulesPhasePass:模块图结构冻结,插件补全 exports 和异步模块等分析结果;SealPass:触发Compilation.hooks.seal,让插件在代码分割前做最后准备;OptimizeDependenciesPass:先根据导出、引用和副作用信息优化依赖连接。
因此 BuildChunkGraphPass 处理的不是最原始的 ModuleGraph,而是已经具备"这条依赖在某个 runtime 下是否真的生效"信息的模块图。
Pass 入口很直接:
rust
// crates/rspack_core/src/compilation/build_chunk_graph/pass.rs(简化)
async fn run_pass(&self, compilation: &mut Compilation) -> Result<()> {
compilation.module_graph_cache_artifact.freeze();
use_code_splitting_cache(compilation, |compilation| async {
build_chunk_graph(compilation)?;
compilation
.build_chunk_graph_artifact
.chunk_graph
.generate_dot(compilation, "after-code-splitting")
.await;
Ok(compilation)
})
.await?;
Ok(())
}
这里有两个值得注意的点。
第一,module_graph_cache_artifact.freeze()。后续算法会反复计算依赖连接的 active state,冻结缓存意味着这段时间把 ModuleGraph 作为稳定输入使用,避免分析过程中发生不一致。
第二,use_code_splitting_cache(...)。代码分割属于适合增量缓存的阶段:如果模块和异步边没有变化,就没有必要每次都重新遍历全部模块。当前 build_chunk_graph() 内部仍将启发式增量更新临时关闭,但 Pass 边界和缓存接口已经预留好。
build_chunk_graph:一次分 Chunk 的总调度
真正入口位于 crates/rspack_core/src/compilation/build_chunk_graph/mod.rs:
rust
// crates/rspack_core/src/compilation/build_chunk_graph/mod.rs(简化)
pub fn build_chunk_graph(compilation: &mut Compilation) -> Result<()> {
let mut splitter = CodeSplitter::default();
let all_modules = compilation
.get_module_graph()
.modules_keys()
.copied()
.collect::<Vec<_>>();
splitter.prepare(&all_modules, compilation)?;
splitter.update_with_compilation(compilation)?;
let inputs = splitter.prepare_input_entrypoints_and_modules(&all_modules, compilation)?;
splitter.prepare_entries(inputs, compilation)?;
splitter.split(compilation)?;
splitter.remove_orphan(compilation)?;
for module_identifier in all_modules {
compilation
.build_chunk_graph_artifact
.chunk_graph
.add_module(module_identifier);
}
compilation.build_chunk_graph_artifact.code_splitter = splitter;
Ok(())
}
把它翻译成流程图:
text
收集 ModuleGraph 中的所有 Module
-> prepare:预处理连接与异步 block
-> prepare_input_entrypoints_and_modules:创建入口 Chunk / Entrypoint
-> prepare_entries:把入口模块压入遍历队列
-> split:遍历同步依赖,遇到异步 block 时创建子 ChunkGroup
-> remove_orphan:清理没有被任何入口使用的 ChunkGroup
-> 得到 ChunkGraph
和上一篇的构图循环类似,这里同样不是"遇到一个文件就生成一个 Chunk",而是一个带状态的队列遍历。不同的是,上一次队列处理的是 Dependency -> Module;这一次队列处理的是 Module -> Chunk 和 AsyncDependenciesBlock -> ChunkGroup。
第一步:prepare,把 ModuleGraph 预处理成可遍历的数据
CodeSplitter::prepare() 的职责是把频繁访问的数据整理成索引:
rust
// crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs(简化)
pub fn prepare(
&mut self,
all_modules: &Vec<ModuleIdentifier>,
compilation: &Compilation,
) -> Result<()> {
self.prepared_connection_map = all_modules
.par_iter()
.map(|module| {
// 收集模块的 module/context dependencies
// 过滤 weak dependencies
// 依 source_order 排序
// 按 (所在 block, 目标 module) 分组
})
.collect();
self.prepared_blocks_map = all_modules
.par_iter()
.map(|module| {
// 遍历 module 自身及其 AsyncDependenciesBlock
// 得到 block -> 子 async blocks 的映射
})
.reduce(...);
Ok(())
}
为什么要先做一层预处理?因为 split() 的遍历过程中会多次问两个问题:
- 某个模块/依赖块有哪些有效的下游模块?
- 某个模块/依赖块下面有哪些异步依赖块?
如果每次都从 ModuleGraph 重新扫描依赖,会让核心循环混杂大量查表和过滤逻辑。预处理后,遍历阶段只处理"下一步要把谁放进哪个 Chunk"。
其中有一个细节:weak 依赖会被过滤。弱依赖不会因为当前模块引用它就强制把目标模块打进该 Chunk,这与普通同步 import 的语义不同。
第二步:入口先变成 Entrypoint、Chunk 和 ChunkGroup
prepare_input_entrypoints_and_modules() 会遍历 Compilation.entries,再调用 prepare_entry_input()。下面是它最关键的部分:
rust
// code_splitter.rs(简化)
let (chunk_ukey, _) = Compilation::add_named_chunk(
name.to_string(),
&mut compilation.build_chunk_graph_artifact.chunk_by_ukey,
&mut compilation.build_chunk_graph_artifact.named_chunks,
);
compilation
.build_chunk_graph_artifact
.chunk_graph
.add_chunk(chunk_ukey);
let mut entrypoint = ChunkGroup::new(ChunkGroupKind::new_entrypoint(
true,
Box::new(options.clone()),
));
entrypoint.set_entrypoint_chunk(chunk.ukey());
entrypoint.connect_chunk(chunk);
compilation
.build_chunk_graph_artifact
.entrypoints
.insert(name.to_string(), entrypoint.ukey);
对 entry: { main: './src/index.ts' } 来说,这里至少创建了:
text
Chunk(main)
ChunkGroup(Entrypoint(main))
ChunkGraph 中的 Chunk 记录
entrypoints["main"] -> Entrypoint(main)
然后入口依赖所解析出的模块会被连接到这个 Chunk:
rust
chunk_graph.connect_chunk_and_entry_module(
chunk.ukey(),
module_identifier,
entrypoint.ukey,
);
这和普通 connect_chunk_and_module() 不同。入口模块除了"属于该 Chunk",还要额外记录"它是该 ChunkGroup 的入口模块",以便后续生成启动代码、runtime 和 stats。
此阶段还会处理 entry 的 runtime、dependOn、include、filename、chunkLoading 和 asyncChunks 等选项。例如 dependOn 会建立 EntryPoint 之间的父子关系,避免重复加载共享 runtime。
第三步:队列遍历同步模块
入口准备完毕后,prepare_entries() 会把入口模块放入 queue:
rust
// code_splitter.rs(简化)
self.queue.push(QueueAction::AddAndEnterModule(AddAndEnterModule {
chunk,
chunk_group_info: cgi,
module,
}));
split() 用循环持续消费队列:
rust
// code_splitter.rs(简化)
while !self.queue.is_empty()
|| !self.queue_connect.is_empty()
|| !self.queue_delayed.is_empty()
|| !self.chunk_groups_for_combining.is_empty()
{
self.process_queue(compilation);
self.process_chunk_groups_for_combining(compilation);
self.process_connect_queue(compilation);
self.process_chunk_groups_for_merging(compilation);
self.process_outdated_chunk_group_info(compilation);
if self.queue.is_empty() {
self.queue = std::mem::take(&mut self.queue_delayed);
self.queue.reverse();
}
}
它使用显式队列而非递归 DFS。原因和上一篇任务循环一致:深依赖树不应让 Rust 调用栈无限增长;同时异步 ChunkGroup 的可用模块集合会发生变化,需要反复把被跳过的模块重新入队。
最重要的几个 QueueAction 是:
| Action | 做什么 |
|---|---|
AddAndEnterModule |
把普通模块加入当前 Chunk,然后继续遍历其依赖 |
AddAndEnterEntryModule |
把模块作为某个 ChunkGroup 的入口模块加入 Chunk |
ProcessBlock |
遍历一个模块或异步依赖块中的下游依赖 |
ProcessEntryBlock |
遍历异步入口块 |
LeaveModule |
记录后序索引,用于确定稳定的模块顺序 |
add_and_enter_module() 的核心是双向连边:
rust
// code_splitter.rs(简化)
compilation
.build_chunk_graph_artifact
.chunk_graph
.connect_chunk_and_module(item.chunk, item.module);
self.enter_module(...);
在 ChunkGraph 中,这个调用会同时更新:
text
ChunkGraphModule(module).chunks += chunk
ChunkGraphChunk(chunk).modules += module
也就是说 ChunkGraph 不是只从 Chunk 查模块,也支持从 Module 反查它被打进了哪些 Chunk。
第四步:同步依赖为什么通常留在同一个 Chunk
enter_module() 最后会调用 process_block()。该函数从预处理索引中取得当前模块的有效依赖:
rust
// code_splitter.rs(简化)
let block_modules = self.get_block_modules(
item.block,
Some(chunk_group_info.runtime.clone()),
compilation,
);
for (module, active_state, connections) in block_modules.iter().rev() {
if active_state.is_true() {
self.queue.push(QueueAction::AddAndEnterModule(...));
} else {
self.queue.push(QueueAction::ProcessBlock(...));
}
}
如果 index.ts 静态导入 math.ts,这条 connection 在当前 runtime 下是 active,math.ts 就被推入同一个 main Chunk。
但还有一个避免重复的判断:
rust
if cgi.min_available_modules.bit(*module_ordinal) {
cgi.skipped_items.insert(item.module);
return;
}
min_available_modules 可以理解为"这个 ChunkGroup 的所有父路径都已经提供的模块集合"。如果 math.ts 已经保证被父 Chunk 加载,子 Chunk 就不再复制它。为了高效计算集合运算,Rspack 为每个模块分配 ordinal,再使用 BigUint 位图保存模块集合。
这解释了代码分割中一个关键目标:异步 Chunk 只应包含它需要、但父级尚未提供的模块。
第五步:遇到 import(),创建异步 ChunkGroup
上一篇已经提到:Parser 遇到 import('./lazy') 时,会产生 AsyncDependenciesBlock。代码分割阶段由 process_block() 找到这个 block,然后调用 make_chunk_group():
rust
// code_splitter.rs(简化)
for block in blocks {
self.make_chunk_group(
block,
item.module,
item.chunk_group_info,
item.chunk,
compilation,
);
}
make_chunk_group() 的分支可以用下面的逻辑概括:
text
这个 block 已经有 ChunkGroup?
是 -> 复用已有 ChunkGroup
否 -> 当前是否允许 async chunks?
否 -> 退化为继续在当前 Chunk 遍历
是 -> 新建 Chunk + ChunkGroup,并建立父子关系
创建普通异步 ChunkGroup 的关键代码是:
rust
// code_splitter.rs(简化)
let chunk_ukey = Compilation::add_chunk(
&mut compilation.build_chunk_graph_artifact.chunk_by_ukey,
);
compilation
.build_chunk_graph_artifact
.chunk_graph
.add_chunk(chunk_ukey);
let mut chunk_group = add_chunk_in_group(
block.get_group_options(),
module_id,
block.loc(),
block.request().clone(),
);
chunk_group.connect_chunk(chunk);
compilation
.build_chunk_graph_artifact
.chunk_graph
.connect_block_and_chunk_group(block_id, chunk_group.ukey);
这三层关联很重要:
text
AsyncDependenciesBlock(import('./lazy'))
|
v
ChunkGroup(lazy)
|
v
Chunk(lazy)
|
v
lazy.ts 及其同步依赖
如果用户通过 magic comment 或配置给异步块指定了 name,Rspack 会调用 add_named_chunk() 复用或创建同名 Chunk;没有名字则创建匿名 Chunk。多个异步块使用同一个名字时,可能进入同一个 ChunkGroup,这也是 webpackChunkName 能合并异步模块的原因。
父子 ChunkGroup 与可用模块集合
创建异步 ChunkGroup 后,Rspack 不会立刻假设"子 Chunk 一定要完整遍历所有模块"。它先在 queue_connect 中记录父子关系,再处理可用模块集合:
rust
// code_splitter.rs(简化)
target.add_parent(chunk_group_ukey);
target_cgi.available_modules_to_be_merged
.push(resulting_available_modules.clone());
self.chunk_groups_for_merging.insert((target_ukey, process_block));
一个子 ChunkGroup 如果有多个父级,真正可认为"已经可用"的模块,是所有父路径都提供的模块交集。源码中 process_chunk_groups_for_merging() 会把多个集合做位运算交集:
rust
cgi.min_available_modules = Arc::new(
cgi.min_available_modules.as_ref() & modules_to_be_merged.as_ref()
);
这比"只看第一个父 Chunk"更严谨。因为一个命名异步 Chunk 可能从多个入口或多个动态导入路径到达,只有在每一条路径都已加载的模块,才可以安全地从该异步 Chunk 中剔除。
用示例走一遍
还是从:
ts
import { sum } from './math';
import('./lazy');
开始。
1. 创建入口
text
entry main
-> 创建 Chunk(main)
-> 创建 Entrypoint(main)
-> index.ts 作为 entry module 连入 main
2. 处理同步 import
text
遍历 index.ts 的普通依赖
-> math.ts connection active
-> math.ts 连入 Chunk(main)
3. 处理动态 import
text
遍历 index.ts 的 AsyncDependenciesBlock
-> 创建 ChunkGroup(lazy)
-> 创建 Chunk(lazy)
-> block -> ChunkGroup(lazy)
-> Entrypoint(main) -> ChunkGroup(lazy)
-> lazy.ts 连入 Chunk(lazy)
最后结果可理解为:
text
ChunkGraph
main Chunk: index.ts, math.ts
lazy Chunk: lazy.ts
ChunkGroup Graph
Entrypoint(main) --> async ChunkGroup(lazy)
注意:这一步还没有处理 splitChunks 规则把公共模块再抽成额外 Chunk。后面的 OptimizeChunksPass、OptimizeTreePass 等阶段才会继续优化当前 ChunkGraph。
这一篇应该带走什么
这一阶段的主线是:
text
ModuleGraph
-> 入口创建 Chunk + Entrypoint ChunkGroup
-> 队列遍历同步依赖并放入当前 Chunk
-> 遇到 AsyncDependenciesBlock
-> 创建/复用异步 Chunk + ChunkGroup
-> 用父级可用模块集合避免重复打包
-> 得到 ChunkGraph
从 Rust 设计角度,可以重点记住四件事:
- 两张图解决两类问题 :
ModuleGraph表达源码依赖;ChunkGraph表达加载与产物分组; - 显式队列替代递归:既避免深依赖栈溢出,也能处理 ChunkGroup 之间集合变化后的重新遍历;
- 双向关联:Chunk 到 Module、Module 到 Chunk 都保存,方便后续优化、ID 分配和代码生成;
- 位图集合计算共享依赖 :
min_available_modules不是简单去重,而是基于所有父加载路径计算安全的可复用模块集合。
写在最后
现在 Rspack 已经知道每个模块将被放入哪个 Chunk,也知道初始 Chunk 与异步 Chunk 应如何加载。但这些 Chunk 里还只是 ModuleIdentifier,没有浏览器能执行的 JavaScript 文本。
下一篇会继续进入 CodeGenerationPass:模块是如何按 runtime 生成不同代码结果的,runtime_requirements 从哪里来,以及 CodeGenerationResult 怎样成为后续生成 main.js 等产物的输入。