Rspack 源码解析(十二):JavaScript Chunk 是如何被渲染出来的

本篇是 Rspack 源码解析系列第十二篇。第十篇讲到 CreateChunkAssetsPass 会通过 render_manifest Hook 产出最终 asset。本文沿 rspack_plugin_javascript 继续往下看:一个 JavaScript Chunk 如何从模块生成结果、runtime module 和 chunk graph,变成最终的 main.js 或异步 chunk 文件。

前言:Core 不直接拼 JavaScript

在前面的主线里,Core 负责构建 ModuleGraph、ChunkGraph、RuntimeRequirements、Hash 和 Asset 生命周期。但真正到了输出 JavaScript 文件时,Core 并不会把模块表、runtime、启动代码写死在自己内部。

它做的是调用:

text 复制代码
compilation_hooks.render_manifest

然后由 JavaScript 插件向 manifest 中追加条目。

这点非常关键:

text 复制代码
Core 负责阶段和数据边界
JavaScript Plugin 负责 JS 输出格式

也就是说,main.jslazy.js、hot update chunk 并不是 Core 的固定产物,而是 JS 插件基于 Chunk 信息渲染出来的文件。

源码入口:CreateChunkAssetsPass 只负责调度

这一篇如果只讲 render_manifest,很容易停留在"插件产出 asset"的概念层。先看真实入口:

text 复制代码
crates/rspack_core/src/compilation/create_chunk_assets/mod.rs

CreateChunkAssetsPassrun_pass 很短:

rust 复制代码
async fn run_pass(&self, compilation: &mut Compilation) -> Result<()> {
  let plugin_driver = compilation.plugin_driver.clone();
  compilation.create_chunk_assets(plugin_driver).await?;
  Ok(())
}

这段源码说明:Core 的这个 Pass 并不拼 JavaScript,也不理解 CSS、Wasm 的输出格式。它只是进入 chunk asset 阶段,然后把控制权交给 Compilation::create_chunk_assets

继续看 create_chunk_assets,真正调用插件的位置是:

rust 复制代码
plugin_driver
  .compilation_hooks
  .render_manifest
  .call(this, chunk, &mut manifests, &mut diagnostics)
  .await?;

这里的 manifests 是一个 Vec<RenderManifestEntry>。也就是说,Core 对每个 Chunk 调一次 render_manifest,具体生成 JS、CSS 还是 Wasm,由 tap 到这个 Hook 上的插件决定。

源码里还通过 rspack_futures::scope 并发处理多个 Chunk。这个细节很重要:到这里时,模块图、ChunkGraph、runtime requirements、hash 都应该已经稳定,asset 阶段只是基于稳定中间结果做渲染。

RenderManifestEntry 可以理解成插件交给 Core 的"出货单":

rust 复制代码
pub struct RenderManifestEntry {
  pub source: BoxSource,
  pub filename: String,
  pub has_filename: bool,
  pub info: AssetInfo,
  pub auxiliary: bool,
}

随后 Core 统一执行:

rust 复制代码
self.emit_asset(
  filename.clone(),
  CompilationAsset::new(Some(file_manifest.source), file_manifest.info),
);

所以 Compilation.assets 不是 JS 插件随手写进去的,而是 Core 根据 manifest entry 统一入库。

JsPlugin 不只是最后拼文件

rspack_plugin_javascript 参与的阶段很多,不只是 asset 渲染。它会注册 JavaScript 相关的 parser、generator、dependency template、runtime requirement、hash 和 render manifest。

可以把它理解成一条贯穿编译过程的 JS 语义插件:

text 复制代码
解析 JS 模块
  -> 识别 import / require / import()
  -> 生成 Dependency
  -> 代码生成时替换依赖表达式
  -> 收集 runtime requirements
  -> 计算 JS content hash
  -> 渲染 JS chunk asset

这也是为什么 JavaScript 插件在 Rspack 中非常核心。它并不是"最后把字符串拼起来"的工具,而是把 JS 语言语义接入整个编译流水线。

源码看 JsPlugin 如何接入 asset 渲染

JavaScript 插件注册 Hook 的源码在:

text 复制代码
crates/rspack_plugin_javascript/src/plugin/impl_plugin_for_js_plugin.rs

impl Plugin for JsPlugin 里可以看到:

rust 复制代码
ctx.compiler_hooks.compilation.tap(compilation::new(self));
ctx.compilation_hooks.additional_tree_runtime_requirements.tap(...);
ctx.compilation_hooks.chunk_hash.tap(chunk_hash::new(self));
ctx.compilation_hooks.content_hash.tap(content_hash::new(self));
ctx.compilation_hooks.render_manifest.tap(render_manifest::new(self));

这几行把 JS 插件的源码角色串起来了:

text 复制代码
compilation:注册 JS dependency factory / template
additional_tree_runtime_requirements:补充 JS runtime 能力
chunk_hash / content_hash:参与 JS 内容 hash
render_manifest:最终生成 JS asset

所以 render_manifest 不是孤立存在的最后一步。它依赖前面 Hook 已经把 JS 语义、依赖模板、runtime、hash 都接入了 Compilation。

render_manifest:先判断这个 Chunk 是否需要 JS 文件

一个 Chunk 不一定要产出 JavaScript 文件。比如某些 Chunk 可能只包含 CSS,或者只包含辅助资源。

因此 JS 插件在 render_manifest 阶段首先要判断:

text 复制代码
这个 Chunk 是否包含 JavaScript 模块?
这个 Chunk 是否包含 runtime module?
它是不是 runtime chunk?
它是不是 hot update chunk?

只有满足条件时,才会向 manifest 中追加 JS asset。

源码中这部分判断就在 render_manifest 函数开头:

rust 复制代码
let is_hot_update = matches!(chunk.kind(), ChunkKind::HotUpdate);
let is_main_chunk = chunk.groups().iter().any(|group_ukey| { ... });
let is_runtime_chunk =
  chunk.has_runtime(&compilation.build_chunk_graph_artifact.chunk_group_by_ukey);

if !is_hot_update
  && is_runtime_chunk
  && !chunk_has_runtime_or_js(...)
{
  return Ok(());
}

if !is_hot_update && !is_main_chunk && !is_runtime_chunk && !chunk_has_js(chunk_ukey, compilation) {
  return Ok(());
}

chunk_has_js 又会继续检查 SourceType::JavaScript

rust 复制代码
chunk_graph.has_chunk_module_by_source_type(
  chunk_ukey,
  SourceType::JavaScript,
  compilation.get_module_graph(),
)

这段源码把原理落到了数据结构上:Chunk 只是容器,是否输出 JS,要看这个 Chunk 里是否真的存在 JavaScript source type 的模块,或者是否携带 JS runtime module。

这一点说明 Chunk 是一个更高层的容器,它可以包含多种 source type:

text 复制代码
Chunk
  ├─ JavaScript modules
  ├─ CSS modules
  ├─ Runtime modules
  ├─ Wasm modules
  └─ auxiliary assets

最终是否生成 .js,由 JavaScript 插件决定;是否生成 .css,则由 CSS 插件决定。

文件名不是简单字符串拼接

确认要生成 JavaScript 后,插件会根据 output 配置和 Chunk 信息得到文件名模板,再把模板展开成真实文件名。

典型配置是:

js 复制代码
output: {
  filename: '[name].[contenthash].js',
  chunkFilename: '[id].[contenthash].js'
}

这里会用到:

text 复制代码
chunk name
chunk id
runtime
content hash
full hash
output public path

所以文件名生成依赖前面多个阶段的稳定结果。没有第八篇的 ID 分配,没有第十篇的 hash 计算,[contenthash] 就没有办法落地。

对应源码是:

rust 复制代码
let filename_template = get_js_chunk_filename_template(
  chunk,
  &compilation.options.output,
  &compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
);

let output_path = compilation
  .get_path_with_info(
    &filename_template,
    PathData::default()
      .chunk_hash_optional(chunk.rendered_hash(...))
      .chunk_id_optional(chunk.id().map(|id| id.as_str()))
      .chunk_name_optional(chunk.name_for_filename_template())
      .content_hash_optional(chunk.rendered_content_hash_by_source_type(
        &compilation.chunk_hashes_artifact,
        &SourceType::JavaScript,
        compilation.options.output.hash_digest_length,
      ))
      .runtime(chunk.runtime().as_str()),
    &mut asset_info,
  )
  .await?;

这里能清楚看到 [id][name][chunkhash][contenthash][runtime] 分别来自 Chunk、hash artifact 和 runtime 信息。

render_main 与 render_chunk

JS 插件渲染时通常会区分入口/runtime Chunk 和普通异步 Chunk。

可以简化成:

text 复制代码
入口或 runtime chunk
  -> render_main

普通异步 chunk
  -> render_chunk

hot update chunk
  -> hot update render

源码中的分流逻辑也在 render_manifest 里:

rust 复制代码
let source = if let Some(source) = hooks.render_chunk_content.call(...).await? {
  source.source
} else if is_hot_update {
  self.render_chunk(...).await?
} else if is_runtime_chunk {
  self.render_main(...).await?
} else {
  self.render_chunk(...).await?
};

也就是说,入口/runtime Chunk 与普通异步 Chunk 在源码层面就走向不同函数:runtime chunk 走 render_main,普通 chunk 走 render_chunk

入口 Chunk 通常承担启动应用的职责:

text 复制代码
安装模块表
  -> 安装 runtime modules
  -> 初始化 __webpack_require__
  -> 执行 entry module

异步 Chunk 则更像一个"模块安装包":

text 复制代码
告诉 runtime:
  这里有一组新的模块
  它们属于某个 chunk id
  加载完成后可以继续执行 import() 后续逻辑

所以 main.jslazy.js 的渲染模型并不相同。入口文件要启动,异步文件主要负责注册。

模块代码在这里已经不是原始源码

到 render chunk 时,模块的 source 已经经历过代码生成。

例如源码:

js 复制代码
import { add } from './math';
console.log(add(1, 2));

在模块 code generation 阶段,import dependency 会通过对应 template 改写成运行时可执行的形式。最终 render chunk 拿到的是模块生成结果,而不是原始文本。

换句话说:

text 复制代码
Parser / Dependency / Generator
  解决单个模块内部怎么变

Chunk Renderer
  解决多个模块如何组织成文件

这两个层次不能混在一起。

import() 的完整链路

用动态导入再串一下:

js 复制代码
import('./lazy').then(mod => mod.run());

它会经历:

text 复制代码
Parser
  -> 识别 DynamicImport Dependency

BuildModuleGraph
  -> lazy module 进入模块图

BuildChunkGraph
  -> lazy module 被放入异步 Chunk

CodeGeneration
  -> import() 被改写成 runtime 调用

RuntimeRequirements
  -> 添加 ensure_chunk、chunk loading 等能力

CreateHash
  -> 计算 main / lazy 的 content hash

render_manifest
  -> main 走 render_main
  -> lazy 走 render_chunk

Compilation.assets
  -> main.[hash].js
  -> lazy.[hash].js

这条线把前面多篇文章串了起来:模块图决定有什么,ChunkGraph 决定怎么分组,runtime requirements 决定需要哪些运行时代码,render manifest 决定最终文件。

Chunk render cache

Rspack 会尽量复用稳定 Chunk 的渲染结果。若模块生成结果、runtime module、hash 和输出信息都没有变化,就没有必要重新渲染整个 JS 文件。

这和前面反复提到的增量构建模式一致:

text 复制代码
有 Artifact
  -> 根据 Mutation 找 affected chunks
  -> 清理失效结果
  -> 复用其余结果

JavaScript 渲染虽然是最终阶段,但仍然受 Artifact 和 Mutation 体系管理。

Rust 角度:插件拥有格式,Core 拥有生命周期

这部分源码最值得关注的不是字符串拼接细节,而是边界划分:

text 复制代码
Core:
  管 Compilation、Graph、Hook、Artifact、Cache

JavaScript Plugin:
  管 JS 语义、JS runtime、JS chunk 输出格式

这样做的好处是,Core 不需要知道所有资源类型的渲染细节。CSS、Wasm、Asset 都可以通过类似的插件方式接入统一生命周期。

这一篇应该带走什么

  1. JavaScript 文件由 rspack_plugin_javascript 通过 render_manifest 生成;
  2. Core 不直接拼 JS bundle,而是提供 Chunk、CodeGenerationResult、Runtime 等稳定输入;
  3. 入口/runtime Chunk 和异步 Chunk 的渲染职责不同;
  4. render chunk 拿到的模块 source 已经经过 dependency template 和 code generation;
  5. JS 渲染阶段同样受 Artifact、Mutation 和缓存边界管理。

写在最后

JavaScript 插件代表 Rust 内部插件的一条线;另一条更复杂的线是 JS 生态兼容。Webpack 插件运行在 JavaScript 世界里,而 Rspack Core 运行在 Rust 世界里。下一篇我们沿 rspack_binding_api 看看 JS Hook 是如何接入 Rust Core 的。

相关推荐
柯南46681 小时前
【AI开发之Rust】第 14 课:网络请求与 JSON —— reqwest + serde
rust·编程语言
前端探险家Rick1 小时前
React Native 二级弹出面板 + 键盘适配:从踩坑到正确方案
前端
用户921080262861 小时前
MCP 是什么?用 Figma MCP 辅助还原 Vue 页面
前端
用户921080262861 小时前
用 Figma MCP 还原 Vue 页面时,我遇到的三个问题
前端
修罗王1 小时前
从 React Bits 到 Vue/Svelte Bits:重新定义前端“视觉表达层“
前端
kiros_wang1 小时前
Notification 本地通知:定时消息、点击路由跳转、桌面角标适配
前端
flash俊杰1 小时前
SQLite + sqlite-vec:100 篇文档内的私域知识库怎么做
前端
Hilaku1 小时前
为什么死磕 1px 的团队,用户体验反而更差?
前端·javascript·程序员