Rspack 源码解析(二十六):CssPlugin 与 CssChunkingPlugin 如何保证 CSS 顺序并切分 chunk

本篇是 Rspack 源码解析系列第二十六篇。上一篇讲了单个 CSS 模块的 parse/generate。本文进入 rspack_plugin_css 的 plugin 层和 rspack_plugin_css_chunking,看多个 CSS 模块如何被排成正确顺序、如何检测顺序冲突、又如何按大小切成独立 css chunk。

前言:CSS 的顺序是语义

JS 的 import 顺序通常只影响求值时机,但 CSS 的规则顺序直接影响样式优先级。

css 复制代码
/* a.css */
.foo { color: red; }

/* b.css */
.foo { color: blue; }

如果 import './a.css'; import './b.css';,最终 .foo 是蓝色;反过来就是红色。所以 CSS 模块在 chunk 里的顺序必须严格遵循依赖顺序,这比 JS 的顺序约束严格得多。

源码入口:CssPlugin 的 render_manifest

crates/rspack_plugin_css/src/plugin/impl_plugin_for_css_plugin.rs 里的 render_manifest 是 CSS 最终产物的入口:

rust 复制代码
#[plugin_hook(CompilationRenderManifest for CssPlugin)]
async fn render_manifest(
  &self,
  compilation: &Compilation,
  chunk_ukey: &ChunkUkey,
  manifest: &mut Vec<RenderManifestEntry>,
  diagnostics: &mut Vec<Diagnostic>,
) -> Result<()> {
  // ...
  let css_import_modules = compilation.build_chunk_graph_artifact.chunk_graph
    .get_chunk_modules_by_source_type(chunk_ukey, SourceType::CssImport, module_graph);
  let css_modules = compilation.build_chunk_graph_artifact.chunk_graph
    .get_chunk_modules_by_source_type(chunk_ukey, SourceType::Css, module_graph);
  if css_import_modules.is_empty() && css_modules.is_empty() {
    return Ok(());
  }

  // 计算 css 文件名(含 contenthash)
  // 调用 render_chunk 生成最终 CSS
  let (source, more_diagnostics) = compilation
    .chunk_render_cache_artifact
    .use_cache(compilation, chunk, &SourceType::Css, || async {
      let (source, diagnostics) = self
        .render_chunk(compilation, module_graph, chunk, &output_path,
          css_import_modules, css_modules)
        .await?;
      Ok((CachedSource::new(source).boxed(), diagnostics))
    })
    .await?;

  manifest.push(RenderManifestEntry {
    source: source.boxed(),
    filename: output_path,
    has_filename: false,
    info: asset_info,
    auxiliary: false,   // 注意:CSS 不是 auxiliary,是 chunk 主体
  });
  Ok(())
}

和第 24 篇 asset 对比:asset 的 auxiliary: true,而 CSS 是 auxiliary: false。因为 CSS 是 chunk 的「第二主体」(JS + CSS 并列),不是附属文件。

get_ordered_chunk_css_modules:排序入口

render_chunk 先调用 get_ordered_chunk_css_modules:

rust 复制代码
async fn render_chunk(...) -> Result<(BoxSource, Vec<Diagnostic>)> {
  let (ordered_css_modules, conflicts) =
    Self::get_ordered_chunk_css_modules(chunk, compilation, css_import_modules, css_modules);
  let source = Self::render_chunk_to_source(compilation, chunk, &ordered_css_modules, &hooks).await?;
  // ...
}

get_ordered_chunk_css_modules 分成两类:css_import_modules(被 @import 引用的)和 css_modules(普通 CSS),分别排序后拼接:

rust 复制代码
pub(crate) fn get_ordered_chunk_css_modules<'a>(
  chunk: &Chunk,
  compilation: &Compilation,
  mut css_import_modules: Vec<&'a dyn Module>,
  mut css_modules: Vec<&'a dyn Module>,
) -> (Vec<&'a dyn Module>, Option<Vec<CssOrderConflicts>>) {
  css_import_modules.sort_unstable_by_key(|module| module.identifier());
  let (mut external_css_modules, conflicts_external) =
    Self::get_modules_in_order(chunk, css_import_modules, compilation);

  css_modules.sort_unstable_by_key(|module| module.identifier());
  let (mut css_modules, conflicts) =
    Self::get_modules_in_order(chunk, css_modules, compilation);

  external_css_modules.append(&mut css_modules);
  // ...
}

get_modules_in_order:核心排序算法

这个算法直接对齐 webpack 的 CssModulesPlugin.js(源码注释里有对应链接)。核心思路是「按 chunk group 的 post-order index 排序」。

先看 module 在 chunk group 里的顺序:

rust 复制代码
let mut modules_by_chunk_group = chunk.groups().iter()
  .map(|group| compilation.build_chunk_graph_artifact.chunk_group_by_ukey.expect_get(group))
  .map(|chunk_group| {
    let mut indexed_modules = modules_list.clone().into_iter()
      .filter_map(|module| {
        chunk_group.module_post_order_index(&module.identifier())
          .map(|index| (index, module))
      })
      .collect::<Vec<_>>();

    // post order 倒序,方便用 pop() 取
    let sorted_modules = {
      indexed_modules.sort_by_key(|item| Reverse(item.0));
      indexed_modules.into_iter().map(|item| item.1).collect::<Vec<_>>()
    };
    SortedModules { set: ..., list: sorted_modules }
  })
  .collect::<Vec<_>>();

注释解释了 post-order 的含义:

text 复制代码
// ->: import
// For A -> B -> C, the pre order is A: 0, B: 1, C: 2.
// The post order is A: 2, B: 1, C: 0.

post-order 保证依赖(被 import 的)排在引用它的模块之前------这正是 CSS 需要的顺序。

多 chunk group 的冲突检测

单个 chunk group 直接返回排序结果。多个 chunk group 时,就需要解决「同一个模块在不同 group 里顺序不同」的冲突:

rust 复制代码
loop {
  let mut failed_modules: IdentifierSet = Default::default();
  let list = modules_by_chunk_group[0].list.clone();
  if list.is_empty() { break; }
  let mut selected_module = *list.last().expect("TODO:");
  let mut has_failed = None;
  'outer: loop {
    for SortedModules { set, list } in &modules_by_chunk_group {
      if list.is_empty() { continue; }
      let last_module = *list.last().expect("TODO:");
      if last_module.identifier() == selected_module.identifier() { continue; }
      if !set.contains(&selected_module.identifier()) { continue; }
      failed_modules.insert(selected_module.identifier());
      if failed_modules.contains(&last_module.identifier()) {
        // 出现冲突,换一个候选
        has_failed = Some(last_module);
        continue;
      }
      selected_module = last_module;
      has_failed = None;
      continue 'outer;
    }
    break;
  }
  if let Some(has_failed) = has_failed {
    // 不可解的冲突,记录并告警
    let conflict = CssOrderConflicts {
      chunk: chunk.ukey(),
      failed_module: has_failed.identifier(),
      selected_module: selected_module.identifier(),
    };
    // ...
  }
  final_modules.push(selected_module);
  // 从所有 list 里移除 selected_module
  // ...
}

这个算法本质是「在多个约束之间找一条拓扑排序路径」,找不到就记录 CssOrderConflicts。

冲突如何变成诊断

冲突不会中断构建,而是变成 warning:

rust 复制代码
if let Some(conflicts) = conflicts {
  diagnostics.extend(conflicts.into_iter().map(|conflict| {
    // ...
    let mut diagnostic = Diagnostic::warn(
      "Conflicting order".into(),
      format!(
        "chunk {}\nConflicting order between {} and {}",
        chunk.name().unwrap_or(...),
        failed_module.readable_identifier(&compilation.options.context),
        selected_module.readable_identifier(&compilation.options.context)
      ),
    );
    diagnostic.file = Some(output_path.to_owned().into());
    diagnostic.chunk = Some(chunk.ukey().as_u32());
    diagnostic
  }));
}

render_chunk_to_source:包裹 @media/@supports/@layer

排序完成后,每个 CSS 模块需要根据它来自的 @import 上下文包裹:

rust 复制代码
let mut num_close_bracket = 0;
if let Some(media) = data.get::<CssMedia>() {
  num_close_bracket += 1;
  container_source.add(RawStringSource::from(format!("@media {media}{{\n")));
}
if let Some(supports) = data.get::<CssSupports>() {
  num_close_bracket += 1;
  container_source.add(RawStringSource::from(format!("@supports ({supports}) {{\n")));
}
if let Some(layer) = data.get::<CssLayer>() {
  num_close_bracket += 1;
  container_source.add(RawStringSource::from(format!(
    "@layer{} {{\n",
    if let CssLayer::Named(layer) = &layer { Cow::Owned(format!(" {layer}")) }
    else { Cow::Borrowed("") }
  )));
}

container_source.add(cur_source.clone());

for _ in 0..num_close_bracket {
  container_source.add(RawStringSource::from_static("\n}"));
}

如果 @import "./x.css" layer(foo) media(screen) supports(display:grid),那 x.css 的内容就被 @media + @supports + @layer 三层包裹。这些信息由第 25 篇的 CssImportDependency 的 media/supports/layer 字段带过来,存在 CodeGenerationData 里。

AUTO_PUBLIC_PATH_PLACEHOLDER 的替换

CSS 里的 url() 可能用了 publicPath: 'auto',生成时先写占位符,渲染时替换:

rust 复制代码
let content = source.source().into_string_lossy();
let auto_public_path_matches: Vec<_> = content
  .match_indices(AUTO_PUBLIC_PATH_PLACEHOLDER)
  .map(|(index, _)| (index, index + AUTO_PUBLIC_PATH_PLACEHOLDER.len()))
  .collect();
if !auto_public_path_matches.is_empty() {
  let mut replace = ReplaceSource::new(source);
  for (start, end) in auto_public_path_matches {
    let relative = PublicPath::render_auto_public_path(compilation, output_path);
    replace.replace(start as u32, end as u32, &relative, None);
  }
  replace.boxed()
}

和第 24 篇 asset 的 AUTO_PUBLIC_PATH_PLACEHOLDER 是同一个机制。

CssChunkingPlugin:把 CSS 拆成独立 chunk

crates/rspack_plugin_css_chunking/src/lib.rs 在 optimize_chunks 阶段(stage = 5)把 CSS 从 JS chunk 里拆出来:

rust 复制代码
#[plugin_hook(CompilationOptimizeChunks for CssChunkingPlugin, stage = 5)]
async fn optimize_chunks(&self, compilation: &mut Compilation) -> Result<Option<bool>> {
  // ...
  const MIN_CSS_CHUNK_SIZE: f64 = 30_f64 * 1024_f64;
  const MAX_CSS_CHUNK_SIZE: f64 = 100_f64 * 1024_f64;
}

默认按 30KB~100KB 拆分:小于 min 的合并,大于 max 的拆开。

strict 与 loose 两种模式

CssChunkingPluginOptions 有 strict 开关:

rust 复制代码
if !self.strict {
  // loose 模式:根据顺序猜测依赖关系
  for b in &remaining_modules {
    let mut dependents = HashSet::new();
    'outer: for a in &remaining_modules {
      // 检查 a 是否依赖 b
      // 如果 a 依赖 b,b 必须出现在 a 之前
    }
    all_dependents.insert(*b, dependents);
  }
}

strict 模式严格校验每个 chunk 里的顺序不被破坏;loose 模式只根据「依赖者一定排在被依赖者之前」这个启发式猜测依赖。注释说明了 loose 的假设:

text 复制代码
// In loose mode we guess the dependents of modules from the order
// assuming that when a module is a dependency of another module
// it will always appear before it in every chunk.

贪心选择下一个模块

拆分时维护一个 potential_next_modules 池,贪心选择「减少请求数最多」的模块:

rust 复制代码
ordered_potential_next_modules.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));

'outer: for (next_module_identifier, size, _) in ordered_potential_next_modules {
  if current_size + size > self.max_size {
    continue;  // chunk 太大
  }
  // strict 模式校验顺序
  // loose 模式校验依赖
  // 全局 CSS 校验不泄漏到无关 chunk
  // ...
}

global CSS 的隔离

有一类特殊约束:全局 CSS(非 .module.css)不能泄漏进不相关的 chunk:

rust 复制代码
fn is_global_css(name_for_condition: &Option<Box<str>>) -> bool {
  name_for_condition.as_ref().is_some_and(|s| {
    !s.ends_with(".module.css") && !s.ends_with(".module.scss") && !s.ends_with(".module.sass")
  })
}

拆分时:

rust 复制代码
if is_global && global_css_mode && all_chunk_states.len() != next_chunk_states.len() {
  continue;  // chunk group 必须完全一致
}

全局 CSS 只能和「覆盖相同 chunk group 集合」的模块分在一起,否则会把全局样式泄漏进只 import 了局部模块的 chunk。

最后:disconnect 旧 chunk,connect 新 chunk

拆完后,把 CSS 模块从原 chunk 断开,连到新的 css chunk:

rust 复制代码
for chunk_state in chunk_states.values() {
  let mut chunks: UkeySet<ChunkUkey> = UkeySet::default();
  for module_identifier in &chunk_state.modules {
    if let Some(new_chunk_ukey) = new_chunks_by_module.get(module_identifier) {
      chunk_graph.disconnect_chunk_and_module(&chunk_state.chunk, *module_identifier);
      // ...
      chunk.split(new_chunk, &mut ...chunk_group_by_ukey);
    }
  }
}

Rust 角度:顺序约束是一等公民

CSS 插件最值得学习的是它把「顺序」从隐式约定变成显式算法:

text 复制代码
post-order index 编码依赖顺序
多 chunk group 冲突检测
strict/loose 两种策略
global CSS 隔离约束

这些都是 webpack 多年来踩过的坑,Rspack 用 Rust 重新实现,并保留了与 webpack 对齐的行为(源码注释里多处引用 webpack 对应行号)。

这一篇应该带走什么

  1. CSS 是 chunk 的第二主体(auxiliary: false),不是附属文件;
  2. get_modules_in_order 用 post-order index 保证依赖在前、引用在后;
  3. 多 chunk group 冲突通过贪心拓扑排序解决,不可解时产生 Conflicting order warning;
  4. render_chunk_to_source 用 @media/@supports/@layer 包裹来自 @import 的模块;
  5. CssChunkingPlugin 按 30KB~100KB 贪心拆分,严格保证 global CSS 隔离。

写在最后

CSS 是「顺序敏感」的模块类型。接下来换一个「异步编译」的模块类型------WebAssembly。它不读文本、不 parse AST,而是用 wasmparser 解析二进制,并把实例化推迟到运行时。下一篇进入 rspack_plugin_wasm,看 wasm 如何成为异步模块。

相关推荐
不简说1 小时前
AI 帮你画模板、点一下就云打印:sv-print 这三个月都干了啥
前端·javascript·人工智能
孙浩君1 小时前
0行手写代码,我用AI做了一个打工人模拟器(含3D霓虹城市漫游)
前端·后端
夏天要喝冰可乐1 小时前
用 Codex 连接 Blender,我做了一块可以走进楼宇的大屏
前端·three.js
醒醒1111 小时前
从微信小程序到全端:一个安全扫描器的演进之路(v0.5.0 → v0.6.0)
前端·安全
seconp1 小时前
计算机毕设做什么项目?整理了 80 个 Web、小程序、AI 与数据方向
前端·小程序·课程设计
代码小学僧2 小时前
如何低成本部署网站?Vercel 和 Cloudflare 怎么选?
前端·后端·serverless
Csvn2 小时前
加载性能
前端
行者全栈架构师2 小时前
【鸿蒙心迹】让应用“悬浮“起来——闪控球(floatingBall)从接入到实战全链路(HarmonyOS 7.x)
前端·算法·架构
用户6353300254792 小时前
Day 6|ES6+ 语法:把现代 JS 写顺手,从解构到 map / filter / reduce
前端