React 基础体系 · 第 40/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 样式工程:CSS Modules、CSS-in-JS、原子 CSS 和主题
React 不负责规定样式工程方案。React 只会把 JSX 中的 className、style 等属性交给渲染器;CSS 如何解析、类名如何生成、样式何时插入文档、服务端如何输出 CSS,都由浏览器、构建工具和样式库共同决定。
因此,选择样式方案时不能只比较写法是否简洁。至少要同时回答四个问题:
- 作用域:一个组件的样式是否会意外影响另一个组件?
- 生成方式:CSS 是构建时生成、服务端提取,还是浏览器运行时生成?
- 状态与主题:悬停、禁用、响应式和深色模式如何表达?
- 边界与交付:在服务端组件、客户端组件、流式 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 级联规则决定。简化表示,可以把一个属性的优先级理解为:
其中:
- :来源和重要性,例如作者样式、用户样式、
!important; - :级联层级,即
@layer; - :选择器优先级;
- :规则在样式表中的出现顺序;
- :属性本身是否可继承等语义。
实际规范比这个模型更细,但这个近似足以解释常见故障:两个类名都设置了 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 的关键性质是:
它不是运行时隔离。浏览器最终仍然处理普通 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.root 和 styles.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 解决的是命名作用域,没有自动解决以下问题:
- 全局
body、html、第三方库样式; - 样式注入顺序;
- 主题 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> 标签。
逻辑大致是:
优点是动态表达力强;代价是浏览器需要承担序列化、缓存、规则插入和样式计算工作。服务端还必须以库规定的方式收集样式,否则可能出现首屏无样式或 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 可能:
- 计算样式对象;
- 对属性排序和序列化;
- 根据内容生成稳定哈希;
- 查缓存,避免重复插入;
- 在
<style>中插入规则; - 返回类名。
如果 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>
其基本模型是:
这里的“并集”不是数学上的真正合并,而是指多个独立 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-2 和 p-4 谁覆盖谁由生成器排序决定,而不是 JSX 中哪个类名写在后面:
<div className="p-4 p-2">内容</div>
如果团队允许这种冲突,调用者很难从字符串顺序判断结果。常见做法是使用合并工具,根据工具的规则删除冲突类:
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
上面的 cn 只能删除空值,不能正确解决 p-4 和 p-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)是对一组设计决策的命名和替换机制。一个可维护的主题系统通常分两层:
- 原始值(primitive tokens):例如蓝色 600、灰色 100;
- 语义 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);
}
主题切换的因果链是:
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">
而客户端第一次渲染后决定使用深色主题,就可能出现:
- 服务端先输出浅色页面;
- 浏览器首次绘制浅色页面;
- 客户端 JavaScript 读取设置;
- 设置
data-theme="dark"; - 页面变为深色。
这就是主题闪烁。更严重的情况是,服务端和客户端对初始主题产生不同的 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,另一部分组件硬编码蓝色。
样式状态的数据流:不要把视觉状态和业务状态混为一谈
组件状态通常有三种来源:
- DOM 原生状态:
:hover、:focus-visible、:disabled; - 组件状态:
variant、size、loading; - 业务状态:请求失败、权限不足、表单未通过校验。
它们的表达方式不同。
<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 但首屏无样式,可以按因果链排查:
- 查看服务端 HTML 是否包含 CSS
<link>或<style>; - 查看 Network 中 CSS 请求是否成功;
- 检查 CSS-in-JS 是否执行了服务端样式收集;
- 检查生成类名是否在服务端和客户端一致;
- 检查 CSP 是否阻止了内联
<style>; - 检查样式是否在客户端组件挂载后才注入;
- 检查是否存在 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;
}
它可能暂时解决覆盖问题,却会把调用者的扩展点锁死。更准确的处理方式是:
- 确认实际生效的规则;
- 比较来源、层级、选择器优先级和顺序;
- 缩小全局选择器;
- 为状态建立明确选择器;
- 只有在确有外部不可控规则时才局部使用
!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 面板中:
- 选中目标元素;
- 查看
class、data-theme和内联style; - 在 Styles 面板中找到被划掉的声明;
- 比较生效规则和被覆盖规则;
- 跳转到 Sources 确认实际文件;
- 在 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 表格与虚拟列表:列模型、窗口、动态高度和交互
- 下一篇:React 动画:CSS、Transition、Motion、布局动画和减少动态
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论