React Styling
样式问题的本质
在 React 中,组件是封装了 UI 和逻辑的可复用单元。样式问题关注的是:如何让样式与组件的封装边界对齐,同时保持开发效率和运行时性能。
传统的全局 CSS 有几个根本性问题:
- 命名冲突:不同组件可能使用相同的类名,导致样式互相覆盖
- 依赖关系不清晰:很难知道某个类名被哪些组件使用,修改时担心影响其他组件
- Dead code 难以清除:删除组件后,对应的 CSS 可能不敢删,因为不确定是否被其他地方引用
React 的样式方案都在试图解决这些问题,只是解决方式不同。
CSS Modules:局部作用域的 CSS
核心机制
CSS Modules 的思想是:把 CSS 类名编译成唯一的哈希名,让每个组件的样式都局部化。
/* Button.module.css */
.button {
padding: 8px 16px;
border-radius: 4px;
background-color: blue;
color: white;
}
.button:hover {
background-color: darkblue;
}
/* Button.jsx */
import styles from './Button.module.css';
function Button({ children, onClick }) {
return (
<button className={styles.button} onClick={onClick}>
{children}
</button>
);
}
编译后,styles.button 可能变成类似 Button_button__3d7X8 这样的哈希名,确保只在当前组件内有效。
CSS Modules 的优势
1. 零运行时开销
CSS Modules 的编译时转换不涉及 JavaScript 运行时,生成的 CSS 是静态的。浏览器下载和解析 CSS 的速度与普通 CSS 无异。
2. 完整的 CSS 功能
你可以使用所有 CSS 特性:伪类、伪元素、媒体查询、@keyframes、@font-face 等。不需要学习新的 DSL。
3. 与设计工具对接方便
设计师通常给出的是 CSS/设计规范,用 CSS Modules 可以直接对应。
4. 静态分析友好
因为类名是编译时确定的,可以做 dead code 检测、打包优化(只打包用到的样式)。
CSS Modules 的局限
1. 条件样式写法繁琐
// 需要用 classnames 库或模板字符串来处理条件样式
import styles from './Card.module.css';
import classNames from 'classnames';
function Card({ isHighlighted, children }) {
return (
<div className={classNames(styles.card, {
[styles.highlighted]: isHighlighted
})}>
{children}
</div>
);
}
2. 主题切换需要额外配置
需要用 CSS Variables 或者运行时的 ThemeProvider。
3. 伪类选择器在复杂场景下仍然可能冲突
CSS Modules 只能局部化类名,如果你用组合选择器(.card .title),仍然可能影响子组件。
CSS Modules 与 CSS Variables 的结合
这是目前我认为最适合中大型项目的方案之一:
/* variables.css */
:root {
--color-primary: #3b82f6;
--color-secondary: #64748b;
--spacing-sm: 8px;
--spacing-md: 16px;
--radius: 4px;
}
/* Button.module.css */
.button {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
background-color: var(--color-primary);
}
.buttonSecondary {
background-color: var(--color-secondary);
}
这种方式结合了 CSS Modules 的局部作用域和 CSS Variables 的主题切换能力。
styled-components:CSS-in-JS 的先驱
核心机制
styled-components 的核心思想是:用模板字符串定义样式,样式和组件本身就是一体。
import styled from 'styled-components';
const Button = styled.button`
padding: 8px 16px;
border-radius: 4px;
background-color: blue;
color: white;
&:hover {
background-color: darkblue;
}
`;
// 使用
function App() {
return <Button>Click me</Button>;
}
编译后,styled-components 会在运行时生成唯一的类名(类似 CSS Modules),同时创建 <style> 标签注入样式。
styled-components 的优势
1. 样式与组件绑定,不需要命名
你不需要想类名,样式就是组件定义的一部分。
2. 可以在样式中使用 props 和主题
const Button = styled.button`
padding: 8px 16px;
border-radius: 4px;
background-color: ${props => props.primary ? 'blue' : 'gray'};
color: white;
`;
function App() {
return (
<>
<Button primary>Primary</Button>
<Button>Secondary</Button>
</>
);
}
3. 支持主题系统(ThemeProvider)
import { ThemeProvider } from 'styled-components';
const theme = {
colors: {
primary: '#3b82f6',
secondary: '#64748b'
}
};
function App() {
return (
<ThemeProvider theme={theme}>
<Button>Themed</Button>
</ThemeProvider>
);
}
4. 自动关键 CSS
styled-components 会把关键 CSS(首屏需要的内容)生成内联样式,优化首屏渲染。
styled-components 的局限
1. 运行时开销
样式是在 JavaScript 运行时生成的,每次渲染都可能重复执行样式代码。虽然开销通常很小,但在大型应用或低端设备上可能成为问题。
2. 服务器端渲染复杂度
SSR 需要额外配置来收集和注入生成的关键 CSS,否则会有样式闪烁(FOUC)。
3. 调试体验
DevTools 中的类名是哈希值,不如原始类名直观。
4. 与传统 CSS 工作流对接困难
设计师给出的 CSS 规范需要转换成 styled-components 语法,不能直接复用。
styled-components 的性能优化
// 使用 attrs 添加静态属性,避免运行时计算
const Input = styled.input.attrs(props => ({
type: props.type || 'text'
}))`
padding: 8px;
`;
// 使用 cx 做条件样式合并
import { css, cx } from 'styled-components';
const Button = styled.button`
padding: 8px 16px;
${props => props.primary && css`
background-color: blue;
color: white;
`}
`;
Emotion:更轻量的选择
Emotion 与 styled-components 的区别
Emotion 可以看作是 styled-components 的"轻量版"。两者 API 几乎完全兼容,但有一些关键区别:
| 特性 | styled-components | Emotion |
|---|---|---|
| 包大小 | ~12kb | ~7kb |
| 运行时开销 | 每次渲染生成样式 | 缓存后无运行时开销 |
| SSR | 需要额外配置 | 内置 renderStylesToString |
| 调试 | 哈希类名 | 哈希类名 + source maps |
Emotion 的两种使用方式
1. styled API(与 styled-components 相同)
import styled from '@emotion/styled';
const Button = styled.button`
background-color: blue;
color: white;
`;
2. css prop(更声明式)
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
function App() {
return (
<button css={css`
background-color: blue;
color: white;
`}>
Click me
</button>
);
}
css prop 的好处是样式和 JSX 在同一个位置,不需要为小样式创建额外的组件。
CSS-in-JS 库的共同问题
Emotion 和 styled-components 作为 CSS-in-JS 方案,都面临一些共同挑战:
1. 样式重复注入
如果多个组件使用相同的样式,CSS-in-JS 可能会重复生成相同样式代码。虽然运行时会有缓存,但 CSS 字符串本身可能被多次注入。
2. 伪元素/伪类的处理
const Tooltip = styled.div`
&::before {
content: '';
position: absolute;
top: -5px;
left: 50%;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid black;
}
`;
这种方式写起来不如普通 CSS 直观。
Tailwind CSS:Utility-First 的方式
核心理念
Tailwind CSS 的思路与上面所有方案都不同。它不是"定义组件样式然后应用到组件",而是提供大量低层次的 utility classes,让你在 JSX 中直接组合。
function Button({ children, primary }) {
return (
<button className={`
px-4 py-2
rounded
${primary ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}
hover:bg-blue-600
transition-colors
`}>
{children}
</button>
);
}
Tailwind 的优势
1. 消除命名烦恼
你不需要想类名,不需要想组织结构,直接在 JSX 中组合。
2. 一致性保证
Tailwind 内置了一套设计系统(颜色、间距、字体等),所有值都是预定义的,确保 UI 的一致性。
3. 极致的压缩能力
Tailwind 会在构建时扫描你的代码,只打包实际使用到的 utility classes。最终产出的 CSS 非常小(通常 < 10kb gzip)。
4. 响应式设计内置
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{/* ... */}
</div>
Tailwind 的局限
1. JSX 变得臃肿
组件的 JSX 可能变得很长,样式代码和内容混在一起。
// 这种写法在 Tailwind 中很常见
<button className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed">
Save changes
</button>
2. 学习曲线
需要记住大量 utility class names。
3. 与传统 CSS 工作流差异大
不适合需要复用复杂 CSS 规范的场景。
4. 动态样式处理
对于需要基于 props 动态计算的样式,Tailwind 的 JIT 模式需要特殊处理:
// 需要用方括号语法动态生成
<div className={`bg-[${color}]`}>
Content
</div>
Tailwind CSS in React 的最佳实践
1. 使用 clsx 或 classnames 库简化条件样式
import clsx from 'clsx';
function Badge({ variant, size }) {
return (
<span className={clsx(
'rounded-full font-semibold',
{
'px-2 py-1 text-xs': size === 'sm',
'px-3 py-1.5 text-sm': size === 'md',
'px-4 py-2 text-base': size === 'lg',
},
{
'bg-blue-100 text-blue-800': variant === 'blue',
'bg-green-100 text-green-800': variant === 'green',
'bg-red-100 text-red-800': variant === 'red',
}
)}>
Badge
</span>
);
}
2. 提取重复样式为组件
// 而不是每次都写长串的 utility classes
const Button = ({ children, variant = 'primary', size = 'md', ...props }) => (
<button
className={clsx(
'font-medium rounded-md transition-colors',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
{
'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500': variant === 'primary',
'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500': variant === 'secondary',
},
{
'px-2.5 py-1.5 text-xs': size === 'sm',
'px-3 py-1.5 text-sm': size === 'md',
'px-4 py-2 text-base': size === 'lg',
}
)}
{...props}
>
{children}
</button>
);
3. 使用 Tailwind 的 @apply 提取复杂样式
/* button.css */
.btn-primary {
@apply bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition-colors;
}
/* Button.jsx */
<button className="btn-primary">
Primary Button
</button>
各方案的权衡对比
开发体验 vs 运行时性能
| 方案 | 开发体验 | 运行时性能 | SSR 复杂度 | 学习曲线 |
|---|---|---|---|---|
| CSS Modules | 好 | 最好(零运行时) | 低 | 低 |
| styled-components | 很好 | 中等(运行时生成) | 中等 | 低 |
| Emotion | 很好 | 较好(有缓存) | 低 | 低 |
| Tailwind CSS | 好(但 JSX 臃肿) | 最好(极小 CSS) | 低 | 中高 |
动态样式处理能力
| 方案 | 主题切换 | 动态计算样式 | 伪元素/伪类 |
|---|---|---|---|
| CSS Modules + CSS Variables | 很好 | 需要 CSS Variables | 好 |
| styled-components | 很好 | 很好 | 中等 |
| Emotion | 很好 | 很好 | 中等 |
| Tailwind CSS | 好 | 需要 JIT 配置 | 好 |
项目规模适配
小项目(< 10 个组件)
任意方案都可以。根据团队熟悉度和现有代码风格选择即可。
中项目(10 - 100 个组件)
推荐 CSS Modules + CSS Variables 或 Tailwind CSS。前者适合需要精细控制的场景,后者适合需要快速迭代的场景。
大项目(> 100 个组件)
推荐 CSS Modules + CSS Variables 或 Emotion。这两者都有较好的可维护性,主题切换能力强,运行时开销可控。
在 React 里处理响应式样式的最佳实践
1. Mobile-First 媒体查询
Tailwind CSS 默认使用 mobile-first 方式,其他方案也应该遵循:
/* 先写移动端默认样式 */
.container {
width: 100%;
padding: 16px;
}
/* 然后用 min-width 逐步增强 */
@media (min-width: 768px) {
.container {
max-width: 720px;
padding: 24px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 960px;
padding: 32px;
}
}
2. CSS Container Queries(新兴方案)
Container Queries 是 CSS 的新特性,允许样式基于容器大小而不是视口大小来响应:
/* 定义容器 */
.card-container {
container-type: inline-size;
}
/* 容器查询 */
.card {
display: grid;
grid-template-columns: 1fr;
}
@container (min-width: 400px) {
.card {
grid-template-columns: 2fr 1fr;
}
}
这个特性与组件化思维更契合——组件可以根据自身容器大小来调整布局,而不是依赖视口。
3. 使用 Hook 做响应式状态
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}
function Sidebar() {
const isLargeScreen = useMediaQuery('(min-width: 1024px)');
return (
<aside className={isLargeScreen ? 'sidebar-open' : 'sidebar-collapsed'}>
{/* ... */}
</aside>
);
}
4. 避免过度响应式
// 错误:把太多东西变成响应式
<div className={`
${isMobile ? 'flex-col' : 'flex-row'}
${isTablet ? 'p-4' : 'p-2'}
${isDesktop ? 'gap-4' : 'gap-2'}
${isLarge ? 'text-lg' : 'text-base'}
`}>
// 正确:只在真正需要的地方做响应式
<div className="flex flex-col md:flex-row gap-2 md:gap-4">
<Sidebar className={isSidebarOpen ? 'open' : 'closed'} />
<MainContent />
</div>
面试中的表达
面试官问样式方案,通常是在考察你对 CSS 原理和工程实践的理解:
React 的样式方案选择本质上是在「封装性」和「灵活性」之间做权衡。CSS Modules 通过编译时哈希实现局部作用域,零运行时开销,适合需要精细控制的中大型项目。styled-components 和 Emotion 作为 CSS-in-JS 方案,把样式和组件绑定,动态样式处理能力强,但有运行时开销。Tailwind CSS 的 utility-first 方式省去了命名烦恼,极致的压缩能力,但会让 JSX 变得臃肿。
我目前的项目用的是 CSS Modules 配合 CSS Variables,主要考虑是零运行时开销、主题切换方便、可以和现有 CSS 规范对接。如果项目追求开发速度且 UI 一致性要求高,Tailwind 也是很好的选择。
延展阅读
- CSS Modules 官方文档 — CSS Modules 的完整规范和用法
- styled-components 官方文档 — styled-components 的完整 API 文档
- Tailwind CSS 官方文档 — Tailwind CSS 的完整文档
- [CSS-in-JS 方案的对比分析](https://github.com/A-gentler sentiment/CSS-in-JS) — 各大 CSS-in-JS 方案的综合对比
- CSS Container Queries 规范 — MDN 关于 Container Queries 的文档