Tailwind CSS 工程化实践
Tailwind 写小项目爽,大项目容易乱(一堆重复 class、设计 token 漂移)。本文讲清 Tailwind 工程化:设计系统配置(token 化颜色/间距)、组件抽象(何时抽组件 vs @apply vs cva)、避免 class 堆积、与设计稿对齐、暗色主题与响应式,让 Tailwind 在大项目里也清爽。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
Tailwind 这几年几乎成了 React 项目的事实标准。写个小页面,确实爽:不用切 CSS 文件、不用想类名、JIT 按需生成、产物体积还小。但项目一大,问题就开始冒头——同一个按钮的 class 列表在二十个文件里被复制粘贴,设计稿把主色从 #3B82F6 改成 #2563EB,你 grep 半天也改不干净;一个卡片组件的 className 长到三百字符,谁也不敢动。
这些问题不是 Tailwind 的锅,是没有把它当设计系统来用的锅。Tailwind 的 config 本质就是一套设计 token,组件抽象也有明确的取舍规则。这篇文章讲清楚:在大项目里,怎么让 Tailwind 既保持原子 CSS 的灵活,又不失控。
一、Tailwind 的价值,和它在大项目里的痛点
先说清楚 Tailwind 为什么值得用,再说它为什么会乱。
价值三点:
- 原子 CSS,不用想命名。
flex items-center gap-4直接写,不用纠结这个容器叫.card-header还是.card-title-wrap。命名是 CSS 维护成本的大头,原子类把这个成本砍掉了。 - JIT 按需生成,产物小。老版本 Tailwind 的痛点是 purge 配置复杂、产物大;JIT 之后,你写什么生成什么,没用到的 class 一个字节都不会进打包产物。
- 样式不跨文件漂移。改一个按钮,直接在按钮那个文件里改,不用跳到
button.css里找对应规则。样式和结构在同一个地方,可读性好。
但项目一大,三个痛点会准时出现:
- class 堆积:同一个视觉模式(比如一张卡片)在十几处重复写一长串 class,改一处要改十几处。
- token 漂移:颜色、间距随手用
bg-[#3B82F6]、mt-[13px]这种任意值硬编码,设计稿一改全站乱套。 - 组件 className 失控:为了让一个组件支持五种变体,className 拼接成一坨三元表达式,可读性比传统 CSS 还差。
下面逐个解决。
二、设计系统配置:把 token 钉死在 config 里
Tailwind 工程化的第一步,是把所有设计 token 收敛到 tailwind.config 里,全站只通过语义化名字引用,禁止散用任意值。
// 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:
// 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:
/* 不推荐作为主要抽象方式 */
.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 冲突。
// 配合 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 双向同步:
- 设计师在 Figma 里定义 Variables(颜色、间距、字号),命名和
tailwind.config一致——brand、surface、text-muted这种语义名,不是Blue/500这种层级名。 - 用 Figma 的 Tokens Studio 或自建脚本,把 Figma Variables 导出成
tailwind.config的格式。 - 设计稿改了 token,CI 跑一遍同步脚本,PR 里 config 自动更新;开发改了 config,反向同步回 Figma。
这一步打通后,「设计稿和实现对不上」的来回沟通能砍掉一大半。前期投入两三天搭同步链路,后面省下的 review 时间远超这个数。
如果团队还做不到自动同步,至少做到人工对齐命名:设计稿里叫 brand 的色,代码里就得是 bg-brand,不能是 bg-blue-600。命名一致是最低成本的对齐手段。
六、暗色主题与响应式:dark: 和断点
第二节埋的伏笔这里收网。因为颜色用 CSS 变量承载,暗色主题切换极其干净——只改变量值,不动 class:
/* 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: 前缀:
<img src="/logo-light.svg" className="dark:hidden" />
<img src="/logo-dark.svg" className="hidden dark:block" />
响应式断点则反过来——移动优先(mobile first)。md: 表示「中等屏幕及以上生效」,所以基础类是移动端样式,断点修饰符是更大屏幕的覆盖:
<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 里反复拦的:
- 全用
@apply。有人觉得原子类太长,把所有组件都写成.card { @apply ... }——这是退化回传统 CSS:重新开始想命名、样式和结构分离、丢失变体能力。Tailwind 的核心价值被你亲手废掉了。 - 硬编码任意值满天飞。
text-[#1a1a1a]、mt-[13px]、w-[347px]散落各处。改设计稿时你 grep 不到、统一不了。出现第二次的值,必须进 config。 - 不抽组件,className 列表几百字符。一个
<div className="flex items-center justify-between rounded-card bg-surface p-4 shadow-sm hover:bg-surface-muted ...">复制十遍,改一处要改十遍。重复出现就抽组件。 - 把短组合过度抽象。
<Flex><FlexBetween><Center>这类「包装原子类」的组件是反模式,增加跳转成本却没带来语义价值。 - 变体用三元表达式硬拼。
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