Rspack 源码解析(四):从 ModuleGraph 到 ChunkGraph

本篇是 Rspack 源码解析系列第四篇。上一篇我们已经得到了一张稳定的 ModuleGraph:它回答的是"模块依赖谁"。但浏览器并不认识 ModuleGraph,它最终要加载的是 main.jssrc_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

这里最容易犯的错误是把 ChunkChunkGroup 当作同一个对象。

  • 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 之前还有三个很短但语义重要的阶段:

  1. FinishModulesPhasePass:模块图结构冻结,插件补全 exports 和异步模块等分析结果;
  2. SealPass:触发 Compilation.hooks.seal,让插件在代码分割前做最后准备;
  3. 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 -> ChunkAsyncDependenciesBlock -> 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() 的遍历过程中会多次问两个问题:

  1. 某个模块/依赖块有哪些有效的下游模块?
  2. 某个模块/依赖块下面有哪些异步依赖块?

如果每次都从 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 的 runtimedependOnincludefilenamechunkLoadingasyncChunks 等选项。例如 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。后面的 OptimizeChunksPassOptimizeTreePass 等阶段才会继续优化当前 ChunkGraph。

这一篇应该带走什么

这一阶段的主线是:

text 复制代码
ModuleGraph
  -> 入口创建 Chunk + Entrypoint ChunkGroup
  -> 队列遍历同步依赖并放入当前 Chunk
  -> 遇到 AsyncDependenciesBlock
  -> 创建/复用异步 Chunk + ChunkGroup
  -> 用父级可用模块集合避免重复打包
  -> 得到 ChunkGraph

从 Rust 设计角度,可以重点记住四件事:

  1. 两张图解决两类问题ModuleGraph 表达源码依赖;ChunkGraph 表达加载与产物分组;
  2. 显式队列替代递归:既避免深依赖栈溢出,也能处理 ChunkGroup 之间集合变化后的重新遍历;
  3. 双向关联:Chunk 到 Module、Module 到 Chunk 都保存,方便后续优化、ID 分配和代码生成;
  4. 位图集合计算共享依赖min_available_modules 不是简单去重,而是基于所有父加载路径计算安全的可复用模块集合。

写在最后

现在 Rspack 已经知道每个模块将被放入哪个 Chunk,也知道初始 Chunk 与异步 Chunk 应如何加载。但这些 Chunk 里还只是 ModuleIdentifier,没有浏览器能执行的 JavaScript 文本。

下一篇会继续进入 CodeGenerationPass:模块是如何按 runtime 生成不同代码结果的,runtime_requirements 从哪里来,以及 CodeGenerationResult 怎样成为后续生成 main.js 等产物的输入。

相关推荐
传奇开心果编程3 小时前
【xilem0.4基础语法学与练】第45课 xilem_core
学习·rust·前端框架
芒鸽4 小时前
把 Agent 运行时做成插件系统:agent-harness(openJiuwen Rust版) 的实践
开发语言·后端·rust
传奇开心果编程16 小时前
【Xilem基础语法学与练】第7课:Xilem与Masonry底层构建技术解析
学习·rust·前端框架
孙启超20 小时前
【AI开发之Rust】第 3 课:字符串与复合类型 —— 数据怎么放
开发语言·人工智能·后端·rust·llm·transformer
柯南46681 天前
【AI开发之Rust】第 4 课:所有权系统 —— Rust 的灵魂
rust
guwentian1 天前
Rolldown vs esbuild vs Webpack:Rust 打包器大战我们该怎么选
前端·webpack·rust·esbuild
传奇开心果编程1 天前
【Rust入门知识点学与练】第26课:Unsafe Rust
开发语言·学习·rust