React Styling

深入理解 React 中各种样式方案的权衡:CSS Modules 的局部作用域、styled-components 和 Emotion 的 CSS-in-JS 方案、Tailwind CSS 的 utility-first 方式,以及如何选择合适的样式策略。

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 也是很好的选择。


延展阅读