#React#TypeScript

Tailwind CSS 工程化实践

Tailwind 写小项目爽,大项目容易乱(一堆重复 class、设计 token 漂移)。本文讲清 Tailwind 工程化:设计系统配置(token 化颜色/间距)、组件抽象(何时抽组件 vs @apply vs cva)、避免 class 堆积、与设计稿对齐、暗色主题与响应式,让 Tailwind 在大项目里也清爽。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年7月6日·更新于 2026年7月23日

Tailwind 这几年几乎成了 React 项目的事实标准。写个小页面,确实爽:不用切 CSS 文件、不用想类名、JIT 按需生成、产物体积还小。但项目一大,问题就开始冒头——同一个按钮的 class 列表在二十个文件里被复制粘贴,设计稿把主色从 #3B82F6 改成 #2563EB,你 grep 半天也改不干净;一个卡片组件的 className 长到三百字符,谁也不敢动。

这些问题不是 Tailwind 的锅,是没有把它当设计系统来用的锅。Tailwind 的 config 本质就是一套设计 token,组件抽象也有明确的取舍规则。这篇文章讲清楚:在大项目里,怎么让 Tailwind 既保持原子 CSS 的灵活,又不失控。

一、Tailwind 的价值,和它在大项目里的痛点

先说清楚 Tailwind 为什么值得用,再说它为什么会乱。

价值三点:

  1. 原子 CSS,不用想命名。flex items-center gap-4 直接写,不用纠结这个容器叫 .card-header 还是 .card-title-wrap。命名是 CSS 维护成本的大头,原子类把这个成本砍掉了。
  2. JIT 按需生成,产物小。老版本 Tailwind 的痛点是 purge 配置复杂、产物大;JIT 之后,你写什么生成什么,没用到的 class 一个字节都不会进打包产物。
  3. 样式不跨文件漂移。改一个按钮,直接在按钮那个文件里改,不用跳到 button.css 里找对应规则。样式和结构在同一个地方,可读性好。

但项目一大,三个痛点会准时出现:

  • class 堆积:同一个视觉模式(比如一张卡片)在十几处重复写一长串 class,改一处要改十几处。
  • token 漂移:颜色、间距随手用 bg-[#3B82F6]、mt-[13px] 这种任意值硬编码,设计稿一改全站乱套。
  • 组件 className 失控:为了让一个组件支持五种变体,className 拼接成一坨三元表达式,可读性比传统 CSS 还差。

下面逐个解决。

二、设计系统配置:把 token 钉死在 config 里

Tailwind 工程化的第一步,是把所有设计 token 收敛到 tailwind.config 里,全站只通过语义化名字引用,禁止散用任意值。

ts
// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        // 语义化,别用 blue-500 这种工程名
        brand: {
          DEFAULT: 'rgb(var(--brand) / <alpha-value>)',
          hover: 'rgb(var(--brand-hover) / <alpha-value>)',
        },
        surface: {
          DEFAULT: 'rgb(var(--surface) / <alpha-value>)',
          muted: 'rgb(var(--surface-muted) / <alpha-value>)',
        },
        text: {
          DEFAULT: 'rgb(var(--text) / <alpha-value>)',
          muted: 'rgb(var(--text-muted) / <alpha-value>)',
        },
        danger: 'rgb(var(--danger) / <alpha-value>)',
      },
      spacing: {
        // 设计稿只有 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64
        // 别出现 mt-[13px] 这种
      },
      borderRadius: {
        card: '12px',
        pill: '9999px',
      },
      fontSize: {
        // 和设计稿的字号阶梯严格对齐
        xs: ['12px', '16px'],
        sm: ['14px', '20px'],
        base: ['16px', '24px'],
      },
    },
  },
} satisfies Config;

注意上面用 rgb(var(--brand) / <alpha-value>) 这种写法——这是用 CSS 变量承载颜色值,配合 <alpha-value> 让 Tailwind 的 bg-brand/50 这种透明度修饰符仍然可用。这样设计稿改主色,你只改一处 CSS 变量(还能顺便支持暗色主题,见第六节),全站跟着变。

硬性规则:在项目里加一条 lint(或 code review 卡),禁止 bg-[#xxx]、p-[13px] 这类任意值,除非是真正一次性、不会复用的装饰性值。一旦某个值出现第二次,就把它收进 config。这条规则执行到位,token 漂移就从根上断了。

三、组件抽象的三种武器:React 组件、@apply、cva

class 堆积的本质是「同一个模式被重复描述」。抽象方式有三种,各有适用场景。

第一种,抽 React 组件(首选)。一个模式重复三次,就抽成组件,把变化点变成 props:

tsx
// src/components/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';

const button = cva(
  // 基础样式:所有变体共享
  'inline-flex items-center justify-center rounded-pill font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-brand text-white hover:bg-brand-hover',
        secondary: 'bg-surface-muted text-text',
        ghost: 'bg-transparent text-text hover:bg-surface-muted',
        danger: 'bg-danger text-white',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof button> {}

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

注意 button({ variant, size, className }) 这一行——cva 会把变体类、基础类、外部传入的 className 合并起来,且支持 tailwind-merge 去重(后面细说)。用法就是 <Button variant="primary" size="md" />,变体类型由 TS 自动补全,填错直接报红。

第二种,@apply(谨慎用)。@apply 把原子类塞进传统 CSS:

css
/* 不推荐作为主要抽象方式 */
.btn-primary {
  @apply inline-flex items-center rounded-pill bg-brand px-4 text-white;
}

@apply 不是不能用,而是不能当主力。它的代价是:你重新引入了「想类名」的成本、重新引入了样式和结构分离、且失去了 cva 那种按 prop 动态切换的便利。我个人的规则是:@apply 只用于和第三方库集成或纯全局样式(比如 body { @apply bg-surface text-text; }),业务组件一律走 React + cva。

第三种,cva(变体管理的标准答案)。上面 Button 已经示范了。cva 的价值在于:变体定义是纯数据,类型能被 VariantProps 自动推导,配合 tailwind-merge 还能解决 class 冲突。

tsx
// 配合 tailwind-merge,让外部 className 能覆盖默认值
import { twMerge } from 'tailwind-merge';
import { clsx, type ClassValue } from 'clsx';

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

cn 这个工具函数几乎是 Tailwind 项目的标配。它解决一个高频痛点:外部传 className="px-8" 想覆盖组件默认的 px-4,如果不 merge,两个类都会生效,谁赢取决于 CSS 顺序(不可预测)。twMerge 会智能去掉冲突的 px-4,保留 px-8。

选型结论:React 组件处理「结构 + 行为」,cva 处理「同一组件的多变体」,@apply 只用于全局/集成场景。三者不是替代关系,是分工。

四、避免 class 堆积:重复模式就该抽出来

class 堆积的根因是该抽的东西没抽。判断标准很简单:同一段 class 字符串出现三次,就该抽象。但抽象的层次要选对:

  • 整个 UI 模式(卡片、表单项、列表行)→ 抽 React 组件。
  • 同一组件的视觉变体(按钮 size/variant、标签颜色)→ 用 cva。
  • 跨组件共享的少量原子样式(比如 flex items-center gap-2)→ 别抽,留着。这种短组合抽出来反而增加跳转成本。

一个常见误区是「过度抽象」。比如把 flex items-center justify-between 抽成一个 <FlexBetween> 组件——这是反模式。原子类的价值就是就地可读,把两三个类的组合包一层组件,反而让人得多看一层定义。抽组件的判断标准是「业务语义」,不是「class 复用」。UserCard 该抽,因为它有业务含义;FlexRow 不该抽,它只是排版工具。

另一个要点是合理拆 config 的粒度。spacing 别全开成任意值,但也别只留 Tailwind 默认那套——和设计稿对齐一套固定的间距阶梯(4 的倍数),既够用,又能逼着大家用 token。

五、与设计稿对齐:设计师和开发共用一套 token

Tailwind 工程化最容易被忽略的一环:设计侧。如果设计师在 Figma 里随手用颜色、随手拉间距,开发这边 config 再规范也白搭,因为对不上。

落地方式是建立 design token 双向同步:

  1. 设计师在 Figma 里定义 Variables(颜色、间距、字号),命名和 tailwind.config 一致——brand、surface、text-muted 这种语义名,不是 Blue/500 这种层级名。
  2. 用 Figma 的 Tokens Studio 或自建脚本,把 Figma Variables 导出成 tailwind.config 的格式。
  3. 设计稿改了 token,CI 跑一遍同步脚本,PR 里 config 自动更新;开发改了 config,反向同步回 Figma。

这一步打通后,「设计稿和实现对不上」的来回沟通能砍掉一大半。前期投入两三天搭同步链路,后面省下的 review 时间远超这个数。

如果团队还做不到自动同步,至少做到人工对齐命名:设计稿里叫 brand 的色,代码里就得是 bg-brand,不能是 bg-blue-600。命名一致是最低成本的对齐手段。

六、暗色主题与响应式:dark: 和断点

第二节埋的伏笔这里收网。因为颜色用 CSS 变量承载,暗色主题切换极其干净——只改变量值,不动 class:

css
/* globals.css */
:root {
  --brand: 37 99 235;        /* rgb(37, 99, 235) */
  --surface: 255 255 255;
  --text: 17 24 39;
  --surface-muted: 243 244 246;
}
.dark {
  --brand: 96 165 250;
  --surface: 17 24 39;
  --text: 243 244 246;
  --surface-muted: 31 41 55;
}

组件里完全不需要写 dark: 前缀——bg-surface text-text 在两种主题下都正确,因为变量值变了。这是把 token 化做到位后的红利:暗色主题从「每个组件加一遍 dark:」退化成「改一次根变量」。

只有真正需要随主题切换的非 token 样式(比如某张装饰图换图),才用 dark: 前缀:

tsx
<img src="/logo-light.svg" className="dark:hidden" />
<img src="/logo-dark.svg" className="hidden dark:block" />

响应式断点则反过来——移动优先(mobile first)。md: 表示「中等屏幕及以上生效」,所以基础类是移动端样式,断点修饰符是更大屏幕的覆盖:

tsx
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
  {/* 手机一列,平板两列,桌面三列,大屏四列 */}
</div>

断点值也要在 config 里收口,别让项目里出现 sm:[800px] 这种自定义断点。一套断点全站统一,设计稿的响应式规格才能对得上。

七、反模式:这些坑别踩

最后点几个高频反模式,都是我在 code review 里反复拦的:

  1. 全用 @apply。有人觉得原子类太长,把所有组件都写成 .card { @apply ... }——这是退化回传统 CSS:重新开始想命名、样式和结构分离、丢失变体能力。Tailwind 的核心价值被你亲手废掉了。
  2. 硬编码任意值满天飞。text-[#1a1a1a]、mt-[13px]、w-[347px] 散落各处。改设计稿时你 grep 不到、统一不了。出现第二次的值,必须进 config。
  3. 不抽组件,className 列表几百字符。一个 <div className="flex items-center justify-between rounded-card bg-surface p-4 shadow-sm hover:bg-surface-muted ..."> 复制十遍,改一处要改十遍。重复出现就抽组件。
  4. 把短组合过度抽象。<Flex> <FlexBetween> <Center> 这类「包装原子类」的组件是反模式,增加跳转成本却没带来语义价值。
  5. 变体用三元表达式硬拼。className={isPrimary ? 'bg-brand...' : isDanger ? 'bg-danger...' : ...}——这种就该用 cva,可读性和类型安全都差一个量级。

这些反模式共同的特征是:为了图一时省事,把 Tailwind 退化成了更难维护的传统 CSS。

小结

  • token 化是地基:颜色、间距、字号、圆角全收进 tailwind.config,用语义名引用,禁止散用任意值。用 CSS 变量承载颜色,顺带解锁暗色主题。
  • 组件抽象按场景分工:React 组件处理结构,cva 管多变体,@apply 只用于全局和集成,别当主力。
  • cn + tailwind-merge 是标配:解决外部 className 覆盖默认值的冲突,任何项目都该有这个工具函数。
  • 设计侧同步 token:Figma Variables 和 config 命名一致,自动或人工同步,把「对不上」的来回砍掉。
  • 反模式要早拦:全用 @apply、硬编码任意值、className 巨长不抽,本质都是把 Tailwind 用回了传统 CSS。

金句:Tailwind 不是「不用写 CSS」,而是「把设计系统编译进 className」——token 收得越紧,组件抽得越准,它在大项目里就越清爽。

Related

相关推荐