主题
CSS-in-JS
样式方案演进
全局 CSS → BEM 命名规范 → CSS Modules → CSS-in-JS → Utility-First (Tailwind) → Zero-Runtime CSS-in-JS
| | | | | |
原始时代 人肉约定 构建时作用域 JS 动态控制 原子化思路 编译时提取每一代方案都在解决前一代的核心痛点:
| 痛点 | 传统 CSS | BEM | CSS Modules | CSS-in-JS |
|---|---|---|---|---|
| 全局污染 | ❌ | 靠命名约定 | ✅ 构建时哈希 | ✅ 运行时/编译时隔离 |
| 组件共置 | ❌ 样式和组件分离 | ❌ | ⚠️ 需 import | ✅ 样式写在组件内 |
| 动态样式 | ❌ 需手动拼 class | ❌ | ❌ | ✅ props 驱动 |
| 类型安全 | ❌ | ❌ | ⚠️ 可生成类型 | ✅ TypeScript 原生支持 |
| 运行时开销 | ✅ 无 | ✅ 无 | ✅ 无 | ⚠️ Runtime 方案有开销 |
Runtime CSS-in-JS
"Runtime" 指的是在浏览器运行时动态生成和注入 <style> 标签。
组件渲染
↓
JS 执行样式函数(读取 props/theme)
↓
生成唯一 className + CSS 字符串
↓
通过 CSSStyleSheet API 或 <style> 标签注入到 DOM
↓
组件使用生成的 classNamestyled-components
最流行的 Runtime CSS-in-JS 方案,使用 Tagged Template Literal 语法。
tsx
import styled from 'styled-components';
const Button = styled.button<{ $primary?: boolean }>`
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #4a90d9;
background: ${props => props.$primary ? '#4a90d9' : 'transparent'};
color: ${props => props.$primary ? '#fff' : '#4a90d9'};
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${props => props.$primary ? '#357abd' : '#e8f0fe'};
}
`;
// 使用
<Button $primary>确认</Button>
<Button>取消</Button>核心原理:
Tagged Template Literal
↓
StyleSheet 管理器(维护已注入的样式缓存)
↓
序列化:将模板字符串 + 插值函数求值 → 最终 CSS 字符串
↓
哈希:MurmurHash → 生成唯一 className(如 .sc-aXeDK)
↓
注入:通过 CSSStyleSheet.insertRule() 或 <style> 注入
↓
返回带有 className 的 React 组件Theme 系统:
tsx
import { ThemeProvider } from 'styled-components';
const theme = {
colors: {
primary: '#4a90d9',
danger: '#e74c3c',
},
spacing: (n: number) => `${n * 4}px`,
};
const Card = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
border: 1px solid ${({ theme }) => theme.colors.primary};
`;
// 根组件注入
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>样式继承与组合:
tsx
const BaseButton = styled.button`
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
`;
const PrimaryButton = styled(BaseButton)`
background: #4a90d9;
color: #fff;
`;
const DangerButton = styled(BaseButton)`
background: #e74c3c;
color: #fff;
`;
// as 多态:渲染为其他 HTML 元素或组件
<PrimaryButton as="a" href="/home">Go Home</PrimaryButton>Emotion
API 设计与 styled-components 相似,但提供两种使用方式:
tsx
// 方式一:styled API(与 styled-components 几乎相同)
import styled from '@emotion/styled';
const Box = styled.div`
padding: 16px;
background: ${props => props.color || '#f5f5f5'};
`;
// 方式二:css prop(需要配置 JSX pragma 或 babel 插件)
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
const dynamicStyle = (isPrimary: boolean) => css`
padding: 8px 16px;
background: ${isPrimary ? '#4a90d9' : '#eee'};
color: ${isPrimary ? '#fff' : '#333'};
`;
function Button({ primary }: { primary?: boolean }) {
return <button css={dynamicStyle(!!primary)}>Click</button>;
}css prop 的优势:不需要额外创建 styled 组件,样式直接写在 JSX 上,适合一次性样式。
Runtime 方案的性能问题
用户交互 → 状态变化 → 组件重新渲染
↓
样式函数重新求值
↓
序列化 CSS 字符串
↓
哈希计算 + 缓存检查
↓
可能注入新 <style> 规则
↓
浏览器重新计算样式(Recalculate Style)核心开销:
- 序列化开销 —— 每次渲染时将模板字符串 + 插值函数求值为 CSS 字符串
- 运行时注入 —— 动态创建/更新
<style>标签触发样式重新计算 - SSR 水合 —— 服务端序列化的样式需要在客户端重新水合(Hydration),可能导致样式闪烁(FOUC)
- React 18 并发模式 ——
useInsertionEffect之前的方案在并发渲染下可能导致样式丢失
量化感知:在中等复杂度页面中,Runtime CSS-in-JS 的样式计算可占总渲染时间的 10-20%。
Zero-Runtime CSS-in-JS
"Zero-Runtime" 指的是在构建时(编译时)将样式提取为静态 CSS 文件,运行时零 JS 开销。
开发时 构建时 运行时
JS 中写样式代码 → 编译器提取为 .css 文件 → 纯静态 CSS,无 JS 运行
(开发体验好) (Babel/SWC 插件) (性能 = 传统 CSS)Vanilla Extract
最成熟的 Zero-Runtime 方案。在 .css.ts 文件中用 TypeScript 写样式:
ts
// button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
export const base = style({
padding: '8px 16px',
borderRadius: '4px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.2s',
});
export const variants = styleVariants({
primary: {
background: '#4a90d9',
color: '#fff',
':hover': { background: '#357abd' },
},
secondary: {
background: 'transparent',
border: '2px solid #4a90d9',
color: '#4a90d9',
':hover': { background: '#e8f0fe' },
},
});
// recipe:类似 CVA 的变体 API
export const button = recipe({
base: {
padding: '8px 16px',
borderRadius: '4px',
},
variants: {
size: {
small: { fontSize: '12px' },
medium: { fontSize: '14px' },
large: { fontSize: '16px' },
},
intent: {
primary: { background: '#4a90d9', color: '#fff' },
danger: { background: '#e74c3c', color: '#fff' },
},
},
defaultVariants: {
size: 'medium',
intent: 'primary',
},
});tsx
// Button.tsx
import { button, base, variants } from './button.css';
// recipe 用法
<button className={button({ size: 'large', intent: 'primary' })}>
Submit
</button>
// style + styleVariants 用法
<button className={`${base} ${variants.primary}`}>
Submit
</button>构建产物:
css
/* 生成的静态 CSS(className 被哈希化) */
.button_base__1a2b3c {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.button_variants_primary__4d5e6f {
background: #4a90d9;
color: #fff;
}
.button_variants_primary__4d5e6f:hover {
background: #357abd;
}Panda CSS
结合了 Zero-Runtime 和 Utility-First 的思路:
tsx
import { css } from '../styled-system/css';
function Card() {
return (
<div className={css({
padding: '16px',
borderRadius: '8px',
bg: 'gray.100',
_hover: { bg: 'gray.200' },
md: { padding: '24px' },
})}>
Content
</div>
);
}构建时静态提取为原子化 CSS,运行时零开销。
StyleX (Meta)
Meta(Facebook)内部使用的 Zero-Runtime 方案,已开源:
tsx
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
base: {
padding: 16,
borderRadius: 8,
},
primary: {
backgroundColor: '#4a90d9',
color: '#fff',
},
large: {
fontSize: 18,
padding: 24,
},
});
function Button({ primary, large }: { primary?: boolean; large?: boolean }) {
return (
<button {...stylex.props(styles.base, primary && styles.primary, large && styles.large)}>
Click
</button>
);
}StyleX 的独特设计:
- 原子化输出 —— 每个 CSS 属性生成一个独立的原子类,最大化跨组件复用
- 确定性合并 —— 后传入的样式总是覆盖前面的("last applied wins"),解决 CSS 特异性问题
- 编译时类型安全 —— Flow/TypeScript 全类型覆盖
Utility-First CSS(Tailwind CSS)
不属于传统 CSS-in-JS,但在组件化开发中解决了相同的问题。
传统 CSS-in-JS Utility-First
-------------------- --------------------
写 CSS 属性 + 值 直接使用预定义的原子 class
需要 runtime / 编译器 构建时 tree-shake 未使用的 class
命名焦虑 无需命名核心概念
html
<!-- 传统方式:先想名字,再写样式 -->
<div class="card">
<h2 class="card-title">Title</h2>
</div>
<!-- Tailwind:直接在标签上描述样式 -->
<div class="p-4 rounded-lg bg-white shadow-md hover:shadow-lg transition-shadow">
<h2 class="text-xl font-bold text-gray-900">Title</h2>
</div>设计系统约束
Tailwind 通过 Design Token 约束样式值,避免任意魔法数字:
js
// tailwind.config.js
module.exports = {
theme: {
spacing: {
0: '0',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
// ...只能使用这些间距值
},
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
},
},
extend: {
borderRadius: {
'xl': '12px',
},
},
},
};动态样式处理
Tailwind 是静态分析的,不支持真正的运行时动态样式:
tsx
// ❌ 不生效 —— Tailwind 无法静态分析模板字符串拼接的 class
function Box({ color }: { color: string }) {
return <div className={`bg-${color}-500`}>Content</div>;
}
// ✅ 正确做法 —— 使用完整的 class 名
const colorMap: Record<string, string> = {
red: 'bg-red-500',
blue: 'bg-blue-500',
green: 'bg-green-500',
};
function Box({ color }: { color: string }) {
return <div className={colorMap[color]}>Content</div>;
}
// ✅ 更复杂的组合 —— 使用 clsx / tailwind-merge
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
function Button({ primary, disabled, className }: ButtonProps) {
return (
<button className={twMerge(clsx(
'px-4 py-2 rounded font-medium transition-colors',
primary ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-gray-200 text-gray-800',
disabled && 'opacity-50 cursor-not-allowed',
className,
))}>
Click
</button>
);
}Tailwind v4 重大变化
Tailwind v4(2025)从 PostCSS 插件转向基于 Rust 的独立引擎(Oxide):
Tailwind v3 Tailwind v4
--- ---
PostCSS 插件 独立 Rust 引擎
tailwind.config.js 配置 CSS-first 配置(@theme 指令)
content 路径手动配置 自动内容检测
@tailwind base/components @import "tailwindcss"css
/* v4: CSS-first 配置 */
@import "tailwindcss";
@theme {
--color-primary: #4a90d9;
--spacing-18: 4.5rem;
--font-display: "Inter", sans-serif;
}方案横向对比
Runtime vs Zero-Runtime vs Utility-First
| 维度 | Runtime (styled-components) | Zero-Runtime (Vanilla Extract) | Utility-First (Tailwind) |
|---|---|---|---|
| 样式写法 | Tagged Template / Object | TypeScript Object(.css.ts) | HTML class |
| 动态样式 | ✅ 完全运行时动态 | ⚠️ 需要 CSS 变量过渡 | ⚠️ class 映射表 |
| 运行时开销 | ❌ JS 解析 + 注入 | ✅ 零开销 | ✅ 零开销 |
| Bundle 影响 | ❌ 库体积 ~12KB gzipped | ✅ 无额外 JS | ✅ 无额外 JS |
| TypeScript | ✅ 泛型 props | ✅ 原生 TS | ⚠️ 通过 IDE 插件 |
| SSR 兼容 | ⚠️ 需要额外配置 | ✅ 天然支持 | ✅ 天然支持 |
| 学习曲线 | 低(写 CSS) | 中(新语法) | 中(记忆 class 名) |
| 调试体验 | ⚠️ 生成的 class 不直观 | ⚠️ 哈希 class | ⚠️ 长 class 列表 |
| RSC 兼容 | ❌ 需要 Client Component | ✅ 可用于 Server Component | ✅ 可用于 Server Component |
选型决策树
你的项目需要大量运行时动态样式?
├─ 是 → 有性能敏感场景吗?
│ ├─ 是 → Vanilla Extract + CSS Variables
│ └─ 否 → styled-components / Emotion
└─ 否 → 使用 React Server Components?
├─ 是 → Tailwind CSS / Vanilla Extract / CSS Modules
└─ 否 → 团队偏好?
├─ 喜欢写 CSS → CSS Modules / styled-components
├─ 喜欢 TS → Vanilla Extract
└─ 追求开发速度 → Tailwind CSS各方案的真实使用者
| 方案 | 典型使用者 |
|---|---|
| styled-components | Atlassian、Revolut、Framer |
| Emotion | MUI (Material UI)、Storybook |
| Vanilla Extract | Shopify Polaris、Seek |
| StyleX | Meta (Facebook, Instagram) |
| Tailwind CSS | Vercel、Shopify Hydrogen、GitHub Primer |
| CSS Modules | Next.js 默认方案、Vite 内建支持 |
CSS Modules:被低估的方案
虽然不属于 CSS-in-JS,但 CSS Modules 是许多场景下最务实的选择。
css
/* Button.module.css */
.root {
padding: 8px 16px;
border-radius: 4px;
}
.primary {
background: #4a90d9;
color: #fff;
}
.primary:hover {
background: #357abd;
}tsx
// Button.tsx
import styles from './Button.module.css';
import clsx from 'clsx';
function Button({ primary, children }: ButtonProps) {
return (
<button className={clsx(styles.root, primary && styles.primary)}>
{children}
</button>
);
}优势:
- 零运行时开销,零 JS 体积
- 构建时作用域隔离(Webpack/Vite 内建支持)
- 学习成本为零(就是写 CSS)
- 完美支持 SSR / RSC
- 可配合
typed-css-modules生成.d.ts获得类型提示
局限:
- 动态样式需要配合 CSS 变量或 class 切换
- 样式和组件不在同一文件(非共置)
- 没有 Theme 系统(需自行封装 CSS 变量)
动态样式的 Zero-Runtime 替代方案
Runtime CSS-in-JS 最大的优势是 props 驱动的动态样式。Zero-Runtime 方案如何实现?
策略一:CSS 变量桥接
tsx
// Vanilla Extract
import { style, createVar } from '@vanilla-extract/css';
const accentColor = createVar();
const padding = createVar();
export const box = style({
vars: {
[accentColor]: '#4a90d9',
[padding]: '16px',
},
backgroundColor: accentColor,
padding: padding,
});tsx
// 组件中通过 style prop 动态修改 CSS 变量
import { box } from './box.css';
import { assignInlineVars } from '@vanilla-extract/dynamic';
function Box({ color, pad }: { color: string; pad: number }) {
return (
<div
className={box}
style={assignInlineVars({
[accentColor]: color,
[padding]: `${pad}px`,
})}
>
Content
</div>
);
}策略二:预定义变体 + data 属性
tsx
// 预定义有限的变体
const colorVariants = styleVariants({
blue: { background: '#4a90d9' },
red: { background: '#e74c3c' },
green: { background: '#27ae60' },
});
// 或使用 data 属性
const box = style({
selectors: {
'&[data-variant="primary"]': { background: '#4a90d9' },
'&[data-variant="danger"]': { background: '#e74c3c' },
},
});
<div className={box} data-variant="primary">Content</div>对比
Runtime CSS-in-JS CSS 变量桥接
动态能力 任意 JS 表达式 CSS 变量值 + 预定义变体
运行时开销 序列化 + 注入 仅修改 style 属性
类型安全 泛型 props 枚举变体
适合场景 复杂交互动画/主题 大部分 UI 组件面试高频题
1. styled-components 的样式是怎么注入到页面中的?
通过 CSSStyleSheet API(insertRule)或动态创建 <style> 标签。组件渲染时,样式函数求值生成 CSS 字符串,经过哈希处理得到唯一 className,然后检查缓存——如果该哈希未被注入过,则调用 insertRule 将 CSS 规则插入到 <style> 标签中。在 SSR 场景下,通过 ServerStyleSheet 收集渲染过程中产生的所有样式,输出为 <style> 标签的 HTML 字符串,随页面一起返回。
2. 为什么 styled-components 在 SSR 中需要特殊处理?
因为 styled-components 的样式是运行时生成的——服务端渲染 HTML 时需要同步收集所有被使用到的样式。如果不做处理:①服务端返回的 HTML 没有对应样式 → 浏览器呈现无样式内容(FOUC);②客户端 Hydration 时重新注入样式 → 页面闪烁。解决方案是使用 ServerStyleSheet 包裹渲染过程,拦截所有 insertRule 调用,将样式收集后注入到 HTML 的 <head> 中。
3. Tailwind CSS 的 class 名是如何生效的?为什么动态拼接不生效?
Tailwind 在构建时通过静态分析源码文件中的 class 名,只生成被使用到的 CSS 规则(Tree-shaking)。它扫描的是完整的 class 字符串字面量(如 bg-red-500),而不是运行 JS 代码。因此 bg-${color}-500 这种动态拼接在构建时无法被识别,对应的 CSS 规则就不会被生成。正确做法是使用完整的 class 名映射表,确保所有可能的值都以字面量形式出现在源码中。
4. 什么是 Zero-Runtime CSS-in-JS?与 Runtime 方案的核心区别?
Zero-Runtime CSS-in-JS 在构建时(通过 Babel/SWC 插件)将样式代码提取为静态 CSS 文件,运行时不执行任何样式相关的 JS 代码。核心区别:①Runtime 方案在浏览器中执行序列化 + 注入,有 JS 开销;Zero-Runtime 在编译时完成,运行时等价于传统 CSS。②Runtime 支持任意 JS 表达式的动态样式;Zero-Runtime 需要通过 CSS 变量或预定义变体实现动态性。③Zero-Runtime 天然支持 RSC 和流式 SSR,而 Runtime 方案需要额外配置。代表方案:Vanilla Extract、StyleX、Panda CSS。
5. 如何在项目中选择合适的样式方案?
关键决策因素:
- React Server Components(RSC) —— 如果使用 RSC,排除 Runtime CSS-in-JS(需要
"use client") - 动态样式需求 —— 大量运行时动态样式 → Runtime 方案;有限变体 → Zero-Runtime 或 Tailwind
- 性能敏感度 —— 高 → 避免 Runtime;一般 → 均可
- 团队偏好 —— 喜欢写 CSS → CSS Modules / styled-components;喜欢 TS → Vanilla Extract;追求速度 → Tailwind
- 设计系统 —— 有严格设计规范 → Tailwind(Design Token 约束)或 Vanilla Extract(类型约束)
- 已有技术栈 —— 使用 MUI → Emotion;使用 Next.js → CSS Modules / Tailwind
不存在"最好"的方案,只有最适合团队和项目约束的方案。
追问思考
- styled-components v6 引入了
shouldForwardProp作为默认行为和$前缀约定(Transient Props),这解决了什么问题? - Tailwind CSS 的"原子化"思路和传统 OOCSS(Object-Oriented CSS)有什么联系和区别?
- 在微前端架构下,不同子应用使用不同的样式方案(如 A 用 styled-components,B 用 Tailwind),可能遇到哪些问题?如何解决?
- CSS 变量(
--custom-property)在 Zero-Runtime 方案中扮演了什么角色?它的性能特性如何? - 如果让你设计一个组件库,你会选择哪种样式方案?为什么?需要考虑哪些因素?