Rspack 源码解析(二十八):HtmlRspackPlugin 如何把模板变成带资源标签的最终产物

本篇是 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 会把 & 转义成 &amp;。在最终 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> 元素,安全地插入子节点。这是「用真正的解析器处理结构化文本」的正确示范。

这一篇应该带走什么

  1. HtmlRspackPlugin 工作在 process_assets 阶段,不参与模块图;
  2. create_assets 从 entry 收集 JS/CSS,过滤 HMR 和 dev 资产;
  3. 5 个 hook 提供完整拦截点,标签按 inject 配置分组 head/body;
  4. AssetInjector 用 swc_html 的 VisitMut 注入,<title> 特殊处理;
  5. & 用占位符规避转义,Windows 路径统一成 /。

写在最后

HTML 插件是「产物层」的典型。接下来看另一个「纯文件操作」的插件------CopyRspackPlugin。它连 chunk 都不碰,直接在 process_assets 阶段把文件从 from 复制到 to。下一篇进入 rspack_plugin_copy,看静态资源复制与 glob 匹配。

相关推荐
马可家的菠萝1 小时前
个人知识库怎么建?从一个正在学的技术主题开始
前端·笔记·知识管理·个人知识库
几何心凉1 小时前
端到端具身交互智能正式上线:让 AI 通过屏幕和机器人,真正走进现实世界
前端·人工智能·实时互动·机器人·交互
tryCbest2 小时前
Vue3——第 6 篇:事件处理与表单绑定——v-on 与 v-model
前端·javascript·vue.js
恋猫de小郭2 小时前
Flutter 的文字为什么比 SwiftUI 更细?问题主要在 Skia 的 256 项查找表里
android·前端·flutter
lexiangqicheng2 小时前
前端高阶面试核心通关指南:深入解析Vue3与React18底层原理及性能优化
前端·面试·性能优化
怕浪猫2 小时前
前端对接聊天 API:我用 React 做了一个比 ChatGPT 还好看的界面
前端·后端·面试
程序员小孔.2 小时前
基于 Spring Cloud + Vue 的 JEEWMS 开源仓储管理平台设计与实现
前端·javascript·vue.js·spring boot·spring cloud·开源
IT_陈寒2 小时前
深入理解Python:核心原理与最佳实践
前端·人工智能·后端
我喜欢就喜欢2 小时前
Chrome 插件开发实战指南
前端·chrome