Rspack 源码解析(十七):SplitChunks 如何从 ChunkGraph 中切出公共代码

5 阅读6分钟

本篇是 Rspack 源码解析系列第十七篇。第七篇讲到 splitChunks 在 OptimizeChunksPass 阶段生效,但只停留在"为什么放在这里"。本文直接进入 crates/rspack_plugin_split_chunks,看一个 cache group 如何把共享模块从多个 Chunk 中切出来、放进新 Chunk,并写回 ChunkGraph 和 incremental Mutation。

前言:splitChunks 不是一个排序算法

很多人以为 splitChunks 只是"把出现两次以上的模块拎出来"。真实实现要同时处理:

cache group 优先级
minSize / minChunks / maxSize / maxRequest
reuseExistingChunk
模块在不同 chunk 中的归属
maxInitialSize / maxAsyncSize 的二次拆分
chunk 与 chunk 之间的父子关系
incremental 的 Mutation 记录

这些逻辑都落在 crates/rspack_plugin_split_chunks 这个 crate 里。

源码入口:optimize_chunks Hook

rspack_plugin_split_chunks/src/plugin/mod.rs 是整个插件的入口。它的注册方式非常典型:

#[plugin_hook(CompilationOptimizeChunks for SplitChunksPlugin, stage = Compilation::OPTIMIZE_CHUNKS_STAGE_ADVANCED)]
async fn optimize_chunks(&self, compilation: &mut Compilation) -> Result<Option<bool>> {
  self.inner_impl(compilation).await?;
  compilation
    .build_chunk_graph_artifact
    .chunk_graph
    .generate_dot(compilation, "after-split-chunks")
    .await;
  Ok(None)
}

impl Plugin for SplitChunksPlugin {
  fn name(&self) -> &'static str {
    "rspack.SplitChunksPlugin"
  }

  fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
    ctx
      .compilation_hooks
      .optimize_chunks
      .tap(optimize_chunks::new(self));
    Ok(())
  }
}

注意两点:

  1. stage = Compilation::OPTIMIZE_CHUNKS_STAGE_ADVANCED——它故意排在其他 chunk 优化之后,因为 splitChunks 需要看到相对完整的 ChunkGraph;
  2. 执行完后调用 generate_dot 输出一张 after-split-chunks 的 dot 图,方便调试分包结果。

第一阶段:准备模块数据

inner_impl 一开始先收集三样东西:

let mut all_modules = compilation
  .get_module_graph()
  .modules_keys()
  .copied()
  .collect::<Vec<_>>();
// Sort modules to ensure deterministic processing order
all_modules.sort_unstable();

let module_sizes = get_module_sizes(all_modules.par_iter().copied(), compilation);
let module_chunks = Self::get_module_chunks(&all_modules, compilation);
  • all_modules.sort_unstable() 保证处理顺序确定,多次构建结果一致;
  • module_sizes 用 rayon 并行计算每个模块的大小;
  • module_chunks 是 module -> 它属于哪些 chunk 的映射。

get_module_chunks 在 chunk.rs 里,直接用 rayon 并行读 ChunkGraph:

pub(crate) fn get_module_chunks(
  all_modules: &[ModuleIdentifier],
  compilation: &Compilation,
) -> ModuleChunks {
  let chunk_graph = &compilation.build_chunk_graph_artifact.chunk_graph;
  all_modules
    .par_iter()
    .map(|module| (*module, chunk_graph.get_module_chunks(*module).clone()))
    .collect::<IdentifierMap<_>>()
}

这告诉我们:splitChunks 的输入不是原始文件,而是已经建好的 ChunkGraph 上的"模块—Chunk"关系。

第二阶段:把 cache groups 按 priority 排序

splitChunks.cacheGroups 里的每组有 priority。priority 越高的组越先匹配。源码先把它们按 priority 分组:

let mut priority_cache_groups = vec![];

for (priority, cache_groups) in &self
  .cache_groups
  .iter()
  .enumerate()
  .map(|v| IndexedCacheGroup {
    cache_group_index: v.0,
    cache_group: v.1,
  })
  .sorted_by(|a, b| match b.compare_by_priority(a) {
    Ordering::Equal => a.compare_by_index(b),
    v => v,
  })
  .chunk_by(|v| v.cache_group.priority)
{
  priority_cache_groups.push((priority, cache_groups.into_iter().collect::<Vec<_>>()));
}

这里 compare_by_priority 先按 priority 降序,priority 相同时按 index 升序(先声明的优先)。chunk_by 把相同 priority 的组聚到一起。

第三阶段:决定模块分组方式

splitChunks 有两种分组维度,取决于 cacheGroups 是否配置了 usedExports:

if self.cache_groups.iter().any(|cg| !cg.used_exports) {
  combinator.prepare_group_by_chunks(&all_modules, &module_chunks, &chunk_index_map);
}

if self.cache_groups.iter().any(|cg| cg.used_exports) {
  combinator.prepare_group_by_used_exports(
    &all_modules,
    &compilation.exports_info_artifact,
    &compilation.build_chunk_graph_artifact.chunk_by_ukey,
    &module_chunks,
    &chunk_index_map,
  );
}
  • prepare_group_by_chunks:按"模块出现在哪些 chunk"分组(经典 splitChunks 行为);
  • prepare_group_by_used_exports:按"模块被哪些 export 使用"分组,这是结合 tree shaking 的更细粒度分包。

两种方式共用一个 Combinator,输出 ModuleGroup 候选。

第四阶段:主循环,逐个取最优 ModuleGroup

核心是一个 while 循环:

while !module_group_map.is_empty() {
  let (module_group_key, mut module_group) =
    self.find_best_module_group(&mut module_group_map);

  let cache_group = module_group.get_cache_group(&self.cache_groups);

  let mut is_reuse_existing_chunk = false;
  let mut is_reuse_existing_chunk_with_all_modules = false;
  let new_chunk = self.get_corresponding_chunk(
    compilation,
    &mut module_group,
    &mut is_reuse_existing_chunk,
    &mut is_reuse_existing_chunk_with_all_modules,
  );
  // ...
}

find_best_module_group 会挑出当前优先级下"最值得切"的 ModuleGroup,处理完后从 map 中移除。这里的关键是 get_corresponding_chunk,它决定是复用已有 chunk 还是新建 chunk。

get_corresponding_chunk:三种去向

chunk.rs 里的 get_corresponding_chunk 分三种情况:

情况一:有 chunk name,且 named_chunks 里已有同名 chunk

if let Some(chunk_name) = &module_group.chunk_name {
  if let Some(chunk) = compilation
    .build_chunk_graph_artifact
    .named_chunks
    .get(chunk_name)
  {
    *is_reuse_existing_chunk = true;
    *chunk
  } else {
    // 创建 named chunk
  }
}

情况二:reuseExistingChunk 且存在可复用的 code splitting chunk

} else if module_group.cache_group_reuse_existing_chunk
  && let Some(reusable_chunk) = self.find_the_best_reusable_chunk(compilation, module_group)
{
  *is_reuse_existing_chunk = true;
  *is_reuse_existing_chunk_with_all_modules = true;
  reusable_chunk
}

find_the_best_reusable_chunk 会找"已经包含同样模块集合"的 chunk,避免重复拆分。其中有一个 fast path:

if compilation
  .build_chunk_graph_artifact
  .chunk_graph
  .get_number_of_chunk_modules(&chunk.ukey())
  != module_group.modules.len()
{
  // Fast path
  return None;
}

情况三:新建一个 chunk

let new_chunk_ukey =
  Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
if let Some(mut mutations) = compilation.incremental.mutations_write() {
  mutations.add(Mutation::ChunkAdd { chunk: new_chunk_ukey });
}

注意新建 chunk 时会写入 Mutation::ChunkAdd,这是增量构建体系的一部分——后续 Pass 能据此知道"新增了哪些 chunk"。

移动模块:disconnect 再 connect

选定 chunk 后,move_modules_to_new_chunk_and_remove_from_old_chunks 负责把模块从旧 chunk 挪到新 chunk:

compilation
  .build_chunk_graph_artifact
  .chunk_graph
  .disconnect_chunks_and_modules(&chunks, &modules);

compilation
  .build_chunk_graph_artifact
  .chunk_graph
  .connect_chunk_and_modules(new_chunk, &modules);

它的本质是改 ChunkGraph 里的归属关系:先断开模块与旧 chunk 的连接,再连到新 chunk。移动前还会过滤掉 chunk_condition 不满足的模块:

let modules = item.modules.iter().filter(|mid| {
  if let Some(module) = compilation.module_by_identifier(mid)
    && module.chunk_condition(&new_chunk, compilation).is_some_and(|c| !c)
  {
    return false;
  }
  true
}).copied().collect::<Vec<_>>();

建立 chunk 之间的 split 关系

模块挪走后,还要让旧 chunk 知道"我的一部分内容被拆到了 new_chunk"。split_from_original_chunks 调用 Chunk::split:

for original_chunk_ukey in original_chunks {
  let [Some(new_chunk), Some(original_chunk)] = compilation
    .build_chunk_graph_artifact
    .chunk_by_ukey
    .get_many_mut([&new_chunk_ukey, original_chunk_ukey])
  else {
    panic!("split_from_original_chunks failed")
  };
  original_chunk.split(
    new_chunk,
    &mut compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
  );
  if let Some(mut mutations) = compilation.incremental.mutations_write() {
    mutations.add(Mutation::ChunkSplit {
      from: *original_chunk_ukey,
      to: new_chunk_ukey,
    });
  }
}

这里又写入了 Mutation::ChunkSplit。它记录"from chunk 拆出了 to chunk",是后续 chunk 加载关系、runtime 生成的重要输入。

约束检查:maxRequest、minSize、maxSize

主循环里穿插着多个约束检查。例如 ensure_max_request_fit 会在 chunk 数量超过 maxInitialRequest/maxAsyncRequest 时减少参与拆分的 chunk 数:

let mut used_chunks = Cow::Borrowed(&module_group.chunks);
self.ensure_max_request_fit(compilation, cache_group, &mut used_chunks);

if used_chunks.len() != module_group.chunks.len() {
  let used_chunks_len = if is_reuse_existing_chunk {
    used_chunks.len() + 1
  } else {
    used_chunks.len()
  };
  if used_chunks_len < cache_group.min_chunks as usize {
    continue;
  }
}

ensure_min_size_fit 和 ensure_max_size_fit 则分别处理最小/最大体积约束。max_size 的拆分逻辑在 max_size.rs 里,是一个独立的二次切分流程(因为把一个超大 chunk 再拆小,需要额外维护 chunk 关系)。

Rust 角度:确定性 + 并行 + 增量记录

这整个插件最能体现 Rspack 核心的三个工程习惯:

  1. 确定性:sort_unstable、sort_by_cached_key、best_reusable_chunk 里的稳定比较,都是为了多次构建结果一致;
  2. 并行:get_module_sizes、get_module_chunks 用 par_iter,计算密集的部分交给 rayon;
  3. 增量记录:新建 chunk 和拆分 chunk 都写 Mutation,让增量构建能精准失效。

这和第十一篇讲的 Artifact/Mutation/Cache 体系一脉相承:splitChunks 不只是改 ChunkGraph,还要把"改了什么"记录下来。

这一篇应该带走什么

  1. splitChunks 在 OptimizeChunksPass 的 ADVANCED stage 执行,输入是已建好的 ChunkGraph;
  2. cache groups 按 priority 排序,priority 相同按声明 index;
  3. 模块先按"chunk 归属"或"used exports"聚成 ModuleGroup,再逐个挑最优切分;
  4. get_corresponding_chunk 决定复用 named chunk、复用已有 chunk 还是新建 chunk;
  5. 移动模块本质是 ChunkGraph 的 disconnect + connect,并写 Mutation::ChunkAdd/ChunkSplit 支撑增量构建。

写在最后

splitChunks 决定了"哪些模块进哪些 chunk",但它能切得多细,取决于前面的 tree shaking 是否已经算清了每个模块真正被使用的导出。下一篇我们回到 rspack_plugin_javascript 的 SideEffectsFlagPlugin,看 tree shaking 如何通过"副作用连接状态"优化模块之间的引用关系。