本篇是 Rspack 源码解析系列第二十八篇。前面的插件都在「模块」和「chunk」体系里工作。本文进入
rspack_plugin_html,看一个 HTML 模板如何在process_assets阶段被解析、注入<script>/<link>标签,最终 emit 成产物。
前言:HTML 插件工作在产物层
HtmlRspackPlugin 不参与模块图、不参与 chunk 图。它做的是「最后一公里」:
text
模板 HTML(可能含 <%= %> 占位)
-> 收集 entry 的 JS/CSS 资产
-> 生成 <script>/<link> 标签
-> 用 swc_html 解析并注入到 <head>/<body>
-> emit 最终 HTML
整个过程发生在 process_assets 阶段,此时 chunk 和 assets 已经基本确定。
源码入口:process_assets
crates/rspack_plugin_html/src/plugin.rs 的 process_assets:
rust
#[plugin_hook(CompilationProcessAssets for HtmlRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE)]
async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
let config: &HtmlRspackPluginOptions = &self.config;
let hooks = HtmlRspackPlugin::get_compilation_hooks(compilation.id());
for filename in &config.filename {
let filename = filename.cow_replace("[templatehash]", "[contenthash]");
// 绝对路径转相对
// ...
let (template_file_name, html) = match generate_html(...).await {
Ok(content) => { ...; (content.0, content.1) }
Err(err) => {
compilation.push_diagnostic(Diagnostic::from(err));
("error.html".to_string(), create_error_html(&error_msg))
}
};
// ...
compilation.emit_asset(html_asset.0.clone(), html_asset.1);
}
Ok(())
}
错误处理很有意思:模板渲染失败不会 panic,而是生成一个 error.html 显示错误信息,并把错误推入 diagnostics。这比让构建直接崩溃体验好得多。
generate_html:hooks 贯穿始终
generate_html 是一串 hook 调用链:
rust
async fn generate_html(...) -> Result<(String, String, Vec<PathBuf>)> {
let public_path = config.get_public_path(compilation, filename).await;
let mut template = HtmlTemplate::new(config, compilation).await?;
// ...
let assets_info = HtmlPluginAssets::create_assets(...).await?;
// 1. 生成前的 hook
let before_generation_data = hooks.borrow()
.before_asset_tag_generation
.call(BeforeAssetTagGenerationData { ... }).await?;
// 2. 从 assets 生成标签
let asset_tags: HtmlPluginAssetTags = HtmlPluginAssetTags::from_assets(config, &before_generation_data.assets);
// 3. 标签生成后的 hook
let alter_asset_tags_data = hooks.borrow()
.alter_asset_tags
.call(AlterAssetTagsData { ... }).await?;
// 4. 分组 head/body
let (head_tags, body_tags) = HtmlPluginAssetTags::to_groups(config, alter_asset_tags_data.asset_tags);
// 5. 分组后的 hook
let alter_asset_tag_groups_data = hooks.borrow()
.alter_asset_tag_groups
.call(AlterAssetTagGroupsData { ... }).await?;
// 6. 渲染模板
template.create_parameters(...).await?;
let template_execution_result = template.render(config).await?;
// 7. 模板执行后的 hook
let after_template_execution_data = hooks.borrow()
.after_template_execution
.call(AfterTemplateExecutionData { ... }).await?;
// 8. 解析 + 注入
let parser = HtmlCompiler::new(config);
let ast_with_diagnostic = parser.parse_file(&template.url, after_template_execution_data.html)?;
// ...
let raw_html = parser.codegen(&mut current_ast, compilation)?;
let html = raw_html.cow_replace("$$RSPACK_URL_AMP$$", "&");
Ok((...))
}
5 个 hook(before_asset_tag_generation、alter_asset_tags、alter_asset_tag_groups、after_template_execution、before_emit、after_emit)给了插件作者完整的拦截点。
create_assets:从 entry 收集资源
crates/rspack_plugin_html/src/asset.rs 的 create_assets 负责「哪些 chunk 的哪些文件要进 HTML」:
rust
let sorted_entry_names: Vec<&String> = if matches!(config.chunks_sort_mode, HtmlChunkSortMode::Manual)
&& let Some(chunks) = &config.chunks {
// manual 模式:按配置顺序
chunks.iter().filter(|&name| compilation.build_chunk_graph_artifact.entrypoints.contains_key(name)).collect()
} else {
// auto 模式:遍历 entrypoints
compilation.build_chunk_graph_artifact.entrypoints.keys().filter(|&entry_name| {
let mut included = true;
if let Some(included_chunks) = &config.chunks {
included = included_chunks.iter().any(|c| c.eq(entry_name));
}
if let Some(exclude_chunks) = &config.exclude_chunks {
included = included && !exclude_chunks.iter().any(|c| c.eq(entry_name));
}
included
}).collect()
};
然后按扩展名分流 JS 和 CSS:
rust
for (asset_name, asset) in included_assets {
if let Some(extension) = Path::new(asset_name.split("?").next().unwrap_or_default()).extension() {
let mut asset_uri = format!("{}{}", assets.public_path, url_encode_path(&asset_name));
// ...
if extension.eq_ignore_ascii_case("css") {
assets.css.push(final_path.to_string());
} else if extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") {
assets.js.push(final_path.to_string());
}
}
}
过滤 HMR 和 development 资产
有个关键过滤:
rust
if asset.info.hot_module_replacement.unwrap_or(false)
|| asset.info.development.unwrap_or(false)
{
None
} else {
Some((asset_name.clone(), asset))
}
HMR 的 update 文件和 dev-only 的 source map 不进 HTML------它们由 runtime 动态加载,不需要静态标签。
from_assets:生成标签
HtmlPluginAssetTags::from_assets 把 URL 列表变成标签:
rust
// script 标签(可并行)
asset_tags.scripts.extend(
assets.js.par_iter()
.map(|x| HtmlPluginTag::create_script(x.as_str(), &config.script_loading))
.collect::<Vec<_>>(),
);
// style 标签(可并行)
asset_tags.styles.extend(
assets.css.par_iter()
.map(|x| HtmlPluginTag::create_style(x.as_str()))
.collect::<Vec<_>>(),
);
// meta/base/title/favicon 标签
// ...
注意这里用 rayon 的 par_iter 并行生成标签------虽然单个标签很轻,但大项目可能有几百个资源,并行有收益。
to_groups:head 还是 body
to_groups 根据 inject 配置决定标签放哪:
rust
for tag in &asset_tags.scripts {
match config.inject {
HtmlInject::Head => head_tags.push(tag.to_owned()),
HtmlInject::Body => body_tags.push(tag.to_owned()),
HtmlInject::False => {
if matches!(config.script_loading, HtmlScriptLoading::Blocking) {
body_tags.push(tag.to_owned());
} else {
head_tags.push(tag.to_owned());
}
}
}
}
inject: false 时也有一套默认逻辑:blocking 脚本放 body,非 blocking(defer/async)放 head。
AssetInjector:swc_html 的 visit_mut
crates/rspack_plugin_html/src/injector.rs 用 swc_html 的 VisitMut 遍历 AST,把标签插进 <head>/<body>:
rust
impl VisitMut for AssetInjector<'_> {
fn visit_mut_element(&mut self, n: &mut Element) {
match &*n.tag_name {
"head" => {
for tag in head_tags.iter() {
// title 特殊处理:替换已有 <title> 的内容
if tag.tag_name == "title" && let Some(...) = n.children.iter_mut().find(...) {
title_ele.children = vec![Child::Text(Text { ... data: Atom::from(tag.inner_html...) ... })];
continue;
}
n.children.push(Child::Element(Element::from(tag.to_owned())));
}
}
"body" => {
for tag in body_tags.iter() {
n.children.push(Child::Element(Element::from(tag.to_owned())));
}
}
_ => {}
}
n.visit_mut_children_with(self);
}
}
<title> 是特殊处理的:如果模板里已有 <title>,就替换其内容;否则追加一个新的。其他标签直接 push 到 children 末尾。
URL 编码与占位符
生成 asset URL 时有几个细节。url_encode_path 只编码路径部分,不编码 query:
rust
fn url_encode_path(file_path: &str) -> String {
let query_string_start = file_path.find('?');
let url_path = &file_path[..query_string_start.unwrap_or(file_path.len())];
let query_string = ...;
format!("{}{}", url_path.split('/').map(|p| urlencoding::encode(p)).join("/"),
query_string.cow_replace("&", "$$RSPACK_URL_AMP$$"))
}
& 被替换成 $$RSPACK_URL_AMP$$,因为 swc_html 的 outerHTML 会把 & 转义成 &。在最终 codegen 后,generate_html 末尾统一还原:
rust
let html = raw_html.cow_replace("$$RSPACK_URL_AMP$$", "&");
create_html_asset:计算 contenthash
create_html_asset 给 HTML 本身算 contenthash,用于 [contenthash] 文件名模板:
rust
let mut hasher = RspackHash::from(&compilation.options.output);
hasher.write(html.as_bytes());
let hash_digest = hasher.digest(&compilation.options.output.hash_digest);
let content_hash = hash_digest.encoded();
let output_path = compilation.get_path_with_info(
output_file_name,
PathData::default().filename(template_file_name).content_hash(content_hash),
&mut asset_info,
).await?;
跨平台路径处理
generate_posix_path 处理 Windows:
rust
pub fn generate_posix_path(path: &str) -> Cow<'_, str> {
if env::consts::OS == "windows" {
path.cow_replace(&['/', '\\'] as &[char], "/")
} else {
path.into()
}
}
Windows 的路径分隔符 \ 在 HTML 的 URL 里要统一成 /。
Rust 角度:用 swc_html 而不是正则
HtmlRspackPlugin 最值得注意的设计是:注入标签用 swc_html 的 AST 遍历,而不是字符串拼接或正则替换。
正则处理 HTML 极其脆弱(注释、属性、嵌套结构都可能误伤),而 swc_html 的 VisitMut 能精确定位 <head>/<body> 元素,安全地插入子节点。这是「用真正的解析器处理结构化文本」的正确示范。
这一篇应该带走什么
- HtmlRspackPlugin 工作在
process_assets阶段,不参与模块图; create_assets从 entry 收集 JS/CSS,过滤 HMR 和 dev 资产;- 5 个 hook 提供完整拦截点,标签按 inject 配置分组 head/body;
AssetInjector用 swc_html 的VisitMut注入,<title>特殊处理;&用占位符规避转义,Windows 路径统一成/。
写在最后
HTML 插件是「产物层」的典型。接下来看另一个「纯文件操作」的插件------CopyRspackPlugin。它连 chunk 都不碰,直接在 process_assets 阶段把文件从 from 复制到 to。下一篇进入 rspack_plugin_copy,看静态资源复制与 glob 匹配。