本篇是 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,
)));
}
三个动作:
get_local_ident根据local_ident_name模板(如[hash:base64:5])算出 hash 后的标识;export_locals_convention按exportsConvention(camelCase、dashes 等)生成导出名;- 同时填
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 侧的体现。
这一篇应该带走什么
- CSS 是双 source type 模块,
source_types由消费方决定; - parse 用
css_module_lexer切出 Url/Import/LocalClass/ICSS 等依赖; LocalClass同时填exports和local_names两张表,并生成 local ident;- generate 的 JS 分支用
get_used_exports按 used exports 裁剪导出; IndexMap/IndexSet保证顺序确定性。
写在最后
本文讲的是单个 CSS 模块的 parse/generate。但 CSS 最难的其实是「多个 CSS 模块之间的顺序」------@import 的顺序、@media/@supports/@layer 的包裹,以及顺序冲突检测。下一篇进入 CssPlugin 和 rspack_plugin_css_chunking,看 CSS 如何被排成正确的顺序并切成独立的 css chunk。