本篇是 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(¤t_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 机制的价值。
这一篇应该带走什么
- HMR 在
process_assets阶段比对 records 和 compilation 的 hash; - runtime module 和普通 module 的 hash 变化都会被检测;
HotUpdateChunk是临时 chunk,render 后手动清理(与 webpack 的 GC 策略不同);- manifest 用
{c, r, m}三个数组描述更新/移除; - hot update 文件强制每次 emit(version 重置),多 runtime 需要
[runtime]占位符。
写在最后
HMR 是「增量更新」的服务端实现。接下来进入「运行时」的核心------chunk 加载。rspack_plugin_runtime 里的 JsonpChunkLoading、CommonJsChunkLoading、ModuleChunkLoading 决定了产物如何按需加载。下一篇进入 rspack_plugin_runtime,看 chunk loading 的运行时抽象。