React 基础体系 · 第 40/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 样式工程:CSS Modules、CSS-in-JS、原子 CSS 和主题

React 不负责规定样式工程方案。React 只会把 JSX 中的 classNamestyle 等属性交给渲染器;CSS 如何解析、类名如何生成、样式何时插入文档、服务端如何输出 CSS,都由浏览器、构建工具和样式库共同决定。

因此,选择样式方案时不能只比较写法是否简洁。至少要同时回答四个问题:

  1. 作用域:一个组件的样式是否会意外影响另一个组件?
  2. 生成方式:CSS 是构建时生成、服务端提取,还是浏览器运行时生成?
  3. 状态与主题:悬停、禁用、响应式和深色模式如何表达?
  4. 边界与交付:在服务端组件、客户端组件、流式 SSR 和 hydration 下是否仍然成立?

本文使用同一个 Button 场景比较 CSS Modules、CSS-in-JS 和原子 CSS,并以 CSS 自定义属性建立主题系统。示例以 React 19、TypeScript 和现代构建工具为基础;具体框架对 CSS 导入、服务端渲染和 React Server Components 的支持仍需以框架文档为准。


先建立共同模型:React 只产生元素,浏览器才计算样式

下面的 JSX:

<button className="button button--primary">保存</button>

经过 React 渲染后,浏览器看到的是类似这样的 DOM:

<button class="button button--primary">保存</button>

浏览器随后按照 CSS 规则计算最终样式。React 并不理解 .button 的含义,也不参与 CSS 选择器的匹配。

style 属性也是类似的边界:

<button
  style={{
    backgroundColor: "rebeccapurple",
    paddingInline: 16,
  }}
>
  保存
</button>

浏览器最终得到:

<button style="background-color: rebeccapurple; padding-inline: 16px;">
  保存
</button>

React 会负责把 JavaScript 属性名转换为 CSS 属性名,并对部分数值自动补充单位,但它不会替你处理完整的 CSS 能力。例如伪元素、媒体查询、容器查询和复杂选择器不能直接写成普通 style 对象。

CSS 的最终结果由级联决定

一个元素的最终样式不是“最后渲染的 React 组件”决定的,而是由 CSS 级联规则决定。简化表示,可以把一个属性的优先级理解为:

P=(O,L,S,N,T)P = (O, L, S, N, T)

其中:

  • OO:来源和重要性,例如作者样式、用户样式、!important
  • LL:级联层级,即 @layer
  • SS:选择器优先级;
  • NN:规则在样式表中的出现顺序;
  • TT:属性本身是否可继承等语义。

实际规范比这个模型更细,但这个近似足以解释常见故障:两个类名都设置了 color,并不意味着后写在 JSX 中的类名一定生效。

.text-blue {
  color: blue;
}

.text-red {
  color: red;
}
<div className="text-red text-blue">文本</div>

这里的结果通常是 blue,因为 CSS 规则出现顺序优先于 HTML 中类名的排列顺序。类名字符串不是一个从左到右执行的命令列表。

如果要让组件状态可靠覆盖基础状态,应明确建立选择器关系或层级:

.button {
  color: white;
  background: gray;
}

.button[data-variant="danger"] {
  background: crimson;
}
<button className={styles.button} data-variant="danger">
  删除
</button>

CSS Modules:构建时局部化类名

定义与机制

CSS Modules 是一种构建工具约定,通常把 *.module.css 文件中的类名转换成局部作用域类名,并导出一个 JavaScript 映射对象。

源码:

/* Button.module.css */
.root {
  border: 0;
  border-radius: 8px;
}

.primary {
  color: white;
  background: #2563eb;
}

构建后的概念结果可能类似:

.Button_root__a1b2c {
  border: 0;
  border-radius: 8px;
}

.Button_primary__c3d4e {
  color: white;
  background: #2563eb;
}

JavaScript 侧拿到:

{
  root: "Button_root__a1b2c",
  primary: "Button_primary__c3d4e"
}

这里的哈希字符串只是常见实现,不是 CSS Modules 规范要求的固定格式。开发环境和生产环境的类名也可能不同。

CSS Modules 的关键性质是:

源码类名构建期唯一类名\text{源码类名} \rightarrow \text{构建期唯一类名}

它不是运行时隔离。浏览器最终仍然处理普通 CSS 类名,React 也不知道这些类名之间有什么关系。

完整示例

/* Button.module.css */
.root {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 2.5rem;
  padding: 0.625rem 1rem;
  border: 1px solid transparent;
  border-radius: 0.5rem;
  font: inherit;
  cursor: pointer;
}

.primary {
  color: white;
  background: var(--color-accent);
}

.secondary {
  color: var(--color-text);
  background: var(--color-surface);
  border-color: var(--color-border);
}

.root:hover:not(:disabled) {
  filter: brightness(0.95);
}

.root:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
}

.root:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}
// Button.tsx
import type { ButtonHTMLAttributes } from "react";
import styles from "./Button.module.css";

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: "primary" | "secondary";
};

export function Button({
  variant = "primary",
  className,
  ...props
}: ButtonProps) {
  const classes = [
    styles.root,
    variant === "primary" ? styles.primary : styles.secondary,
    className,
  ]
    .filter(Boolean)
    .join(" ");

  return <button {...props} className={classes} />;
}

输入:

<Button variant="secondary" disabled>
  取消
</Button>

构建后,styles.rootstyles.secondary 会被替换为局部类名;className 仍然允许调用者追加全局类名。这种“局部基础样式 + 显式扩展点”是 CSS Modules 的常见组件边界。

CSS Modules 不是所有选择器都局部化

通常,类选择器和动画名称会被处理,但具体行为依赖实现和配置。以下内容需要特别注意:

/* Button.module.css */
:global(.legacy-reset) {
  box-sizing: border-box;
}

.root {
  animation: pulse 1s ease-in-out;
}

@keyframes pulse {
  from {
    opacity: 0.5;
  }

  to {
    opacity: 1;
  }
}

:global 会故意退出局部作用域,适合接入旧系统或第三方全局类,但也重新引入了命名冲突风险。动画名、组合语法、嵌套语法是否被局部化,则应由实际构建工具验证,不应仅根据文件名推断。

常见组合方式是:

/* Card.module.css */
.root {
  padding: 1rem;
}

.compact {
  composes: root;
  padding: 0.5rem;
}

composes 的支持情况与配置有关;现代项目也常直接在 React 中组合导出的类名,以减少对特定 CSS Modules 实现的依赖。

CSS Modules 的边界

CSS Modules 解决的是命名作用域,没有自动解决以下问题:

  • 全局 bodyhtml、第三方库样式;
  • 样式注入顺序;
  • 主题 token;
  • 动态任意值;
  • 组件跨包复用时的构建配置一致性;
  • SSR 环境如何收集和输出 CSS。

例如,下面的选择器可能仍然无法覆盖第三方组件:

.root .third-party-input {
  border: 1px solid red;
}

如果第三方组件实际渲染了 Shadow DOM,普通 CSS 根本无法穿透;如果它只是普通 DOM,则还要确认最终生成的类名、选择器优先级和样式表顺序。


CSS-in-JS:把样式声明与 JavaScript 运行模型结合

定义与两类实现

CSS-in-JS 不是单一库,而是一组方法:在 JavaScript 或 TypeScript 中表达 CSS,并由工具生成类名、样式表或内联样式。

主要有两类实现。

运行时 CSS-in-JS

组件运行时根据 props、主题或状态生成 CSS,并将样式插入 <style> 标签。

逻辑大致是:

(样式对象,props,theme)运行时序列化(类名,CSS 规则)(\text{样式对象}, \text{props}, \text{theme}) \xrightarrow{\text{运行时序列化}} (\text{类名}, \text{CSS 规则})

优点是动态表达力强;代价是浏览器需要承担序列化、缓存、规则插入和样式计算工作。服务端还必须以库规定的方式收集样式,否则可能出现首屏无样式或 hydration 不一致。

零运行时或编译型 CSS-in-JS

构建阶段读取样式声明,提前生成 CSS 文件和类名。运行时只需要使用类名,动态能力通常受限于可静态分析程度。

它的性能特征更接近 CSS Modules,但语法和类型能力可能更接近 JavaScript。

“CSS-in-JS 性能一定差”或“CSS-in-JS 一定没有 SSR 支持”都不准确。真正需要区分的是:样式是否在浏览器运行时生成、是否支持服务端提取、动态值的数量,以及具体库的缓存策略。

运行时 CSS-in-JS 的典型生命周期

以概念流程表示:

sequenceDiagram
  participant R as React 渲染
  participant J as CSS-in-JS 运行时
  participant D as DOM
  participant B as 浏览器样式计算

  R->>J: 根据 props/theme 计算样式
  J->>J: 序列化并生成或复用类名
  J->>D: 插入 style 规则
  R->>D: 渲染带类名的元素
  D->>B: 重新计算样式
  B-->>D: 绘制最终结果

如果服务端参与渲染,服务器必须在输出 HTML 的同时输出对应 CSS,或者由框架在客户端尽早插入 CSS。否则浏览器可能先绘制无样式内容,再等待 JavaScript 执行后补上样式,产生 FOUC(Flash of Unstyled Content)。

一个可运行的概念示例

下面使用一个抽象的 css 函数表示 CSS-in-JS API。不同库的真实 API 并不相同,不能把它直接当作某个具体库的可复制代码:

type ButtonStyleProps = {
  variant: "primary" | "secondary";
};

function buttonStyle({ variant }: ButtonStyleProps) {
  return {
    display: "inline-flex",
    alignItems: "center",
    border: 0,
    borderRadius: 8,
    padding: "10px 16px",
    color: variant === "primary" ? "white" : "var(--color-text)",
    background:
      variant === "primary"
        ? "var(--color-accent)"
        : "var(--color-surface)",
  };
}

实际 CSS-in-JS 库通常会把结果转换为:

const className = css(buttonStyle({ variant }));
return <button className={className}>保存</button>;

其中 css 可能:

  1. 计算样式对象;
  2. 对属性排序和序列化;
  3. 根据内容生成稳定哈希;
  4. 查缓存,避免重复插入;
  5. <style> 中插入规则;
  6. 返回类名。

如果 variant 来自用户输入,不能直接把任意字符串拼接进 CSS。应该使用联合类型、白名单映射或经过验证的 token:

const colorByVariant = {
  primary: "var(--color-accent)",
  secondary: "var(--color-surface)",
} as const;

function getColor(variant: keyof typeof colorByVariant) {
  return colorByVariant[variant];
}

这样既避免了非法 CSS,也让 TypeScript 检查所有变体是否覆盖。

React 19 与 CSS-in-JS 的边界

React 19 本身没有规定 CSS-in-JS 的插入 API,也没有为任意 CSS-in-JS 库提供统一 SSR 协议。React 提供的是渲染模型,包括服务端渲染、客户端 hydration 和并发渲染;样式库必须在这些模型上正确实现自己的缓存与输出机制。

服务端组件带来的约束更明显:

  • 服务端组件不能使用浏览器 API;
  • 服务端组件不能使用需要客户端状态或事件处理的逻辑;
  • 运行时 CSS-in-JS 通常依赖客户端或框架集成;
  • 某些框架允许服务端组件导入静态 CSS,但不允许直接在服务端组件中调用客户端样式运行时。

因此,使用 React Server Components 时,常见边界是:

// ServerComponent.tsx
import { Button } from "./Button";

export default async function Page() {
  const data = await loadData();
  return <Button variant="primary">{data.label}</Button>;
}

Button 如果内部使用运行时 CSS-in-JS,往往需要被标记为客户端组件,具体写法由框架决定。若希望组件保持服务端可渲染,CSS Modules、静态 CSS 或构建期原子 CSS 通常更容易集成,但这不是 React 规范保证,而是工具链的常见兼容方向。


原子 CSS:把样式拆成可复用的单一职责类

定义

原子 CSS(Atomic CSS)把一条或少量 CSS 声明封装成一个小类,例如:

.p-4 {
  padding: 1rem;
}

.text-white {
  color: white;
}

.bg-blue-600 {
  background: #2563eb;
}

组件通过组合类名表达样式:

<button className="inline-flex items-center rounded-md px-4 py-2 text-white bg-blue-600">
  保存
</button>

其基本模型是:

组件样式=i=1nutilityi\text{组件样式} = \bigcup_{i=1}^{n} \text{utility}_i

这里的“并集”不是数学上的真正合并,而是指多个独立 CSS 规则共同作用于一个元素。

Tailwind CSS、UnoCSS 等工具通常会扫描源码中的类名,再生成实际 CSS。它们属于原子或近似原子工具,但具体语法、变体、扫描方式和生成策略由工具决定。

原子 CSS 的状态表达

原子 CSS 不只是静态类名,还经常用变体前缀表达状态:

<button
  className={[
    "inline-flex items-center rounded-md px-4 py-2",
    "bg-blue-600 text-white",
    "hover:bg-blue-700",
    "focus-visible:outline focus-visible:outline-2",
    "disabled:cursor-not-allowed disabled:opacity-50",
  ].join(" ")}
>
  保存
</button>

这些前缀最终会生成类似:

.hover\:bg-blue-700:hover {
  background: #1d4ed8;
}

.disabled\:opacity-50:disabled {
  opacity: 0.5;
}

响应式和媒体查询也是同一思想:

<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
  ...
</div>

生成的 CSS 会在对应媒体条件成立时启用。

动态类名的扫描陷阱

下面这种写法对许多基于静态扫描的原子 CSS 工具不可靠:

const color = "blue";
return <div className={`bg-${color}-600`}>内容</div>;

扫描器可能无法推断 bg-blue-600 这个完整字符串,因此生产 CSS 中没有对应规则。

应改为完整字符串映射:

const backgroundByStatus = {
  success: "bg-green-600",
  warning: "bg-yellow-500",
  error: "bg-red-600",
} as const;

type Status = keyof typeof backgroundByStatus;

export function StatusBadge({ status }: { status: Status }) {
  return (
    <span className={backgroundByStatus[status]}>
      {status}
    </span>
  );
}

这个版本的输入是有限联合类型,构建工具能看到完整类名,TypeScript 也能阻止未定义状态。

原子 CSS 的冲突不是类名顺序冲突

很多原子 CSS 工具会保证同一工具集合中的生成顺序,例如 p-2p-4 谁覆盖谁由生成器排序决定,而不是 JSX 中哪个类名写在后面:

<div className="p-4 p-2">内容</div>

如果团队允许这种冲突,调用者很难从字符串顺序判断结果。常见做法是使用合并工具,根据工具的规则删除冲突类:

function cn(...classes: Array<string | false | null | undefined>) {
  return classes.filter(Boolean).join(" ");
}

上面的 cn 只能删除空值,不能正确解决 p-4p-2 的语义冲突。生产项目通常需要使用与具体原子 CSS 工具匹配的冲突解析器,或者通过组件 API 保证同一组变体只产生一个值。

原子 CSS 的优点和边界

原子 CSS 的主要优点来自有限词汇表:

  • 重复声明可以被同一条规则复用;
  • CSS 文件更容易做静态提取;
  • 样式值集中在设计 token 或工具配置中;
  • 响应式和状态组合形式统一。

但它不能自动解决:

  • 组件语义是否清晰;
  • 复杂选择器和伪元素;
  • 第三方组件内部结构;
  • 动态用户值;
  • 类名条件过多导致的可读性下降;
  • 设计 token 被任意工具值绕过。

例如,下面的组件 API 比让调用者传入任意几十个类名更稳定:

type AlertProps = {
  tone: "info" | "success" | "warning" | "error";
  children: React.ReactNode;
};

const toneClasses = {
  info: "border-blue-200 bg-blue-50 text-blue-900",
  success: "border-green-200 bg-green-50 text-green-900",
  warning: "border-yellow-200 bg-yellow-50 text-yellow-900",
  error: "border-red-200 bg-red-50 text-red-900",
} as const;

export function Alert({ tone, children }: AlertProps) {
  return (
    <div
      role="status"
      className={`rounded-md border p-4 ${toneClasses[tone]}`}
    >
      {children}
    </div>
  );
}

组件负责保证 tone 与视觉状态一一对应,调用者不需要了解每个原子类。


主题:用 token 与 CSS 自定义属性承载可变设计语义

主题不是简单切换一张颜色表

主题(Theme)是对一组设计决策的命名和替换机制。一个可维护的主题系统通常分两层:

  1. 原始值(primitive tokens):例如蓝色 600、灰色 100;
  2. 语义 token(semantic tokens):例如强调色、页面背景、主要文本。

组件应该依赖语义 token:

color: var(--color-text);
background: var(--color-surface);

而不是直接依赖:

color: #111827;
background: #ffffff;

这样,深色主题只需替换 token,而不必修改每个组件。

用 CSS 自定义属性建立主题

/* theme.css */
:root {
  color-scheme: light;

  --color-text: #111827;
  --color-surface: #ffffff;
  --color-border: #d1d5db;
  --color-accent: #2563eb;
  --color-focus: #93c5fd;
}

[data-theme="dark"] {
  color-scheme: dark;

  --color-text: #f9fafb;
  --color-surface: #111827;
  --color-border: #374151;
  --color-accent: #60a5fa;
  --color-focus: #bfdbfe;
}

body {
  margin: 0;
  color: var(--color-text);
  background: var(--color-surface);
}

组件只引用语义变量:

/* Button.module.css */
.root {
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
}

.primary {
  color: white;
  background: var(--color-accent);
}

主题切换的因果链是:

修改根元素属性变量值重新解析引用变量的属性失效并重算组件外观更新\text{修改根元素属性} \rightarrow \text{变量值重新解析} \rightarrow \text{引用变量的属性失效并重算} \rightarrow \text{组件外观更新}

React 不需要重新渲染每个按钮,因为 CSS 变量的变化发生在浏览器样式层。

客户端主题切换示例

import { useEffect, useState } from "react";

type Theme = "light" | "dark";

function getInitialTheme(): Theme {
  if (typeof window === "undefined") {
    return "light";
  }

  const stored = window.localStorage.getItem("theme");
  if (stored === "light" || stored === "dark") {
    return stored;
  }

  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

export function ThemeToggle() {
  const [theme, setTheme] = useState<Theme>(getInitialTheme);

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
    window.localStorage.setItem("theme", theme);
  }, [theme]);

  return (
    <button
      type="button"
      onClick={() => setTheme((current) => (
        current === "light" ? "dark" : "light"
      ))}
      aria-pressed={theme === "dark"}
    >
      当前主题:{theme === "light" ? "浅色" : "深色"}
    </button>
  );
}

这里的 typeof window === "undefined" 是服务端兼容检查。服务端没有 window,不能读取 localStorage 或调用 matchMedia

SSR 与 hydration 的主题闪烁

如果服务端输出的是:

<html data-theme="light">

而客户端第一次渲染后决定使用深色主题,就可能出现:

  1. 服务端先输出浅色页面;
  2. 浏览器首次绘制浅色页面;
  3. 客户端 JavaScript 读取设置;
  4. 设置 data-theme="dark"
  5. 页面变为深色。

这就是主题闪烁。更严重的情况是,服务端和客户端对初始主题产生不同的 DOM 属性或文本,导致 hydration 警告。

可行策略取决于产品需求:

  • 服务端根据 Cookie 读取主题,并输出对应 data-theme
  • 使用系统主题时,让服务端输出中性结果,再尽早执行同步初始化脚本;
  • 接受首次主题在客户端确定,但将其视为明确的视觉取舍;
  • 不要在服务端渲染阶段直接读取 localStorage,因为它只存在于浏览器。

初始化脚本应只做确定性的 DOM 属性设置,不能依赖 React 状态:

<script>
  (() => {
    const saved = localStorage.getItem("theme");
    const systemDark = matchMedia("(prefers-color-scheme: dark)").matches;
    const theme = saved === "dark" || (!saved && systemDark)
      ? "dark"
      : "light";

    document.documentElement.dataset.theme = theme;
  })();
</script>

这段脚本会读取用户可控的本地值,但不会把值拼接到 HTML 或 CSS 中;如果项目使用严格 CSP,还需要为脚本配置 nonce 或改为外部脚本。

React Context 与 CSS 变量的分工

如果主题只影响颜色、间距、圆角等 CSS 属性,CSS 变量是高效载体:

<div data-theme="dark">{children}</div>

如果组件逻辑也需要知道当前主题,例如图表库需要选择不同图片资源,则可以额外使用 Context:

import { createContext, useContext } from "react";

type Theme = "light" | "dark";

const ThemeContext = createContext<Theme>("light");

export function useTheme() {
  return useContext(ThemeContext);
}

两者职责不同:

  • CSS 变量:把主题值传给 CSS;
  • Context:把主题状态传给 JavaScript;
  • React 状态:管理主题状态变化;
  • 根元素属性:提供 CSS 选择器和继承边界。

不要为了修改颜色而让整个组件树读取 Context 并重新渲染。可以让 Context 只服务于确实需要主题值的逻辑,视觉样式仍由 CSS 变量完成。


四种样式表达方式的同一组件对照

同一个按钮可以这样实现。

CSS Modules

import styles from "./Button.module.css";

export function Button({
  variant = "primary",
  children,
}: {
  variant?: "primary" | "secondary";
  children: React.ReactNode;
}) {
  return (
    <button
      className={`${styles.root} ${
        variant === "primary" ? styles.primary : styles.secondary
      }`}
    >
      {children}
    </button>
  );
}

样式声明在独立 CSS 文件中,作用域由构建阶段处理。

CSS-in-JS

type Variant = "primary" | "secondary";

const buttonStyles = {
  primary: {
    color: "white",
    background: "var(--color-accent)",
  },
  secondary: {
    color: "var(--color-text)",
    background: "var(--color-surface)",
    border: "1px solid var(--color-border)",
  },
} satisfies Record<Variant, Record<string, string>>;

export function Button({
  variant = "primary",
  children,
}: {
  variant?: Variant;
  children: React.ReactNode;
}) {
  const style = buttonStyles[variant];

  return (
    <button style={style}>
      {children}
    </button>
  );
}

这个示例使用的是 React 原生 style 属性,不是完整 CSS-in-JS 库,但能说明“样式值由 JavaScript 根据变体决定”的核心。它不能表达 :hover、媒体查询或伪元素,因此真实 CSS-in-JS 库通常会把对象编译或序列化为类名,而不是完全依赖内联样式。

原子 CSS

const classes = {
  primary: "bg-blue-600 text-white hover:bg-blue-700",
  secondary:
    "border border-gray-300 bg-white text-gray-900 hover:bg-gray-50",
} as const;

export function Button({
  variant = "primary",
  children,
}: {
  variant?: keyof typeof classes;
  children: React.ReactNode;
}) {
  return (
    <button
      className={[
        "inline-flex min-h-10 items-center rounded-md px-4 py-2",
        "focus-visible:outline focus-visible:outline-2",
        "disabled:cursor-not-allowed disabled:opacity-50",
        classes[variant],
      ].join(" ")}
    >
      {children}
    </button>
  );
}

这里假定项目已配置对应原子 CSS 工具;仅安装 React 并不能让这些类名自动生效。

主题与三种方案组合

主题并不是 CSS Modules、CSS-in-JS 和原子 CSS 的替代品。它是一个跨方案的值传递机制:

:root {
  --color-accent: #2563eb;
}

[data-theme="dark"] {
  --color-accent: #60a5fa;
}

CSS Modules 可以引用变量:

.primary {
  background: var(--color-accent);
}

CSS-in-JS 可以生成引用变量的规则:

const style = {
  background: "var(--color-accent)",
};

原子 CSS 也可以通过配置 token 映射到变量,或者直接使用任意值语法,但应保持 token 来源统一,避免一部分组件使用 --color-accent,另一部分组件硬编码蓝色。


样式状态的数据流:不要把视觉状态和业务状态混为一谈

组件状态通常有三种来源:

  1. DOM 原生状态:hover:focus-visible:disabled
  2. 组件状态variantsizeloading
  3. 业务状态:请求失败、权限不足、表单未通过校验。

它们的表达方式不同。

<button
  disabled={loading}
  aria-busy={loading}
  data-variant={variant}
>
  {loading ? "保存中…" : "保存"}
</button>
.button {
  /* 基础外观 */
}

.button[data-variant="danger"] {
  background: var(--color-danger);
}

.button:disabled {
  opacity: 0.5;
}

.button:focus-visible {
  outline: 3px solid var(--color-focus);
}

这里:

  • loading 是 React 状态,决定按钮是否禁用和文本内容;
  • data-variant 是可序列化的组件状态,交给 CSS 选择;
  • :focus-visible 是浏览器状态,不应由 React 手工监听键盘事件模拟;
  • aria-busy 是可访问性语义,不是样式替代品。

一个常见错误是只改变颜色表示错误:

<div className="text-red-600">提交失败</div>

如果它是表单错误,还需要建立语义关联:

<input aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" role="alert">
  邮箱格式不正确
</p>

样式方案决定“怎么写颜色”,不决定“状态是否被正确表达”。


样式插入顺序、并发渲染与 SSR

并发渲染不等于样式按组件顺序插入

React 的并发渲染允许渲染工作被中断、恢复或重新执行。样式库不能依赖“某个组件函数执行过一次,所以规则只会插入一次”这种假设。

运行时 CSS-in-JS 至少要保证:

  • 相同样式内容能稳定映射到相同类名;
  • 重复渲染不会无限插入重复规则;
  • 中断后再次渲染不会产生错误的全局状态;
  • 服务端和客户端对同一规则生成一致的结果;
  • 流式输出时,规则能在对应内容需要时及时可用。

这也是为什么不能随意在组件函数中手写全局副作用:

function BadComponent() {
  const style = document.createElement("style");
  style.textContent = ".bad { color: red; }";
  document.head.appendChild(style);

  return <div className="bad">错误示例</div>;
}

这个写法的问题包括:

  • 服务端没有 document
  • React 重新渲染会重复插入;
  • 严格模式开发环境下可能更容易暴露重复副作用;
  • 卸载组件时没有清理;
  • 并发渲染可能在最终提交前就改变全局 DOM。

样式注入应交给经过 React 和目标框架验证的库。若必须自己实现,至少要把 DOM 操作放在提交后的 effect 中,并处理幂等、清理和服务端分支;但这仍不等价于完整的 SSR CSS 收集方案。

首屏样式缺失的诊断路径

当页面服务端返回 HTML 但首屏无样式,可以按因果链排查:

  1. 查看服务端 HTML 是否包含 CSS <link><style>
  2. 查看 Network 中 CSS 请求是否成功;
  3. 检查 CSS-in-JS 是否执行了服务端样式收集;
  4. 检查生成类名是否在服务端和客户端一致;
  5. 检查 CSP 是否阻止了内联 <style>
  6. 检查样式是否在客户端组件挂载后才注入;
  7. 检查是否存在 CSS 加载顺序导致的覆盖。

如果服务端 HTML 使用了类名 css-a1b2,客户端 hydration 后变成 css-c3d4,问题通常不是 React 自己“改错了样式”,而是样式序列化输入、缓存边界、随机值或服务端客户端配置不一致。

不要在样式生成过程中使用随机数、当前时间或只在浏览器存在的值:

// 不可靠:服务端和客户端可能生成不同类名
const className = css({
  animationName: `fade-${Math.random()}`,
});

CSS Modules、CSS-in-JS 和原子 CSS 的工程取舍

可以用下面的维度进行比较:

维度 CSS Modules 运行时 CSS-in-JS 原子 CSS
作用域 构建期局部类名 通常由库生成类名 工具类名通常全局但命名约定稳定
动态样式 适合有限变体,任意值需额外处理 表达力强 适合预定义 token,动态字符串需谨慎
运行时成本 通常较低 可能有序列化和插入成本 通常主要在构建阶段
SSR 静态 CSS 较直接 依赖库和框架集成 静态提取通常直接
伪类和媒体查询 原生 CSS 表达自然 取决于库 通过变体语法表达
组件可读性 结构清晰 样式与逻辑集中 类名较长,依赖团队约定
设计 token 需自行建立 可通过主题对象建立 通常由配置和 CSS 变量建立
第三方覆盖 依赖选择器和顺序 依赖库生成规则 依赖层级和工具顺序

这些不是绝对性能排名。一个使用大量动态值和复杂主题计算的 CSS Modules 项目,可能比一个合理缓存的 CSS-in-JS 项目更难维护;一个把所有样式都写成原子类的项目,也可能因为类名组合复杂而降低开发效率。

更具体的选择逻辑是:

  • 组件有大量复杂选择器、动画、媒体查询,且希望接近原生 CSS:CSS Modules 通常直接;
  • 样式强依赖运行时 props,且需要封装主题和组件 API:CSS-in-JS 可能更方便,但要验证 SSR、RSC 和运行时成本;
  • 页面和组件主要由设计 token、间距、布局、响应式组合构成:原子 CSS 可以减少重复声明;
  • 多个方案共存:应统一 token、层级和命名边界,否则不同方案之间会互相覆盖。

混用时最容易出错的地方

全局样式与局部样式的边界不清

建议把全局 CSS 限定在少数职责:

/* global.css */
@layer reset, tokens, base, components, utilities;

@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }
}

@layer base {
  body {
    margin: 0;
    font-family: system-ui, sans-serif;
  }
}

组件样式放在 CSS Modules 或组件方案中。@layer 是现代 CSS 级联层能力,但具体浏览器兼容范围、构建工具处理方式和第三方 CSS 层级仍需测试。

!important 被当作组件 API

.root {
  color: red !important;
}

它可能暂时解决覆盖问题,却会把调用者的扩展点锁死。更准确的处理方式是:

  1. 确认实际生效的规则;
  2. 比较来源、层级、选择器优先级和顺序;
  3. 缩小全局选择器;
  4. 为状态建立明确选择器;
  5. 只有在确有外部不可控规则时才局部使用 !important

主题变量未定义

.title {
  color: var(--color-heading);
}

如果某个子树没有继承到 --color-heading,颜色属性可能变为无效或回退到继承结果。可以提供回退:

.title {
  color: var(--color-heading, var(--color-text));
}

但回退不能替代 token 检查。应在设计系统中明确所有语义变量,并通过视觉回归测试覆盖每种主题。

把用户输入当作类名或 CSS 值

<div className={`text-${userInput}`}>内容</div>

这会造成扫描缺失、样式不可预测,甚至在某些 CSS-in-JS 拼接场景下引入注入风险。应把外部输入映射到有限集合:

const classByRole = {
  admin: "text-red-700",
  member: "text-blue-700",
  guest: "text-gray-700",
} as const;

type Role = keyof typeof classByRole;

function UserRole({ role }: { role: Role }) {
  return <span className={classByRole[role]}>{role}</span>;
}

生产验证:观察最终 CSS,而不是只看源码

样式工程的验证对象是浏览器最终收到和应用的资源。

检查构建结果

构建后至少检查:

  • CSS 文件是否被生成;
  • CSS Modules 类名是否在 JS 和 CSS 中对应;
  • 原子 CSS 扫描是否包含条件分支中的完整类名;
  • CSS-in-JS 是否按预期提取或注入;
  • 未使用样式是否被移除;
  • 主题变量是否存在于实际页面入口。

例如,若使用 npm 脚本:

npm run build

预期结果应包括构建成功、生成静态资源,以及没有未解析的 CSS 导入。具体输出格式取决于 Vite、Next.js、Rspack 或其他工具,不能把某个工具的文件名当作通用结论。

使用浏览器开发者工具定位覆盖问题

在 Elements 面板中:

  1. 选中目标元素;
  2. 查看 classdata-theme 和内联 style
  3. 在 Styles 面板中找到被划掉的声明;
  4. 比较生效规则和被覆盖规则;
  5. 跳转到 Sources 确认实际文件;
  6. 在 Computed 面板中查看最终值和变量解析来源。

如果 background: var(--color-accent) 没有生效,通常有三种原因:

  • --color-accent 未定义;
  • 变量值本身不是合法的 background 值;
  • 该声明被更高优先级规则覆盖。

如果类名存在但没有任何匹配规则,则重点检查构建扫描、CSS 是否加载,以及 CSS Modules 导入是否被框架正确处理。

主题测试不能只测切换按钮

至少应测试:

  • 首次服务端输出的主题;
  • 客户端 hydration 是否有警告;
  • 刷新后主题是否保持;
  • 系统主题变化时是否按产品规则响应;
  • 弱光和高对比场景下文本对比度;
  • :focus-visible、禁用状态和错误状态;
  • 不同主题下图标、边框、阴影和插图是否仍然可见。

一个稳定的分层方案

一个实际项目可以采用以下分层,但这不是 React 的强制结构:

全局层
├── reset
├── 字体与基础元素
├── 设计 token / CSS 自定义属性
└── 第三方库的必要覆盖

组件层
├── CSS Modules 或编译型 CSS-in-JS
├── 组件状态类
└── 伪类、动画、媒体查询

组合层
├── 原子 CSS 的布局和间距
├── 页面级响应式组合
└── 有限、类型安全的变体映射

逻辑层
├── React state
├── Context
└── 服务端读取的用户偏好

核心边界是:

  • React 状态决定业务和交互状态;
  • data-* 或类名把有限组件状态传给 CSS;
  • CSS 变量承载可替换的主题值;
  • CSS Modules、CSS-in-JS 或原子 CSS 负责具体规则的组织和交付;
  • 服务端负责初始 HTML 与主题偏好的协调;
  • 客户端只在需要交互时接管状态变化。

当这几个职责混在一起时,问题通常表现为:为了改一个颜色触发整棵组件树重渲染、为了覆盖一条规则加入越来越多 !important、为了支持一个状态拼接无法被构建工具识别的类名,或者服务端和客户端生成不同的样式结果。

样式方案的选择最终不是“哪种语法最好”,而是能否让作用域、生成时机、状态数据流、主题值和服务端边界保持可解释。CSS Modules 解决局部命名,CSS-in-JS 解决 JavaScript 驱动的样式表达,原子 CSS 解决有限规则的组合与复用,主题系统解决语义值的替换;它们可以单独使用,也可以在清晰边界下组合使用。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。