Rspack 源码解析(五):CodeGenerationPass 与模块代码生成

文章来源声明: 原文作者:Amos_zz; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685302724012769307; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

这篇把按 runtime 缓存、分组并发、runtime requirement 声明等设计讲清了,适合想理解 Rspack 编译性能与 Pass 解耦的前端构建开发者。

> 本篇是 Rspack 源码解析系列第五篇。上一篇我们从 ModuleGraph 得到了 ChunkGraph,已经知道 `index.ts`、`math.ts`、`lazy.ts` 各自会被放入哪些 Chunk。但“放进 Chunk”不代表它们已经是浏览器可以执行的源码。本篇进入 `CodeGenerationPass`,看 Rspack 怎样让每个模块针对不同 runtime 生成代码、运行时依赖和 source map 等结果。

前言

先把几个阶段的输出区分开:

Build Module Graph
  输出:ModuleGraph
  解决:谁依赖谁

Build Chunk Graph
  输出:ChunkGraph
  解决:谁进入哪个 Chunk,异步 Chunk 如何连接

Code Generation
  输出:CodeGenerationResults
  解决:每个 Module 在某个 runtime 下具体生成什么代码

Create Chunk Assets
  输出:Asset(main.js、lazy.js 等)
  解决:如何把一个 Chunk 中的多个模块代码拼成最终文件

本篇只聚焦第三层。CodeGenerationPass 并不直接写出 dist/main.js,而是先为模块生成“可被渲染的代码片段和元数据”。真正将这些片段拼成 asset 的工作在后面的 CreateChunkAssetsPass

CodeGenerationPass 在流水线中的位置

run_passes.rs 中,代码生成位于一系列优化和标识分配之后:

BuildChunkGraphPass
  -> OptimizeModulesPass
  -> OptimizeChunksPass
  -> OptimizeTreePass
  -> OptimizeChunkModulesPass
  -> ModuleIdsPass
  -> ChunkIdsPass
  -> AssignRuntimeIdsPass
  -> OptimizeCodeGenerationPass
  -> CreateModuleHashesPass
  -> CodeGenerationPass
  -> RuntimeRequirementsPass
  -> CreateHashPass
  -> CreateModuleAssetsPass
  -> CreateChunkAssetsPass

这个顺序不是偶然的。

  • 代码生成需要知道模块在哪些 Chunk/runtime 中执行;
  • 模块 ID、Chunk ID 会直接进入生成代码;
  • Tree Shaking、模块拼接等优化必须先确定,否则会为最终不需要的模块生成无用代码;
  • 模块 hash 在代码生成前创建,用于结果缓存和内容 hash 计算。

换句话说,CodeGenerationPass 是“图优化结果”到“源代码产物”的边界。

先建立四个概念

概念可以理解成作用
`Module::code_generation()`各类模块自己的代码生成方法`NormalModule`、`ExternalModule`、`ContextModule` 实现不同逻辑
`ModuleCodeGenerationContext`本次生成代码可读取的编译上下文提供 `Compilation`、runtime、模板和模块拼接 scope
`CodeGenerationResult`单个模块的生成结果保存不同 `SourceType` 的 source、runtime requirements、hash 等
`CodeGenerationResults`所有模块、所有 runtime 的结果仓库供后续渲染 Chunk 使用

这里的关键点是:同一个 Module 可能在多个 runtime 下有不同生成结果。

例如 index.ts 被两个不同 entry 引用,或某个模块在不同 runtime 中有不同的可用导出、模块 ID 或 runtime requirement。Rspack 不能只用 ModuleIdentifier -> Source 的简单映射,而要按 runtime 保存结果。

Pass 入口:增量构建先缩小范围

入口位于 crates/rspack_core/src/compilation/code_generation/mod.rs

<span>// code_generation/mod.rs(简化)</span>
<span>async</span> <span>fn</span> <span>code_generation_pass_impl</span>(compilation: &<span>mut</span> Compilation) <span>-></span> <span>Result</span><()> {
  <span>let</span> <span></span><span>code_generation_modules</span> = <span>if</span> <span>let</span> <span></span><span>Some</span>(mutations) = compilation
    .incremental
    .<span>mutations_read</span>(IncrementalPasses::MODULES_CODEGEN)
    && !compilation.code_generation_results.<span>is_empty</span>()
  {
    <span>// 删除已移除模块的旧结果</span>
    <span>// 根据 ModuleSetHashes mutation 收集受影响模块</span>
    <span>// 只重生成这些模块</span>
    modules
  } <span>else</span> {
    *compilation.code_generation_results = <span>Default</span>::<span>default</span>();
    compilation.<span>get_module_graph</span>().<span>modules_keys</span>().<span>copied</span>().<span>collect</span>()
  };

  compilation.<span>code_generation</span>(code_generation_modules).<span>await</span>?;

  compilation.plugin_driver
    .compilation_hooks
    .after_code_generation
    .<span>call</span>(compilation, &<span>mut</span> diagnostics)
    .<span>await</span>?;
  <span>Ok</span>(())
}

首次构建时,所有模块都会进入 code generation。增量构建时,Rspack 利用前面阶段记录的 Mutation::ModuleSetHashes 缩小范围:模块没有变化、生成 hash 没有失效,就无需重新生成。

这与上一篇的 Cutout + repair 是同一种思路:整个编译器不是“文件一变就重做所有阶段”,而是让每个 Artifact 都有自己的变更边界。

为什么要按 runtime 分组

Compilation::code_generation() 并不会立即遍历所有模块调用生成函数。它先区分有无 code generation dependencies 的模块,再交给 code_generation_modules()

<span>// code_generation/mod.rs(简化)</span>
<span>for</span> <span>runtime</span> <span>in</span> chunk_graph.<span>get_module_runtimes_iter</span>(
  module,
  &<span>self</span>.build_chunk_graph_artifact.chunk_by_ukey,
) {
  <span>let</span> <span></span><span>hash</span> = ChunkGraph::<span>get_module_hash</span>(<span>self</span>, module, runtime)?;

  <span>if</span> <span>let</span> <span></span><span>Some</span>(job) = map.<span>get_mut</span>(hash) {
    job.runtimes.<span>push</span>(runtime.<span>clone</span>());
  } <span>else</span> {
    map.<span>insert</span>(hash.<span>clone</span>(), CodeGenerationJob {
      module,
      hash,
      runtime: runtime.<span>clone</span>(),
      runtimes: <span>vec!</span>[runtime.<span>clone</span>()],
      scope,
    });
  }
}

这段代码有两层去重。

第一层:模块按其运行时遍历

ChunkGraph::get_module_runtimes_iter() 从 ChunkGraph 中取出模块所在 ChunkGroup 的 runtime。一个模块被多个入口共享时,可能对应多个 runtime。

第二层:生成 hash 相同的 runtime 合并成一个 Job

如果多个 runtime 下该模块的生成 hash 相同,Rspack 只生成一次,再将结果复用到多个 runtime:

module A
  runtime X -> hash h1
  runtime Y -> hash h1
  runtime Z -> hash h2

最终 Job:
  Job(A, h1, [X, Y])
  Job(A, h2, [Z])

这比“每个 Chunk 都重新生成其中每个模块”节省得多。它也说明代码生成结果的缓存粒度不是单纯的文件,而是“模块在等价运行时上下文中的代码”。

并行执行:先组织 Job,再并发生成

所有 CodeGenerationJob 收集完后,Rspack 使用 rspack_futures::scope 并发执行:

<span>// code_generation/mod.rs(简化)</span>
<span>let</span> <span></span><span>results</span> = rspack_futures::scope::<_, _>(|token| {
  jobs.<span>into_iter</span>().<span>for_each</span>(|job| {
    <span>let</span> <span></span><span>s</span> = <span>unsafe</span> { token.<span>used</span>((&<span>self</span>, &module_graph, job)) };

    s.<span>spawn</span>(|(this, module_graph, job)| <span>async</span> {
      <span>let</span> <span></span><span>module</span> = module_graph
        .<span>module_by_identifier</span>(&job.module)
        .<span>expect</span>(<span>"should have module"</span>);

      module.<span>code_generation</span>(&<span>mut</span> code_generation_context).<span>await</span>
    })
  })
})
.<span>await</span>;

和第三篇 Build Module Graph 的后台任务设计形成对照:

  • 模块图构建时,共享图结构会被串行写入,因此需要把 AddTask 等操作放回主任务队列;
  • 代码生成时,输入的 ModuleGraph、ChunkGraph 和前序优化结果已经稳定,每个 Job 产出独立的 CodeGenerationResult,因此适合并行计算。

这体现了一个重要的并发设计原则:不是所有编译阶段都需要同一种并发模型;能否并行取决于任务是否写同一份共享状态。

ModuleCodeGenerationContext:模块生成时能看到什么

每个 Job 会创建 ModuleCodeGenerationContext

<span>// code_generation/mod.rs(简化)</span>
<span>let</span> <span>mut </span><span>runtime_template</span> = this.runtime_template.<span>create_module_code_template</span>();
<span>let</span> <span>mut </span><span>code_generation_context</span> = ModuleCodeGenerationContext {
  compilation: this,
  runtime: <span>Some</span>(&job.runtime),
  concatenation_scope: job.scope.<span>clone</span>(),
  runtime_template: &<span>mut</span> runtime_template,
};

module.<span>code_generation</span>(&<span>mut</span> code_generation_context).<span>await</span>

模块生成代码时不应该自己持有整个编译器的可变状态,而是通过这个上下文读取所需能力:

字段用途
`compilation`读取 options、ModuleGraph、ChunkGraph、插件钩子等编译信息
`runtime`按当前 runtime 判断依赖连接、导出名和运行时代码需求
`concatenation_scope`支持 Module Concatenation 等优化后的模块组合
`runtime_template`生成 `__webpack_require__` 等 runtime 调用模板,并收集 runtime requirements

这是很典型的 Rust API 设计:将一次操作实际需要的上下文显式封装为参数,而不是让模块对象依赖全局状态。

NormalModule 的代码生成发生了什么

不同模块类型各自实现 Module trait 中的 code_generation()

<span>// crates/rspack_core/src/module.rs(示意)</span>
<span>#[async_trait]</span>
<span>pub</span> <span>trait</span> <span>Module</span> {
  <span>async</span> <span>fn</span> <span>code_generation</span>(
    &<span>self</span>,
    context: &<span>mut</span> ModuleCodeGenerationContext,
  ) <span>-></span> <span>Result</span><CodeGenerationResult>;
}

例如:

  • NormalModule:对 JavaScript/TypeScript 经 Loader、Parser 处理后的源码进行依赖替换和封装;
  • ExternalModule:生成对外部变量、CommonJS 或 ESM import 的引用代码;
  • ContextModule:生成上下文模块的查找映射;
  • ConcatenatedModule:生成 Scope Hoisting 后拼接的模块代码。

NormalModule::code_generation() 位于 crates/rspack_core/src/normal_module.rs。它不是再去读源文件或执行 Loader;这些工作已经在第三篇的 BuildTask 中完成。此时它消费已经构建好的 source 和 dependencies,并让 dependency template 根据当前 runtime 改写对应位置。

可以把这个阶段粗略理解成:

Loader / Parser 之后的源码
  + 依赖信息
  + 当前 ModuleId / runtime
  + dependency templates
  -> 浏览器运行时可调用的模块源码

例如源码:

<span>import</span> { sum } <span>from</span> <span>'./math'</span>;
<span>console</span>.<span>log</span>(<span>sum</span>(<span>1</span>, <span>2</span>));

经过模块化代码生成后,不再只是原始 import 文本,而会变成与模块 ID、模块工厂和 runtime loader 协作的代码形态。具体字符串会随 target、output、模块格式和优化配置而变,但原则不变:模块之间的引用从“源码层的请求”变为“运行时能解析的模块引用”。

CodeGenerationResult 包含的不只是 JavaScript

module.code_generation() 返回的不是单一字符串,而是 CodeGenerationResult。从后续渲染角度,它至少要表达:

不同 SourceType 的 Source
  + runtime_requirements
  + data
  + hash

SourceType 允许模块产生不止一种类型的输出,例如 JavaScript、CSS 或 runtime 相关 source。后面的 Chunk 渲染器会按 source type 组合模块结果。

runtime_requirements 同样重要。模块本身生成代码时可能发现需要某个运行时全局能力,例如:

__webpack_require__
模块缓存
异步 Chunk 加载
公开路径
ESM namespace helpers

这些 requirement 先由模块生成阶段收集,再由后面的 RuntimeRequirementsPass 汇总到 Chunk 和 Tree,最后再添加所需 runtime module。这样一个普通模块不用自己直接拼完整 runtime,只需要声明“我用到了什么”。

源码中在生成完成后会合并模板收集到的 requirement,并计算结果 hash:

<span>// code_generation/mod.rs(简化)</span>
module.<span>code_generation</span>(&<span>mut</span> code_generation_context)
  .<span>await</span>
  .<span>map</span>(|<span>mut</span> codegen_res| {
    codegen_res.runtime_requirements.<span>extend</span>(
      *runtime_template.<span>runtime_requirements</span>()
    );
    codegen_res.<span>set_hash</span>(
      &options.output.hash_function,
      &options.output.hash_digest,
      &options.output.hash_salt,
    );
    codegen_res
  })

缓存:模块代码生成结果如何复用

生成代码前,Rspack 会通过 code_generate_cache_artifact.use_cache() 查询缓存:

<span>// code_generation/mod.rs(简化)</span>
<span>let</span> <span></span><span>codegen_res</span> = this
  .code_generate_cache_artifact
  .<span>use_cache</span>(&job, || <span>async</span> {
    module.<span>code_generation</span>(&<span>mut</span> code_generation_context).<span>await</span>
  })
  .<span>await</span>;

缓存键对应 CodeGenerationJob,因此自然包含模块、hash 和 runtime 相关信息。对于未变更模块,缓存命中时就可以避免重复执行 dependency template 和源码转换。

同时,代码生成错误不会让 Rspack 在这里立刻丢弃所有其他模块结果:

<span>let</span> <span></span><span>codegen_res</span> = <span>match</span> codegen_res {
  <span>Ok</span>(codegen_res) => codegen_res,
  <span>Err</span>(err) => {
    <span>let</span> <span>mut </span><span>diagnostic</span> = Diagnostic::<span>from</span>(err);
    diagnostic.module_identifier = <span>Some</span>(module);
    <span>self</span>.<span>push_diagnostic</span>(diagnostic);
    CodeGenerationResult::<span>default</span>()
  }
};

这延续了第三篇 Factorize/Build 阶段的设计:尽可能收集完整 diagnostics,最后统一向用户报告,而不是遇到第一个模块失败就停止整个编译流程。

结果如何写回 Compilation

每个并发 Job 完成后,主流程将结果写回 code_generation_results

<span>// code_generation/mod.rs(简化)</span>
<span>self</span>
  .code_generation_results
  .<span>insert</span>(module, codegen_res, runtimes);
<span>self</span>.code_generated_modules.<span>insert</span>(module);

可以把这个 Artifact 理解为:

CodeGenerationResults
  Module(index.ts)
    Runtime(main) -> JS source + requirements + hash
  Module(math.ts)
    Runtime(main) -> JS source + requirements + hash
  Module(lazy.ts)
    Runtime(lazy) -> JS source + requirements + hash

后续阶段不会重新调用每个 Module 的 code_generation(),而是从这里读取结果来:

  • 聚合 runtime requirements;
  • 计算 compilation/chunk hash;
  • 渲染模块代码;
  • 生成 main.js、异步 Chunk 和 source map 等最终 asset。

用前三篇的示例走一遍

继续使用:

<span>// index.ts</span>
<span>import</span> { sum } <span>from</span> <span>'./math'</span>;
<span>import</span>(<span>'./lazy'</span>);
<span>console</span>.<span>log</span>(<span>sum</span>(<span>1</span>, <span>2</span>));

在上一阶段,ChunkGraph 已经得到:

main Chunk: index.ts, math.ts
lazy Chunk: lazy.ts

现在 CodeGenerationPass 会做:

index.ts @ main runtime
  -> 生成模块代码
  -> 声明同步引用 math.ts 所需的 runtime helper
  -> 声明 import() 所需的异步 Chunk 加载 helper

math.ts @ main runtime
  -> 生成导出 sum 的模块代码

lazy.ts @ lazy runtime
  -> 生成异步 Chunk 中的模块代码

结果并不是立即写文件,而是写入 CodeGenerationResults。之后的 Chunk 渲染阶段才会将:

main Chunk 的模块代码 + main 所需 runtime modules
  -> main.js

lazy Chunk 的模块代码
  -> lazy.js

这一篇应该带走什么

这一阶段的主线是:

优化完成的 ModuleGraph + ChunkGraph
  -> 按 module/runtime 收集 CodeGenerationJob
  -> 相同 hash 的 runtime 合并 Job
  -> 并行调用 Module::code_generation
  -> 收集 source、runtime requirements 和 hash
  -> 写入 CodeGenerationResults
  -> 供后续 runtime 和 asset 阶段消费

从 Rust 设计角度,至少有四点值得记住:

  1. 按 runtime 而不是按文件缓存:同一模块不同执行上下文可能有不同代码;
  2. 先分组、再并行:在图结构稳定后,独立的 code generation job 可以安全并发;
  3. Capability declaration 而非直接拼 runtime:模块只收集 runtime_requirements,统一由后续阶段提供实现;
  4. Artifact 作为阶段契约CodeGenerationResults 将“生成模块代码”和“渲染最终文件”隔开,让后续 Pass 不必重新理解 Module 内部细节。

写在最后

至此,Rspack 已经完成了从源码模块到“按 runtime 生成的模块代码”的转换。距离 dist/main.js 只差最后几步:收集 runtime requirements、计算 hash、为 Chunk 创建 asset,再进入 process_assets 钩子。

下一篇可以继续追 RuntimeRequirementsPassCreateChunkAssetsPass,重点看 runtime module 如何被加入 Chunk,以及多个 CodeGenerationResult 最终怎样被渲染为浏览器能加载的文件。