本篇是 Rspack 源码解析系列第三十篇。前面从模块讲到 chunk、HTML、静态复制。本文进入
rspack_plugin_devtool,看 source map 如何从已有产物里提取、如何确定sources数组、如何生成.map文件或内联 data URL,又如何追加sourceMappingURL注释。
前言:source map 是「产物的影子」
source map 不改变产物内容,只提供「生成代码 -> 源码」的映射。它有两个形态:
text
外部文件:main.js.map + main.js 末尾 //# sourceMappingURL=main.js.map
内联:main.js 末尾 //# sourceMappingURL=data:application/json;base64,...
SourceMapDevToolPlugin 就是生成这个「影子」,并把它关联回产物。
源码入口:process_assets(DEV_TOOLING 阶段)
crates/rspack_plugin_devtool/src/source_map_dev_tool_plugin.rs:
rust
#[plugin_hook(CompilationProcessAssets for SourceMapDevToolPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_DEV_TOOLING)]
async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
// 建立 file -> chunk 映射
let mut file_to_chunk: HashMap<&str, &Chunk> = HashMap::default();
for chunk in compilation.build_chunk_graph_artifact.chunk_by_ukey.values() {
for file in chunk.files() {
file_to_chunk.insert(file, chunk);
}
for file in chunk.auxiliary_files() {
file_to_chunk.insert(file, chunk);
}
}
let compilation_assets = compilation.assets().iter()
.filter(|(_filename, asset)| asset.info.related.source_map.is_none());
let mapped_assets = self.mapped_assets_cache.use_cache(compilation_assets, |assets| {
self.map_assets(compilation, &file_to_chunk, &output_path, assets)
}).await?;
// ...
}
PROCESS_ASSETS_STAGE_DEV_TOOLING 是一个靠后的阶段,说明 source map 生成发生在其他产物基本就绪之后。
过滤:已经有关联 source map 的跳过
rust
.filter(|(_filename, asset)| asset.info.related.source_map.is_none())
asset.info.related.source_map 记录「这个 asset 是否已经有关联的 source map」。如果已经有(例如 loader 生成的),就不重复处理。
collect_tasks:从产物提取 source map
map_assets 先 collect_tasks:
rust
let tls: ThreadLocal<ObjectPool> = ThreadLocal::new();
let tasks = compilation_assets.into_par_iter()
.filter_map(|(asset_filename, asset)| {
// test/include/exclude 匹配
// ...
asset.get_source().and_then(|source| {
let object_pool = tls.get_or(ObjectPool::default);
let source_map = source.map(object_pool, &map_options)?;
// 从 source_map.sources() 提取 SourceReference
// ...
})
})
.collect::<Result<Vec<_>>>()?;
这里用 rayon 的 into_par_iter 并行处理所有 assets,ThreadLocal<ObjectPool> 让每个线程有独立的对象池,避免锁竞争。
SourceReference:区分模块引用和源码引用
提取出的每个 source 名要判断是「模块」还是「普通源码」:
rust
let source_references = source_map.sources().iter().map(|source_name| {
if let Some(stripped) = source_name.strip_prefix("webpack://") {
let source_name = make_paths_absolute(compilation.options.context.as_str(), stripped);
let identifier = ModuleIdentifier::from(source_name.as_str());
match compilation.get_module_graph().module_by_identifier(&identifier) {
Some(module) => SourceReference::Module(module.identifier()),
None => SourceReference::Source(Arc::from(source_name)),
}
} else {
SourceReference::Source(Arc::from(source_name.clone()))
}
}).collect::<Vec<_>>();
webpack:// 前缀是模块的命名空间,能反查回模块,用于后续的 module_filename_template 计算。非 webpack:// 的(如 node_modules 里的第三方源码)是普通 Source。
finalize_source_maps:确定 sources 数组
finalize_source_maps 负责把每个 source reference 映射到最终的 source 名字。module_filename_template 默认是 webpack://[namespace]/[resourcePath]:
rust
let reference_to_source_name_mapping = match &self.module_filename_template {
ModuleFilenameTemplate::String(template) => {
// 对每个 source_reference 并行计算最终名字
}
}
这里也有 SCHEMA_SOURCE_REGEXP 的过滤:
rust
static SCHEMA_SOURCE_REGEXP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(data|https?):").expect("failed to compile SCHEMA_SOURCE_REGEXP"));
data: 或 http(s): 开头的 source 不映射成文件路径,保持原样。
map_assets:两种输出形态
map_assets 的核心分支是「有 filename 还是没 filename」:
rust
if let Some(source_map_filename_config) = &plugin.source_map_filename {
// 外部 .map 文件
// 1. 计算 source map 文件名(含 contenthash)
// 2. 计算 sourceMappingURL
// 3. 给产物追加 sourceMappingURL 注释
// 4. 生成独立的 .map 文件
} else {
// 内联 data URL
let base64 = base64::encode_to_string(source_map_json.as_bytes());
// 追加 //# sourceMappingURL=data:application/json;charset=utf-8;base64,...
}
外部 .map:contenthash 与文件名
外部 .map 文件的 contenthash 是 source map JSON 的 hash:
rust
let mut hasher = RspackHash::from(&compilation.options.output);
hasher.write(source_map_json.as_bytes());
let digest = hasher.digest(&compilation.options.output.hash_digest);
// ...
let data = PathData::default().filename(&filename)
.chunk_id_optional(...).chunk_hash_optional(...).content_hash_optional(Some(digest.encoded()));
let source_map_filename = compilation.get_asset_path(source_map_filename_config, data).await?;
sourceMappingURL 注释
默认追加的注释是:
rust
Some(SourceMappingUrlComment::String("\n//# sourceMappingURL=[url]".to_string()))
CSS 需要特殊处理:// 注释在 CSS 里不合法,要换成 /* */:
rust
let css_extension_detected = CSS_EXTENSION_DETECT_REGEXP.is_match(&asset_filename);
let current_source_mapping_url_comment = match &plugin.source_mapping_url_comment {
Some(SourceMappingUrlComment::String(s)) => {
let s = if css_extension_detected {
URL_FORMATTING_REGEXP.replace_all(s, "\n/*$1*/")
} else { Cow::from(s) };
Some(SourceMappingUrlCommentRef::String(s))
}
// ...
};
CSS_EXTENSION_DETECT_REGEXP 是 \.css($|\?),URL_FORMATTING_REGEXP 是 ^\n\/\/(.*)$。CSS 的 //# sourceMappingURL=... 会变成 /*# sourceMappingURL=... */。
计算相对 URL
外部 .map 的 URL 需要相对路径:
rust
let source_map_url = if let Some(public_path) = &plugin.public_path {
format!("{public_path}{source_map_filename}")
} else {
// 计算 .map 相对当前产物文件的路径
source_map_path.relative(file_path.parent().unwrap()).to_string_lossy().to_string()
};
然后用 [url] 占位符替换:
rust
let current_source_mapping_url_comment = current_source_mapping_url_comment
.cow_replace("[url]", &source_map_url)
.into_owned();
debug_ids
debug_ids 选项会生成 //# debugId=...:
rust
let debug_id = plugin.debug_ids.then(|| {
let debug_id = generate_debug_id(&asset_filename, &source.buffer());
source_map.set_debug_id(Some(debug_id.clone()));
debug_id
});
// ...
let debug_id_comment = debug_id.map(|id| format!("\n//# debugId={id}")).unwrap_or_default();
debug_id 用于把产物和它的 source map 关联起来,是调试工具(如 Chrome DevTools)识别 source map 的一种方式。
关联回 asset
生成 .map 后,要把它记录到产物和 chunk:
rust
if let Some((source_map_filename, source_map_asset)) = source_map {
compilation.emit_asset(source_map_filename.clone(), source_map_asset);
// 加到 chunk 的 auxiliary_files
chunk.add_auxiliary_file(source_map_filename.clone());
}
source map 是 chunk 的 auxiliary file------和第 24 篇 asset 文件、第 26 篇 CSS 的归类一致。
mapped_assets_cache
MappedAssetsCache 用于增量缓存:
rust
self.mapped_assets_cache.use_cache(compilation_assets, |assets| {
self.map_assets(compilation, &file_to_chunk, &output_path, assets)
}).await?
只有 assets 变化时才重新生成 source map,避免每次构建都重复计算------这是增量构建性能的关键。
Rust 角度:并行提取 + 统一关联
SourceMapDevToolPlugin 的两个阶段分工清晰:
text
collect_tasks(并行):从每个产物提取已有 source map
finalize_source_maps(并行):计算 sources 数组
map_assets(并行):生成 .map 或内联,追加注释
全流程用 rayon 的 into_par_iter 和自定义的 rspack_futures::scope 并发,因为不同产物之间互不依赖。ThreadLocal<ObjectPool> 避免了共享对象池的锁竞争。
这一篇应该带走什么
- source map 在
PROCESS_ASSETS_STAGE_DEV_TOOLING阶段生成; asset.info.related.source_map记录关联,已关联的跳过;webpack://前缀可反查模块,用于module_filename_template;- 外部
.map的 contenthash 是 JSON hash,内联用 base64 data URL; - CSS 的
sourceMappingURL注释要换成/* */。
写在最后
source map 是「产物 -> 源码」的映射。接下来回到「模块替换」这个更早期的阶段------Hot Module Replacement。它通过比对两次构建的 module hash,生成只含「变化模块」的 hot update 文件。下一篇进入 rspack_plugin_hmr,看 HMR 如何做增量更新。