前置知识: Tailwind CSS

Tailwind CSS 组件复用

8 min中级

Tailwind CSS 组件复用方案对比:纯工具类组件封装 / @apply 提取 / CSS 变量组合,配 cva + clsx + tailwind-merge 工程化实践,附适用场景决策表

0. 先打个比方:从”预制菜”到”中央厨房”

你一定见过预制菜:把洗好的菜、配好的料包封装在一起,拆开就能下锅,省去每天洗菜切菜的重复劳动。做网站也类似——同一个”蓝色圆角按钮”可能出现在全站几十个地方,如果每次都重新写一遍那 8 个工具类,改一次样式就要全站搜索替换,维护成本极高。

组件复用的本质,就是把”反复出现的工具类组合”沉淀为可复用的零件。就像餐饮行业的三种经营模式:

  • 菜品封装:中央厨房把标准菜式做成半成品(对应:纯工具类组件封装);
  • 调料包:把固定的调料配比装成一包(对应:@apply 提取样式);
  • 统一供应链:所有餐厅从同一供应商进货、共用一套原材料标准(对应:CSS 变量组合 + 设计令牌)。

本篇文章采用对比驱动的讲法:把三种主流复用方案放在一起对比——各自的写法、原理、优劣、适用场景,最后给出工程化组合方案(cva + clsx + tailwind-merge)和一张决策速查表。

1. 三种复用方案总览

先把三张”牌”摊开对比,心中有数再逐张细讲:

维度方案一:组件封装方案二:@apply 提取方案三:CSS 变量组合
载体React/Vue 组件代码CSS(@layer components)CSS 变量 + 设计令牌
典型语法class="btn btn-primary"@apply bg-blue-600 ...bg-primary + var(--color-primary)
是否依赖框架是(组件框架)否(纯 CSS)否(纯 CSS)
变体支持通过 props / cva@apply 内可写 dark: 等依赖令牌本身
适用项目React/Vue 等组件化项目纯 HTML / 服务端模板需要主题切换 / 多端复用的项目
可维护性高(单一封装点)中(类名与 CSS 双处维护)高(改一处全站生效)
学习成本低中低

核心结论先行:组件框架项目首选”组件封装”(配合 cva 管理变体);纯 HTML 项目用 @apply;主题相关的取值交给”CSS 变量组合”。三者并不互斥,可以组合使用。

2. 方案一:纯工具类组件封装(推荐)

2.1 直观理解

组件封装就像中央厨房的”菜品封装”:把一组工具类写进组件内部,调用方只传 props,无需关心内部样式。类名只出现一次,改样式只改组件文件。

2.2 React 示例:按钮组件

// Button.tsx —— 类名只在这里出现一次
export function Button({ variant = 'primary', children }) {
  return (
    <button
      className={
        variant === 'primary'
          ? 'rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700'
          : 'rounded-lg bg-gray-100 px-4 py-2 font-medium text-gray-800 hover:bg-gray-200'
      }
    >
      {children}
    </button>
  )
}
// 使用处:只关心业务,不关心样式
<Button variant="primary">提交</Button>
<Button>取消</Button>

2.3 为什么说”组件封装”是主流推荐

  • 单一事实来源:类名组合只存在组件文件里,全局搜索 rounded-lg 就能找到所有按钮;
  • 类型安全:TypeScript 的 props 定义天然约束了调用方的取值范围;
  • 与框架生态契合:React/Vue/Svelte 的组件模型就是为这种复用设计的。

3. 方案二:@apply 提取样式

3.1 直观理解

@apply 就像”调料包”:把一组工具类的”配方”装进一个自定义类名里,HTML 里写一个类名,编译时展开成整组样式。

3.2 基础用法

/* src/styles/global.css */
@import "tailwindcss";

/* 在 components 层注册一个"半成品样式" */
@layer components {
  .btn-primary {
    @apply inline-flex items-center justify-center rounded-lg bg-blue-600
           px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700;
  }
}
<!-- 使用处:一行类名,即可获得整组样式 -->
<button class="btn-primary">主按钮</button>
<a href="#" class="btn-primary">作为链接使用</a>

3.3 原理:@apply 做了什么事

@apply 不是运行时行为,而是构建期的”宏展开”:Tailwind 编译器在编译时把 @apply 后面的工具类逐个解析,把对应的 CSS 声明复制到 .btn-primary 规则里。你可以把编译结果想象成:

.btn-primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.5rem;
  background-color: var(--color-blue-600);
  /* ...其他声明 */
}
.btn-primary:hover { background-color: var(--color-blue-700); }

@apply 内部还可以写变体和令牌类:

@layer components {
  .card {
    @apply rounded-xl border border-gray-200 bg-white p-6 shadow-sm
           dark:border-gray-800 dark:bg-gray-900;
  }
}

3.4 注意:v4 中作用域样式里的 @apply

在 v4 中,如果要在 CSS Modules、Vue <style scoped>、Svelte <style> 这类作用域样式里使用 @apply,需要通过 @reference 指令引入主题令牌,否则编译器不知道 bg-blue-600 等类对应的值:

/* Button.module.css —— 作用域样式中使用 @apply 需要 @reference */
@reference "../../styles/global.css";

.btn {
  @apply rounded-lg bg-blue-600 px-4 py-2 text-white;
}

3.5 @apply 的适用与不适用

  • 适合:纯 HTML / 服务端模板项目;工具类组合确实过长、反复出现且不依赖框架的场景;
  • 不适合:组件框架项目——官方建议直接在组件里写工具类,因为组件本身就是最好的封装,额外写一层 @apply 反而造成”类名与 CSS 两处维护”。

4. 方案三:CSS 变量组合(设计令牌)

4.1 直观理解

第三张牌最”隐形”却最根本:把颜色、间距、圆角等取值沉淀为设计令牌(承接第 5 篇),组件里全部使用语义类。这样”换肤”时只改令牌,所有组件自动跟随。

4.2 示例

/* 主题层:定义语义令牌 */
@theme {
  --color-primary: #1677ff;
  --color-danger: #f5222d;
  --radius-card: 12px;
  --shadow-card: 0 2px 8px rgb(0 0 0 / 0.08);
}
<!-- 组件层:只使用语义类,不出现具体色值 -->
<button class="bg-primary text-white rounded-card px-4 py-2">提交</button>
<div class="bg-white shadow-card rounded-card p-6">卡片</div>

4.3 与方案一、二的组合

方案三的威力在于”打底”:它让方案一的组件 props 值和方案二的 @apply 内容都建立在稳定的语义令牌之上。例如按钮组件里写 bg-primary 而不是 bg-blue-600,未来品牌换色时组件代码零改动。

5. 工程化组合:cva + clsx + tailwind-merge

当组件出现多个维度(variant、size、状态)时,手工三元表达式会爆炸。业界(shadcn/ui 等主流实践)的标准答案是三个小工具组合使用。

5.1 clsx:条件类名的可读写法

clsx 是一个不到 300 字节的小库,支持字符串、对象、数组,自动过滤 false/null/undefined:

import { clsx } from 'clsx'

// 写法一:逻辑与 —— 条件成立才拼接
const a = clsx('px-4 py-2', isActive && 'bg-blue-600 text-white')

// 写法二:对象 —— 键是类名,值是布尔条件
const b = clsx({ 'bg-blue-600 text-white': isActive, 'opacity-50': isDisabled })

5.2 tailwind-merge:解决类名冲突

有一个关键陷阱:CSS 里最后定义的规则优先,与 HTML class 属性中字符串的顺序无关。如果组件内部有 bg-blue-600,调用方传 bg-red-500,最终元素 class 是 bg-blue-600 bg-red-500,谁生效取决于编译产物顺序,结果不可预测。

tailwind-merge 能识别 Tailwind 类的语义,保留同组类中靠后的那一个:

import { twMerge } from 'tailwind-merge'

twMerge('bg-blue-600 text-white', 'bg-red-500')
// => 'bg-red-500 text-white'(都是背景色,后者胜出)

5.3 cva:变体的一等公民

cva(class-variance-authority)用声明式配置管理组件的多维度变体:

import { cva, type VariantProps } from 'class-variance-authority'

// 定义按钮的变体体系:variant × size 两个维度
const buttonVariants = cva(
  // 基础样式:始终存在
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        primary:   'bg-blue-600 text-white hover:bg-blue-700',
        secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
        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: 'primary',
      size: 'md',
    },
  },
)

// 提取类型:调用方 props 自动获得类型提示
type ButtonProps = VariantProps<typeof buttonVariants>

5.4 组合成 cn():完整组件

把三者串起来,就得到 shadcn/ui 同款的标准写法:

// lib/utils.ts —— 全项目共享的 cn 工具函数
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
// Button.tsx —— 变体 + 外部覆盖两不误
import { cn } from '@/lib/utils'
import { buttonVariants } from './button-variants'

export function Button({ variant, size, className, children }) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
    >
      {children}
    </button>
  )
}
// 使用处:可以精细化覆盖任意样式
<Button variant="danger" size="lg" className="w-full dark:bg-red-400">
  全宽危险按钮
</Button>

工作流程说明:cva 负责按 variant/size 生成基础类名 → clsx 负责拼接条件类名 → twMerge 负责消除冲突(外部 className 的 w-full 不会与内部的宽度类打架)。

6. 补充零件:@utility 自定义工具类

如果需要一个”不属于任何组件、也不属于默认工具类”的新能力,v4 提供了 @utility 指令(上一篇文章介绍过)。它和 @apply 的区别在于:@apply 是”组合已有工具类”,@utility 是”创建全新的工具类”:

/* 创建全新工具类:文字渐变 */
@utility text-gradient {
  background-image: linear-gradient(to right, #1677ff, #722ed1);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
<!-- 像内置工具类一样支持变体 -->
<h2 class="text-gradient hover:opacity-80">渐变标题</h2>

7. 复用决策速查表

场景推荐方案理由
React/Vue/Svelte 组件框架项目组件封装 + 工具类(不写 @apply)组件本身就是封装层,避免双处维护
组件有多种变体(primary/secondary/size)cva + cn()声明式变体管理 + 类型安全
需要允许调用方覆盖样式cn()(clsx + tailwind-merge)冲突可预测,后者胜出
纯 HTML / 服务端模板项目@apply 提取组件类无组件框架,CSS 是最合适的封装层
CSS Modules / Vue scoped 中用 @apply@reference + @apply让作用域样式认识主题令牌
全新的、不属于任何组件的工具能力@utility原生指令,支持变体
品牌换色 / 多主题切换设计令牌(方案三)打底改一处、全站生效

8. 常见错误与对策

常见错误报错 / 现象原因解决办法
组件内部与外部类名冲突(bg-blue-600 + bg-red-500)颜色时对时错、结果不确定CSS 优先级取决于编译产物顺序,与 class 书写顺序无关用 twMerge / cn() 合并类名
在 CSS Modules 里用 @apply编译报错”找不到工具类”作用域样式不知道主题令牌文件头部加 @reference 引入主题文件
把 @apply 写进 <style scoped> 而缺 @reference类名解析失败同上同上
组件项目里滥用 @apply 包一层类类名与 CSS 双处维护,改一处漏一处组件本身就是封装层,@apply 冗余直接在组件里写工具类
手写三元表达式拼接变体(${v==='x'?'...':'...'})可读性差、容易写错缺少变体管理工具改用 cva
@apply 里用了未定义的类名编译报错 Cannot apply unknown utility class类名拼写错误或令牌未定义检查拼写;自定义类先定义在 @theme 中
忘记给组件加 className 透传调用方无法覆盖样式组件没有接收外部类名组件 props 增加 className 并用 cn() 合并

9. 一句话记忆

复用三板斧:组件封装管结构(框架项目首选)、@apply 管纯 CSS 沉淀、设计令牌管全局取值;工程化收尾用 cva + cn() 管变体和类名冲突——先定方案,再写代码。