Chapter 08

样式方案:CSS Modules 与 Tailwind CSS

从全局污染到局部作用域,掌握现代 React 项目中最实用的两种样式方案

1. CSS in React 的三大挑战

全局命名污染

普通 CSS 的选择器都是全局的。在 ComponentA.css 中定义 .title { color: red; },会影响整个应用中所有带 class="title" 的元素,不论它们在哪个组件中。

命名冲突

随着项目规模增大,开发者需要记住所有已使用的类名,避免重名。BEM(.block__element--modifier)等命名规范试图解决这个问题,但依赖人工约束,难以维护。

CSS 与 JS 状态同步

组件有很多动态样式需求(展开/收起、主题切换、错误状态),需要在 CSS 和 JavaScript 之间传递状态,用字符串拼接类名既繁琐又容易出错。

2. 样式方案全景对比

方案隔离性动态样式学习成本包体积推荐场景
原生 CSS无(全局)需手动拼类名最低最小小项目、快速原型
CSS Modules文件级别需手动拼类名传统组件库、精细控制
Styled-components组件级别原生 JS 表达式较大(运行时)设计系统、主题化
Tailwind CSS无(但不需要)条件类名中(需记类名)极小(purge)快速开发、新项目首选
Sass/SCSS无(需手动)需手动老项目迁移、嵌套样式

3. CSS Modules 详解

CSS Modules 是一种让 CSS 文件中的类名在构建时自动转换为唯一哈希值的技术。每个组件的样式天然隔离,不需要担心命名冲突。Vite 内置支持,文件名以 .module.css 结尾即可。

/* Button.module.css — 写法与普通 CSS 完全一样 */

/* .button 在构建后会变成类似 _button_3kx2a_1 的唯一类名 */
.button {
  padding: 8px 16px;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s;
}

.primary {
  background: #3178C6;
  color: white;
}

.danger {
  background: #ef4444;
  color: white;
}

.disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* :global — 该选择器不做哈希转换,保持全局 */
:global(.third-party-widget) {
  border: 1px solid red;
}
// Button.tsx — 导入 CSS Modules
import styles from './Button.module.css';

// styles 是一个对象:{ button: '_button_3kx2a_1', primary: '_primary_3kx2a_2', ... }

type ButtonVariant = 'primary' | 'danger';

interface ButtonProps {
  variant?: ButtonVariant;
  disabled?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

function Button({ variant = 'primary', disabled, children, onClick }: ButtonProps) {
  // 拼接多个类名
  const className = [
    styles.button,
    styles[variant],
    disabled ? styles.disabled : '',
  ].filter(Boolean).join(' ');

  return (
    <button
      className={className}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

composes — 组合样式

/* 用 composes 复用已有类(类似继承)*/
.base {
  padding: 8px 16px;
  border-radius: 6px;
}

.primaryBtn {
  composes: base;  /* 继承 base 的所有样式 */
  background: #3178C6;
  color: white;
}

/* 还可以跨文件 composes */
.specialBtn {
  composes: flexCenter from './utils.module.css';
  font-weight: 700;
}

TypeScript 类型支持

默认情况下 CSS Modules 导入类型为 any。使用 typed-css-modules 可以生成精确的类型定义文件,让编辑器提供类名自动补全和错误检查。

# 生成 .module.css.d.ts 类型声明文件
npx tcm src --watch

# 生成后,Button.module.css.d.ts 内容类似:
// declare const styles: { button: string; primary: string; danger: string; };
// export default styles;

4. Tailwind CSS 详解

Tailwind CSS 是"功能类优先"(Utility-First)的 CSS 框架。它不提供 .btn 这样的预制组件类,而是提供大量原子类(p-4 text-blue-500 flex),每个类只做一件事。

🧠
思路转变:传统 CSS 是"给 HTML 元素起名字,再在 CSS 文件里描述它"。Tailwind 是"直接在 HTML 上堆功能类,消灭 CSS 文件"。React 组件已经实现了复用,样式提取到 CSS 类的意义因此减少。

安装配置(Vite + Tailwind v4)

# 安装 Tailwind(v4 使用新的配置方式)
npm install tailwindcss @tailwindcss/vite

# vite.config.ts
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react(), tailwindcss()],
});
/* src/index.css — 只需一行 */
@import 'tailwindcss';

常用类分类速查

// 布局
flex items-center justify-between  // Flexbox
grid grid-cols-3 gap-4              // Grid
w-full h-screen max-w-lg           // 尺寸

// 间距
p-4         // padding: 1rem (16px)
px-6 py-2   // padding x轴/y轴
mt-8 mb-4   // margin top/bottom

// 颜色与背景
text-gray-900 bg-white              // 白色背景黑色文字
text-blue-500 bg-blue-50            // 蓝色系
border border-gray-200              // 边框

// 字体
text-sm text-base text-lg text-xl  // 字号
font-normal font-medium font-bold  // 字重
leading-relaxed tracking-wide      // 行高/字间距

// 圆角与阴影
rounded rounded-lg rounded-full    // 圆角
shadow shadow-md shadow-lg         // 阴影

// 交互
hover:bg-blue-600 focus:ring-2     // 悬停和聚焦
cursor-pointer select-none         // 鼠标样式
transition duration-200            // 过渡动画

实战示例:用 Tailwind 写 Card 组件

// 用 Tailwind 类名直接在 JSX 中编写样式
function UserCard({ user }: { user: User }) {
  return (
    <div className="bg-white rounded-xl shadow-md p-6 flex items-center gap-4 hover:shadow-lg transition-shadow">
      <img
        src={user.avatar}
        alt={user.name}
        className="w-12 h-12 rounded-full object-cover"
      />
      <div>
        <h3 className="text-base font-semibold text-gray-900">{user.name}</h3>
        <p className="text-sm text-gray-500">{user.email}</p>
      </div>
    </div>
  );
}

cn() 工具函数:条件类名的最优解

在 React 中经常需要根据条件拼接 Tailwind 类名。clsx 处理条件合并,tailwind-merge 解决 Tailwind 类冲突(如同时有 p-2p-4,后者优先)。

# 安装
npm install clsx tailwind-merge
// lib/utils.ts — 定义 cn 工具函数
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// 使用示例
cn('p-2', 'p-4')                      // → 'p-4'(冲突解决)
cn('base-class', isActive && 'active')  // → 条件添加
cn({ 'text-red-500': hasError, 'text-green-500': isSuccess })

组件变体:cva(class-variance-authority)

当组件有多个变体(variant/size/color)时,手动拼字符串很容易失控。cva 提供结构化的变体管理,配合 TypeScript 有完整的类型提示。

// npm install class-variance-authority
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils';

// 定义按钮变体
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-blue-600 text-white hover:bg-blue-700',
        outline: 'border border-gray-300 hover:bg-gray-100',
        ghost:   'hover:bg-gray-100 text-gray-700',
        danger:  'bg-red-500 text-white hover:bg-red-600',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-base',
        lg: 'h-12 px-6 text-lg',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
);

// 组件 Props 从 cva 推断类型
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

function Button({ variant, size, className, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

// 用法(TypeScript 会提示合法值)
// <Button variant="danger" size="lg">删除</Button>

5. 响应式设计

Tailwind 使用移动优先的断点前缀。不带前缀的类名应用于所有屏幕,带前缀的类名只在指定断点及以上生效。

前缀最小宽度对应设备
(无前缀)0px所有设备(移动优先)
sm:640px小屏手机横屏
md:768px平板
lg:1024px笔记本
xl:1280px桌面显示器
2xl:1536px大显示器
// 移动:单列;平板:两列;桌面:三列
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  ...
</div>

// 移动:隐藏;桌面:显示
<aside className="hidden lg:block">侧边栏</aside>

// 移动:竖排;桌面:横排
<nav className="flex flex-col md:flex-row gap-2 md:gap-6">...</nav>

6. 深色模式

Tailwind 的 dark: 前缀配合 HTML 根元素的 class="dark" 即可实现深色模式切换。

// tailwind.config(v3)或直接在 v4 中开箱即用
// darkMode: 'class'  — 通过 class 切换(而非系统偏好)

// 组件:深色下切换背景和文字颜色
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
  <h1 className="text-2xl font-bold">标题</h1>
</div>
// ThemeToggle.tsx — 切换深色/浅色模式
import { useState, useEffect } from 'react';

function ThemeToggle() {
  const [dark, setDark] = useState(() =>
    localStorage.getItem('theme') === 'dark'
  );

  useEffect(() => {
    // 在 html 根元素上切换 class
    document.documentElement.classList.toggle('dark', dark);
    localStorage.setItem('theme', dark ? 'dark' : 'light');
  }, [dark]);

  return (
    <button
      onClick={() => setDark((d) => !d)}
      className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800"
    >
      {dark ? '☀️ 浅色' : '🌙 深色'}
    </button>
  );
}

7. Headless UI:无样式组件库

Headless UI(无头组件库)提供完全可访问的、功能完整的组件,但不带任何样式。你可以用 Tailwind 或任何方式来定义外观。核心理念:逻辑与样式分离

主流选择:

// npm install @radix-ui/react-dialog
import * as Dialog from '@radix-ui/react-dialog';

// 使用 Radix Dialog + Tailwind 样式
function MyDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="px-4 py-2 bg-blue-600 text-white rounded-lg">
        打开对话框
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
          <Dialog.Title className="text-lg font-bold mb-2">标题</Dialog.Title>
          <Dialog.Description className="text-gray-600">描述内容</Dialog.Description>
          <Dialog.Close className="absolute top-4 right-4 text-gray-400 hover:text-gray-700">
            ✕
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
🌟
shadcn/ui:不是组件库,而是一个代码集合。它基于 Radix UI + Tailwind,通过 CLI 将组件源码直接复制到你的项目中,你拥有完整控制权,可以随意修改。是 2024 年最受欢迎的 React UI 方案之一。