Rspack 源码解析(七):Module、Chunk 与加载树优化

28 阅读4分钟

本篇是 Rspack 源码解析系列第七篇。ChunkGraph 建好后,Rspack 并不会立刻生成代码,而是依次执行 OptimizeModulesPassOptimizeChunksPassOptimizeTreePassOptimizeChunkModulesPass。它们回答的是:已知依赖图和分包图后,怎样让最终体积和加载关系更合理。

前言:优化不是一个 Pass

源码中的顺序是:

BuildChunkGraphPass
  -> OptimizeModulesPass
  -> OptimizeChunksPass
  -> OptimizeTreePass
  -> OptimizeChunkModulesPass

四个 Pass 看起来都只是调用 Hook,正说明 Rspack Core 的职责是定义稳定的阶段和数据边界,具体算法由 rspack_plugin_* 中的插件注册。按对象粒度拆开,避免一个“optimize”同时修改所有数据结构:

Pass主要对象关注点
OptimizeModulesPassModuleGraph模块是否可连接、拼接或移除
OptimizeChunksPassChunk / ChunkGroupChunk 如何拆分、合并、命名
OptimizeTreePassChunkGraph 的整棵加载树跨 Chunk 的可达性与运行时关系
OptimizeChunkModulesPass一个 Chunk 内的模块集合模块在具体 Chunk 内的布局

四个 Pass 的共同形态

以模块优化为例:

while matches!(
  compilation.plugin_driver.clone().compilation_hooks
    .optimize_modules
    .call(compilation, &mut diagnostics)
    .await?,
  Some(true)
) {}

compilation.plugin_driver.clone().compilation_hooks
  .after_optimize_modules
  .call(compilation)
  .await?;

这段代码包含两层设计:

  1. Hook 的插件顺序由 PluginDriver 统一管理,Pass 不依赖某个具体插件;
  2. Some(true) 表示本轮图被改变,需要再次运行同一优化阶段直到稳定。

OptimizeChunksPass 也使用重复执行;OptimizeTreePassOptimizeChunkModulesPass 则是一次调用。是否允许迭代由该阶段的语义决定,而不是所有 Hook 都无条件循环。

模块级:先决定哪些源码逻辑值得保留

模块优化消费第六篇得到的 exports 和 side effects 信息。它通常会驱动:

used exports
  -> 标记不可达导出
  -> 移除无副作用且不可达的模块
  -> 条件满足时做模块拼接(scope hoisting)

模块拼接并不是把文本字符串直接相连。Rspack 中有 ConcatenatedModule 一类模块抽象,它仍然以 Module 身份参与后续 CodeGeneration。这样优化后的结果不会破坏 Module::code_generation() 这一统一接口。

从 Rust 角度看,这说明 trait object 的价值:后续阶段只依赖 dyn Module 的行为,不需要为 NormalModuleConcatenatedModule 分别写整条流水线。

Chunk 级:splitChunks 为什么在这里生效

第四篇的 BuildChunkGraph 只根据入口、同步依赖和 import() 建立初始分组。公共模块的提取、Chunk 合并等策略属于 OptimizeChunksPass

entry-a -> shared
entry-b -> shared

初始:shared 同时在 a、b 的 Chunk 中
  -> Chunk 优化插件识别复用与阈值
  -> 创建 common Chunk 或调整归属
  -> a、b 改为依赖 common

是否抽取不是“共享就一定抽”。还要考虑 minSizeminChunks、请求数量和 cache group。将它放在 ChunkGraph 已构建之后,插件可以看到完整的模块归属和 ChunkGroup 父子关系。

Tree 级:从单个 Chunk 到加载路径

一个模块位于多个 Chunk 时,单看某个 Chunk 不足以判断它是否可用。OptimizeTreePass 处理的是入口到异步 ChunkGroup 的整体结构:

Entrypoint(main)
  -> Chunk(main)
  -> ChunkGroup(lazy)
      -> Chunk(lazy)

这一级优化需要考虑 parent、child、runtime 和可用模块集合。例如把模块提升到父 Chunk 可能减少异步请求,但也会扩大初始包;将其保留在异步 Chunk 则相反。由树级插件作决策,能避免各个 Chunk 局部修改造成加载关系不一致。

Chunk 内模块级:最后整理具体归属

OptimizeChunkModulesPass 的核心调用是:

compilation.plugin_driver.clone().compilation_hooks
  .optimize_chunk_modules
  .call(compilation)
  .await?;

它位于 tree 优化之后,因为此时 Chunk 结构已基本稳定。此阶段适合完成“某个模块在某个 Chunk 中是否仍需要存在”的最后调整。Pass 前后还调用 cache Hook:

async fn before_pass(&self, compilation: &mut Compilation, cache: &mut dyn Cache) {
  cache.before_optimize_chunk_modules(compilation).await;
}

这使缓存实现可以按阶段保存或恢复结果,而不是把缓存逻辑散落到每个优化插件。

一张流程图

稳定 ModuleGraph + ChunkGraph
       |
       v
OptimizeModules      以 Module 为单位减少/合并代码
       |
       v
OptimizeChunks       以 Chunk 为单位执行 splitChunks 等策略
       |
       v
OptimizeTree         校验并优化入口到异步 Chunk 的加载树
       |
       v
OptimizeChunkModules 最终调整模块和具体 Chunk 的归属
       |
       v
准备给模块、Chunk 和 runtime 分配可执行身份

这一篇应该带走什么

  1. 优化阶段按 Module、Chunk、Tree、Chunk-Module 四个粒度分层;
  2. splitChunks 的输入是已完成的 ChunkGraph,而不是原始文件列表;
  3. 反复执行的 Hook 使用 Some(true) 作为明确的“尚未稳定”信号;
  4. Core 只调度阶段,内置或用户插件决定具体优化算法;
  5. 优化后仍然保留统一的 Module 抽象,后续代码生成不必知道某模块是否被拼接过。

写在最后

当图结构不再变化,Rspack 就可以开始给 Module、Chunk 和 runtime 分配 ID。这个阶段表面上只是命名,实际上直接影响模块加载、缓存命中和生成代码的稳定性。下一篇进入三类 ID 的分配过程。