Rspack 源码解析(二十五):CssParserAndGenerator 如何把 CSS 变成可 Tree Shaking 的模块

本篇是 Rspack 源码解析系列第二十五篇。第二十四篇末尾说 CSS「有自己的词法分析、依赖类型、顺序冲突检测」。本文进入 rspack_plugin_css 的 parser_and_generator,看一段 CSS 文本如何被 css_module_lexer 切成依赖,又如何按 used exports 裁剪 class,生成 JS 与 CSS 两份代码。

前言:CSS 是「双 source type」模块

CSS 模块与前面 JSON/asset 的最大区别是:它同时服务于两个消费方。

text 复制代码
import styles from './a.css';
import './b.css';
  • b.css 只需要 SourceType::Css:把样式拼进最终的 .css 文件;
  • a.css(CSS Modules)还需要 SourceType::JavaScript:导出 { class: "hashed" } 给 JS 用。

所以 CssParserAndGenerator 的 source_types 是动态的,这正是它复杂度的根源。

源码入口:CssParserAndGenerator 的字段

crates/rspack_plugin_css/src/parser_and_generator/mod.rs 定义了核心结构:

rust 复制代码
#[cacheable]
#[derive(Debug)]
pub struct CssParserAndGenerator {
  pub convention: Option<CssExportsConvention>,
  pub local_ident_name: Option<LocalIdentName>,
  pub exports_only: bool,
  pub named_exports: bool,
  pub es_module: bool,
  pub url: bool,
  pub resolve_import: CssParserImport,
  pub exports: Option<CssExports>,
  pub local_names: Option<FxHashMap<String, String>>,
  pub hot: bool,
}

两个字段值得注意:

  • exports:CssExports = IndexMap<String, IndexSet<CssExport>>,记录 class 名到导出标识的映射;
  • local_names:FxHashMap<String, String>,记录原始 class 名到 hash 后 local ident 的映射。

这两个结构是 CSS Modules 的「名字表」,parse 时填充,generate 时查询。

source_types:谁引用我,决定我生成什么

rust 复制代码
fn source_types(&self, module: &dyn Module, module_graph: &ModuleGraph) -> &[SourceType] {
  if self.exports_only {
    return CSS_MODULE_EXPORTS_ONLY_SOURCE_TYPE_LIST;  // [JavaScript]
  }

  let no_need_js = module_graph
    .get_incoming_connections(&module.identifier())
    .all(|conn| {
      let dep = module_graph.dependency_by_id(&conn.dependency_id);
      matches!(
        dep.dependency_type(),
        DependencyType::CssImport | DependencyType::EsmImport
      )
    });

  if no_need_js {
    CSS_MODULE_SOURCE_TYPE_LIST            // [Css]
  } else {
    CSS_MODULE_AND_JS_SOURCE_TYPE_LIST     // [Css, JavaScript]
  }
}

逻辑清晰:

  • 如果只是被 @import 或 ESM import 引用,且不消费 class 名,就只需要 Css;
  • 否则需要 Css + JavaScript 两份。

这和第 24 篇 asset 的 source_types 一脉相承:source type 由消费方决定。

parse:css_module_lexer 分词

CSS 没有像 JS 那样的完整 AST,而是用 css_module_lexer::collect_dependencies 直接扫出依赖:

rust 复制代码
let (deps, warnings) = css_module_lexer::collect_dependencies(&source_code, mode);
for dependency in deps {
  match dependency {
    css_module_lexer::Dependency::Url { request, range, kind } => { ... }
    css_module_lexer::Dependency::Import { request, range, media, supports, layer } => { ... }
    css_module_lexer::Dependency::Replace { content, range } => { ... }
    css_module_lexer::Dependency::LocalClass { name, range, .. } => { ... }
    css_module_lexer::Dependency::LocalKeyframes { name, range, .. } => { ... }
    css_module_lexer::Dependency::ICSSExportValue { prop, value } => { ... }
    _ => {}
  }
}

每种 Dependency 都转成对应的 Dependency trait 实现:

  • Url -> CssUrlDependency(交给 asset 插件处理 url())
  • Import -> CssImportDependency(交给 NormalModuleFactory 处理 @import)
  • LocalClass -> CssLocalIdentDependency(CSS Modules 的 class hash)
  • ICSSExportValue -> CssExportDependency(:export 语法)

LocalClass:class 名如何被 hash

CSS Modules 的核心是 .foo 变成 ._foo_abc123。看 LocalClass 分支:

rust 复制代码
css_module_lexer::Dependency::LocalClass { name, range, .. } => {
  let (_prefix, name) = name.split_at(1); // 去掉 '.' 或 '#'
  let name = unescape(name);

  let local_ident = LocalIdentOptions::new(
    resource_data,
    self.local_ident_name.as_ref().expect("..."),
    compiler_options,
  )
  .get_local_ident(&name)
  .await?;

  let convention = self.convention.as_ref().expect("...");
  let exports = self.exports.get_or_insert_default();
  let convention_names = export_locals_convention(&name, convention);
  for convention_name in convention_names.iter() {
    update_css_exports(
      exports,
      convention_name.to_owned(),
      CssExport {
        ident: local_ident.clone(),
        orig_name: name.clone().into_owned(),
        from: None,
        id: None,
      },
    );
  }

  let local_names = self.local_names.get_or_insert_default();
  local_names.insert(name.into_owned(), local_ident.clone());

  dependencies.push(Box::new(CssLocalIdentDependency::new(
    local_ident,
    convention_names,
    range.start + 1,
    range.end,
  )));
}

三个动作:

  1. get_local_ident 根据 local_ident_name 模板(如 [hash:base64:5])算出 hash 后的标识;
  2. export_locals_convention 按 exportsConvention(camelCase、dashes 等)生成导出名;
  3. 同时填 exports(导出表)和 local_names(名字映射),并生成 CssLocalIdentDependency 供模板替换。

update_css_exports 用 IndexSet 去重:

rust 复制代码
fn update_css_exports(exports: &mut CssExports, name: String, css_export: CssExport) -> bool {
  if let Some(existing) = exports.get_mut(&name) {
    existing.insert(css_export)
  } else {
    exports.insert(name, IndexSet::from_iter([css_export])).is_none()
  }
}

generate 的 CSS 分支:依赖模板替换

generate 按 requested_source_type 分流。SourceType::Css 分支先建一个 ReplaceSource,然后遍历依赖执行模板替换:

rust 复制代码
let mut source = ReplaceSource::new(source.clone());
// ...
module.get_dependencies().iter().for_each(|id| {
  let dep = module_graph.dependency_by_id(id);
  if let Some(dependency) = dep.as_dependency_code_generation() {
    if let Some(template) = dependency.dependency_template()
      .and_then(|template_type| compilation.get_dependency_template(template_type))
    {
      template.render(dependency, &mut source, &mut context)
    }
  }
});

例如 CssImportDependencyTemplate::render 会把 @import "..." 整段替换成空字符串:

rust 复制代码
impl DependencyTemplate for CssImportDependencyTemplate {
  fn render(&self, dep, source, _context) {
    let dep = dep.as_any().downcast_ref::<CssImportDependency>().expect("...");
    source.replace(dep.range.start, dep.range.end, "", None);
  }
}

为什么删掉?因为 @import 的内容已经变成模块图里的另一个 CSS 模块,会在 chunk 渲染时按顺序拼接回来。

generate 的 JS 分支:按 used exports 裁剪

SourceType::JavaScript 分支的核心是 get_used_exports:

rust 复制代码
fn get_used_exports<'a>(
  exports: &'a CssExports,
  identifier: ModuleIdentifier,
  runtime: Option<&RuntimeSpec>,
  exports_info_artifact: &ExportsInfoArtifact,
) -> IndexMap<&'a str, &'a IndexSet<CssExport>> {
  let exports_info = exports_info_artifact
    .get_prefetched_exports_info_optional(&identifier, PrefetchExportsInfoMode::Default);

  exports.iter().filter(|(name, _)| {
    let export_info = exports_info.as_ref()
      .map(|info| info.get_read_only_export_info(&Atom::from(name.as_str())));
    if let Some(export_info) = export_info {
      export_info.get_used(runtime) != UsageState::Unused
    } else {
      true
    }
  })
  .map(|(name, exports)| (name.as_str(), exports))
  .collect()
}

只保留 UsageState != Unused 的导出。如果某个 class 从没被 import { foo } from './a.css' 使用,它的导出就不会进生成的 JS 对象。这是 CSS Modules 的 tree shaking。

get_unused_local_ident:连 CSS 里的选择器都能删

更有意思的是 get_unused_local_ident:

rust 复制代码
fn get_unused_local_ident(...) -> CodeGenerationDataUnusedLocalIdent {
  let exports_names = exports.iter().fold(..., |mut map, (name, css_exports)| {
    css_exports.iter().for_each(|css_export| {
      // orig_name -> 所有导出名
    });
    map
  });

  // 找出所有导出名都是 Unused 的 orig_name
  // 返回它们对应的 local ident
}

它找出的 idents 会放进 CodeGenerationDataUnusedLocalIdent,之后在 CSS chunk 渲染阶段(见第 26 篇 get_chunk_unused_local_idents)用于删除整条未使用的 CSS 规则。

es_module 与 namespace object

生成 JS 对象时还要处理 ESM 语义:

rust 复制代码
let (ns_obj, left, right) = if self.es_module
  && exports_info.other_exports_info().get_used(generate_context.runtime) != UsageState::Unused
{
  (
    generate_context.runtime_template
      .render_runtime_globals(&RuntimeGlobals::MAKE_NAMESPACE_OBJECT),
    "(".to_string(),
    ")".to_string(),
  )
} else {
  (String::new(), String::new(), String::new())
};

如果开启了 esModule 且存在具名导出之外的访问,就包一层 __webpack_require__.makeNamespaceObject(...),保证 ESM 命名空间语义。

Rust 角度:用 IndexMap/IndexSet 保证确定性

CSS 导出顺序会影响生成的 JS 对象和 CSS 规则的顺序,进而影响 hash。所以 CssExports 用 IndexMap/IndexSet(indexmap crate)而不是 HashMap/HashSet:

rust 复制代码
pub type CssExports = IndexMap<String, IndexSet<CssExport>>;

这保证了插入顺序可预测,多次构建输出稳定------这是第 8 篇「ids 与确定性」原则在 CSS 侧的体现。

这一篇应该带走什么

  1. CSS 是双 source type 模块,source_types 由消费方决定;
  2. parse 用 css_module_lexer 切出 Url/Import/LocalClass/ICSS 等依赖;
  3. LocalClass 同时填 exports 和 local_names 两张表,并生成 local ident;
  4. generate 的 JS 分支用 get_used_exports 按 used exports 裁剪导出;
  5. IndexMap/IndexSet 保证顺序确定性。

写在最后

本文讲的是单个 CSS 模块的 parse/generate。但 CSS 最难的其实是「多个 CSS 模块之间的顺序」------@import 的顺序、@media/@supports/@layer 的包裹,以及顺序冲突检测。下一篇进入 CssPlugin 和 rspack_plugin_css_chunking,看 CSS 如何被排成正确的顺序并切成独立的 css chunk。

相关推荐
Kainy1 小时前
Rust重写Photoshop开源项目PhotoCraft:无头命令行修图与AI Agent自动化实战
人工智能·rust·photoshop
I'm Jie1 小时前
Rhost v2.0.0 版本发布!!!不止 SSH 终端,内置 AI Agent 支持自然语言操作远程服务器
运维·服务器·rust·开源·ssh·tauri·finalshell
wespaul1 小时前
VeapAI 自然语言操作实战|第 06 篇:AiConsole 前端运行时——一个 Hub,八个注册表
前端·人工智能·milvus
龙亘川2 小时前
健康服务规范化背后,数字化如何 “辅助赋能“
大数据·前端·人工智能·智慧城市·数据可视化
data analyse 4562 小时前
SourceMap 上传版本与报错版本对不上?版本校验与错误还原设计
大数据·前端·人工智能·数据分析
前端双越老师2 小时前
程序员 1v1 访谈系列第三期:先不要想在工作中有突破
前端·ai编程·全栈
小羊没烦恼!2 小时前
浅谈SQL Server中的事务日志(二)----事务日志在修改数据时的角色
前端·数据库·后端·python·web安全·oracle
myaifas2 小时前
新人的第一个任务该怎么选
前端·产品经理
怕浪猫2 小时前
前端转全栈最低成本的技术栈选择
前端·javascript·面试