Rspack 源码解析(三十一):HotModuleReplacementPlugin 如何用 hash 比对生成增量更新

本篇是 Rspack 源码解析系列第三十一篇。第十一篇讲增量构建时提到 records 与热更新。本文进入 rspack_plugin_hmr,看 HMR 如何通过比对两次构建的 module hash 与 runtime module hash,找出「变化的模块」,生成 hot update chunk 和 manifest。

前言:HMR 的核心是「找出变化」

全量构建会把所有产物重新生成。HMR 只想回答一个问题:哪些模块变了?

text 复制代码
旧构建的 module hash 表(records)
  对比
新构建的 module hash 表(compilation)
  = 变化的模块集合

然后只为这些模块生成 hot update 文件,客户端拿到后热替换。本文看服务端(Rust 侧)如何做这个对比。

源码入口:process_assets 里的 hash 对比

crates/rspack_plugin_hmr/src/lib.rs 的 process_assets:

rust 复制代码
#[plugin_hook(CompilationProcessAssets for HotModuleReplacementPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_ADDITIONAL)]
async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
  let Some(CompilationRecords {
    chunks: old_chunks,
    runtimes: all_old_runtime,
    modules: old_all_modules,
    runtime_modules: old_runtime_modules,
    hash: old_hash,
  }) = compilation.records.take()
  else {
    return Ok(());
  };

  // hash 没变,直接跳过
  if let Some(old_hash) = &old_hash
    && let Some(hash) = &compilation.hash
    && old_hash == hash
  {
    return Ok(());
  }
  // ...
}

compilation.records 是上一次构建留下的快照(第 11 篇的 records 机制)。如果整体 hash 没变,说明什么都没变,直接返回。

找出变化的 runtime module

先比对 runtime module 的 hash:

rust 复制代码
let mut updated_runtime_modules: IdentifierSet = Default::default();
for (identifier, old_runtime_module_hash) in &old_runtime_modules {
  if let Some(new_runtime_module_hash) = compilation.runtime_modules_hash.get(identifier) {
    if new_runtime_module_hash != old_runtime_module_hash {
      updated_runtime_modules.insert(*identifier);  // updated
    }
  }
}
for identifier in compilation.runtime_modules.keys() {
  if !old_runtime_modules.contains_key(identifier) {
    updated_runtime_modules.insert(*identifier);  // added
  }
}

runtime module 变化也要进 hot update,因为 runtime 代码(如 chunk 加载逻辑)也可能因为配置或依赖变化而改变。

找出完全移除的模块

rust 复制代码
let all_module_ids: HashMap<ModuleId, ModuleIdentifier> = compilation
  .module_ids_artifact.iter().map(|(k, v)| (v.clone(), *k)).collect();
let mut completely_removed_modules: HashSet<ModuleId> = Default::default();

for (chunk_id, (old_runtime, old_module_ids)) in &old_chunks {
  let mut remaining_modules: HashSet<ModuleId> = Default::default();
  for old_module_id in old_module_ids {
    if !all_module_ids.contains_key(old_module_id) {
      completely_removed_modules.insert(old_module_id.clone());
    } else {
      remaining_modules.insert(old_module_id.clone());
    }
  }
  // ...
}

all_module_ids 是「新构建的 module id -> identifier」映射。旧 chunk 里有、但新构建里没有的 module id,就是被删除的模块。

找出每个 chunk 里的变化模块

对每个旧 chunk,找出 hash 变化的模块:

rust 复制代码
new_modules = compilation.build_chunk_graph_artifact.chunk_graph
  .get_chunk_modules_identifier(&current_chunk.ukey())
  .iter()
  .filter_map(|&module| {
    let module_id = ChunkGraph::get_module_id(&compilation.module_ids_artifact, module)?;
    let Some(old_module_hashes) = old_all_modules.get(module_id) else {
      return Some(module);  // 新增
    };
    let old_hash = old_module_hashes.get(&chunk_id);
    let new_hash = compilation.code_generation_results.get_hash(&module, Some(current_chunk.runtime()));
    if old_hash != new_hash {
      return Some(module);  // 变化
    }
    None
  })
  .collect::<Vec<_>>();

code_generation_results.get_hash 是模块在特定 runtime 下的代码生成 hash。hash 变了,说明模块内容(或其依赖)变了。

构造 HotUpdateChunk

有变化的模块就构造一个 ChunkKind::HotUpdate 的 chunk:

rust 复制代码
if !new_modules.is_empty() || !new_runtime_modules.is_empty() {
  let mut hot_update_chunk = Chunk::new(None, ChunkKind::HotUpdate);
  hot_update_chunk.set_id(chunk_id.clone());
  hot_update_chunk.set_runtime(...);
  let ukey = hot_update_chunk.ukey();
  // 复制 group
  // ...
  compilation.build_chunk_graph_artifact.chunk_by_ukey.add(hot_update_chunk);
  compilation.build_chunk_graph_artifact.chunk_graph.add_chunk(ukey);
  for module_identifier in &new_modules {
    compilation.build_chunk_graph_artifact.chunk_graph.connect_chunk_and_module(ukey, *module_identifier);
  }
  // ...
}

注意这段注释,解释了一个与 webpack 的差异:

text 复制代码
// In webpack, there is no need to add HotUpdateChunk to compilation.chunks,
// because HotUpdateChunk is no longer used after generating the manifest.
//
// However, in Rspack, we need to add HotUpdateChunk to compilation.build_chunk_graph_artifact.chunk_by_ukey
// because during the manifest generation, HotUpdateChunk is passed to various plugins via the ukey.
// ... after the manifest is generated, we need to manually remove the HotUpdateChunk.

webpack 用 WeakMap 让生命周期跟随 Chunk 自动回收;Rspack 用 Ukey 手动管理,所以要在 manifest 生成后手动清理。

render_manifest 生成 hot update 文件

HotUpdateChunk 也走 render_manifest:

rust 复制代码
compilation.plugin_driver.compilation_hooks.render_manifest
  .call(compilation, &ukey, &mut manifest, &mut diagnostics).await?;

// 手动清理 ChunkGraph 和 chunks
for module_identifier in new_modules {
  compilation.build_chunk_graph_artifact.chunk_graph.disconnect_chunk_and_module(&ukey, module_identifier);
}
// ...

render_manifest 复用普通 chunk 的渲染管线,只是 chunk kind 是 HotUpdate。

生成 hot update 文件

每个 manifest entry 生成一个文件,文件名用 hot_update_chunk_filename 模板:

rust 复制代码
let filename = if entry.has_filename {
  entry.filename.clone()
} else {
  compilation.get_path(
    &compilation.options.output.hot_update_chunk_filename,
    PathData::default()
      .chunk_id_optional(hot_update_chunk.id().map(|id| id.as_str()))
      .chunk_name_optional(hot_update_chunk.name_for_filename_template())
      .hash_optional(old_hash.as_ref().map(|hash| hash.rendered(...))),
  ).await?
};
let asset = CompilationAsset::new(
  Some(entry.source),
  entry.info.with_hot_module_replacement(Some(true)).with_version(Default::default()),
);
compilation.emit_asset(filename, asset);

with_version(Default::default()) 的注释说明了意图:

text 复制代码
// Reset version to make hmr generated assets always emit

hot update 文件必须每次都 emit(即使文件名相同),所以把 version 重置,绕过「版本未变就不 emit」的优化。

manifest 文件:告诉客户端更新了什么

最后生成 manifest,格式是 { "c": 更新的chunk, "r": 移除的chunk, "m": 移除的module }:

rust 复制代码
let manifest_content = serde_json::json!({
  "c": c,   // updated chunk ids
  "r": r,   // removed chunk ids
  "m": m,   // removed module ids
}).to_string();

compilation.emit_asset(
  filename,
  CompilationAsset::new(
    Some(RawStringSource::from(
      if compilation.options.output.module {
        format!("export default {manifest_content};")
      } else {
        manifest_content
      }
    ).boxed()),
    AssetInfo::default().with_hot_module_replacement(Some(true)),
  ),
);

如果 output 是 ESM(output.module),manifest 用 export default;否则是普通 JSON 字符串。

多 runtime 的文件名冲突检测

如果 hot_update_main_filename 没包含 [runtime],多个 runtime 会写进同一个文件:

rust 复制代码
match hot_update_main_content_by_filename.entry(filename) {
  hash_map::Entry::Occupied(mut occupied_entry) => {
    // 合并内容,并警告
    compilation.push_diagnostic(Diagnostic::warn(
      "HotModuleReplacementPlugin".to_string(),
      r#"The configured output.hotUpdateMainFilename doesn't lead to unique filenames per runtime..."#.to_string(),
    ));
  }
  // ...
}

这是个容易踩的坑:多个 entry 对应多个 runtime,如果文件名模板没区分 runtime,HMR 更新会互相覆盖。

注册依赖 factory 与 parser

HMR 还要处理 module.hot.accept 这类特殊依赖:

rust 复制代码
#[plugin_hook(CompilerCompilation for HotModuleReplacementPlugin)]
async fn compilation(&self, compilation, params) -> Result<()> {
  compilation.set_dependency_factory(DependencyType::ImportMetaHotAccept, params.normal_module_factory.clone());
  compilation.set_dependency_factory(DependencyType::ImportMetaHotDecline, params.normal_module_factory.clone());
  compilation.set_dependency_factory(DependencyType::ModuleHotAccept, params.normal_module_factory.clone());
  compilation.set_dependency_factory(DependencyType::ModuleHotDecline, params.normal_module_factory.clone());
  Ok(())
}

normal_module_factory_parser 还会给 JS/CSS parser 挂上 HMR 的 parser plugin:

rust 复制代码
if let Some(parser) = parser.downcast_mut::<JavaScriptParserAndGenerator>() {
  if module_type.is_js_auto() {
    parser.add_parser_plugin(Box::new(ModuleHotReplacementParserPlugin::new()));
    parser.add_parser_plugin(Box::new(ImportMetaHotReplacementParserPlugin::new()));
  }
  // ...
} else if matches!(module_type, ModuleType::Css | ModuleType::CssAuto | ModuleType::CssModule)
  && let Some(parser) = parser.downcast_mut::<CssParserAndGenerator>()
{
  parser.hot = true;  // CSS 也支持 HMR
}

注入 HMR runtime

additional_tree_runtime_requirements 无条件注入 HotModuleReplacementRuntimeModule:

rust 复制代码
#[plugin_hook(CompilationAdditionalTreeRuntimeRequirements for HotModuleReplacementPlugin)]
async fn additional_tree_runtime_requirements(...) {
  runtime_modules.push(HotModuleReplacementRuntimeModule::new(&compilation.runtime_template).boxed());
  Ok(())
}

这个 runtime module 包含 module.hot 对象、accept/decline 等 API 的运行时实现。

Rust 角度:hash 比对是增量的根基

HMR 的核心算法极其朴素------比对 hash:

text 复制代码
整体 hash 相同 -> 跳过
runtime module hash 不同 -> 更新 runtime
module hash 不同 -> 更新 module
module id 消失 -> 移除 module

朴素,但有效。它依赖一个前提:每个模块的 code generation 都有稳定的、能反映内容的 hash。这正是第 10 篇 hash 机制的价值。

这一篇应该带走什么

  1. HMR 在 process_assets 阶段比对 records 和 compilation 的 hash;
  2. runtime module 和普通 module 的 hash 变化都会被检测;
  3. HotUpdateChunk 是临时 chunk,render 后手动清理(与 webpack 的 GC 策略不同);
  4. manifest 用 {c, r, m} 三个数组描述更新/移除;
  5. hot update 文件强制每次 emit(version 重置),多 runtime 需要 [runtime] 占位符。

写在最后

HMR 是「增量更新」的服务端实现。接下来进入「运行时」的核心------chunk 加载。rspack_plugin_runtime 里的 JsonpChunkLoading、CommonJsChunkLoading、ModuleChunkLoading 决定了产物如何按需加载。下一篇进入 rspack_plugin_runtime,看 chunk loading 的运行时抽象。

相关推荐
尤水就下1 小时前
🔥说一说在线文档的 Block 架构是如何实现的
前端·javascript·架构
是江迪呀2 小时前
介绍一个开源项目 —— 烂笔头
前端·后端
恋猫de小郭2 小时前
Flutter 紧急发布 3.47.8 安全升级版本,建议都升级
android·前端·flutter
紧跟先前的步伐2 小时前
Go AI Agent 实战 18|Tool Calling 的基本原理与完整执行流程
前端·数据库·人工智能·ai·golang·1024程序员节
captain3762 小时前
Spring Web MVC(2)
java·前端·spring boot·后端·spring·mvc
晓得迷路了2 小时前
栗子前端技术周刊第 151 期 - ViteConf 2026、React Router 8.5、React Native 0.88...
前端·javascript·vue.js
ai工具集合2 小时前
老项目位图图标放大模糊?PNG 一键转 SVG 矢量化实操,附前端避坑指南
前端
2601_968708032 小时前
DreamsChat Web – Chat, Audio, Video Web APP with Admin Dashboard
前端·架构
小大宇2 小时前
Vue知识点8-Watch
前端·javascript·vue.js