tailwind-merge、cva、tailwind-variants 精要
要点
tailwind-merge负责 class 冲突/去重,是安全拼 class 的底座;推荐封装cn = twMerge(clsx(...))。cva/tailwind-variants把 variants/default/compound 集中声明,输出 class builder,避免状态散落;tv内置 merge + slots,适合多槽组件。- 闭环:tokens → 原子类/variants → builder → 组件 → content 扫描 → JIT → merge → 产物。
tailwind-merge:去重与边界
- 定义:一段针对 Tailwind 类名的「冲突解析器」,根据官方生成顺序和自定义配置判断哪些类互斥、保留最后一条有效值。
- 意义:让「后写覆盖前写」成为可预测规则,避免动态组合时出现
p-4 p-2这类冲突导致的随机样式;类名越多越需要去重。也是 shadcn/ui 这类组件库能安全暴露className扩展点的基石。 - 典型用法:封装
cn = twMerge(clsx(...)),组件内部先写好「官方默认」,调用方传入的className/slot覆盖时,tailwind-merge会清理掉冲突类,只留下最后的意图。
为什么它是「基石」
- 组件库必须开放
className让消费者定制,如果没有 merge,任何「兜底样式」都可能被调用方的冲突类覆盖,且覆盖关系取决于 Tailwind 内部排序而非调用方意图。 - shadcn/ui 的所有组件都通过
cn暴露覆盖点,tailwind-merge才能保证「库默认 + 用户覆盖」是可预期的;不然需要禁止调用方使用部分类名或写大量!important。 - 对 tokens 自定义(例如新增
radius-4xl或品牌色)时,tailwind-merge也能根据扩展配置正确识别冲突,避免出现「自定义类永远不会覆盖掉默认类」的问题。 - 作用:
p-4 p-2→p-2;自定义 tokens/尺寸时需同步规则。 - 建议:在所有 class 拼接入口统一用
cn;对常见自定义(色板、spacing、radius)写额外 merge config 并加用例验证。 - 快测:在 Demo 按钮里故意写
p-4 p-2,观察输出;对自定义色板 class 也做覆盖测试。
没有 tailwind-merge 时
import clsx from 'clsx'
function Button({ size = 'md', className, ...props }) {
// 难以保证调用方不会传入冲突类(如 px-2),只能靠约定
const base = 'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium'
const sizeClass = size === 'lg' ? 'px-4 py-2.5 text-base' : 'px-3 py-2 text-sm'
return <button className={clsx(base, sizeClass, className)} {...props} />
}
// 使用方
// 这里传了 px-2,最终输出 class 顺序取决于 props 位置,样式不可控
;<Button className="px-2 text-red-600" />
示例:生成产物的「顺序陷阱」
Tailwind 会按内部规则排序候选(如 p-0 → p-1 → p-2 → p-4 ...),与 class 字符串的顺序无关:
<!-- 期待 p-2 覆盖 p-4,但生成的 CSS 顺序仍是 p-2 在前、p-4 在后 -->
<button class="p-4 p-2 bg-blue-500 bg-red-500">示例</button>
/* Tailwind 生成的片段(节选) */
.p-2 { padding: 0.5rem; }
.p-4 { padding: 1rem; } /* 出现在后面,仍然生效,覆盖掉 p-2 */
.bg-blue-500 { background-color: #3b82f6; }
.bg-red-500 { background-color: #ef4444; } /* 同样覆盖蓝色 */
即便 class 写成「后者想覆盖前者」,最终仍由 Tailwind 的排序决定,无法满足「调用方最后一项赢」的诉求。
引入 tailwind-merge 之后
import { twMerge } from 'tailwind-merge'
import clsx from 'clsx'
const cn = (...inputs: clsx.ClassValue[]) => twMerge(clsx(...inputs))
function Button({ size = 'md', className, ...props }) {
const base = 'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium'
const sizeClass = size === 'lg' ? 'px-4 py-2.5 text-base' : 'px-3 py-2 text-sm'
return <button className={cn(base, sizeClass, className)} {...props} />
}
// 使用方
// 即便传入 px-2,tailwind-merge 也会保留「最后的有效值」,变为 px-2,行为可预测
;<Button className="px-2 text-red-600" />
示例:merge 后的产物
<button class="p-2 bg-red-500">示例</button>
.p-2 { padding: 0.5rem; }
.bg-red-500 { background-color: #ef4444; }
通过删除冲突类,最终产物只包含用户期望的「最后一份声明」,不再受 Tailwind 内部排序影响。
shadcn 风格组件的常见模式
// utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(...inputs))
// components/button.tsx
const base = 'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium'
const tones = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
ghost: 'bg-transparent text-foreground hover:bg-accent',
}
const sizes = { sm: 'h-8 px-2.5', md: 'h-10 px-3', lg: 'h-12 px-4 text-base' }
export function Button({ tone = 'default', size = 'md', className, ...props }) {
return (
<button
className={cn(base, tones[tone], sizes[size], className)}
{...props}
/>
)
}
// 使用方:随意传入自定义类,默认与覆盖可预测
;<Button tone="ghost" className="px-10 bg-yellow-400 text-black" />
在上例中,调用方传入的 px-10 会去掉默认的 px-3/px-4,bg-yellow-400 也会覆盖默认的 bg-primary。tailwind-merge 让「库的默认 + 用户的覆盖」成为可靠的组合,避免写 !important 或约束文档里硬性禁止某些类名,从而成为这类可扩展组件模式的基石。
cva:轻量变体工厂
import { cva } from 'class-variance-authority'
export const badge = cva('inline-flex items-center rounded-md text-xs font-medium', {
variants: {
tone: {
subtle: 'bg-muted text-muted-foreground border border-border',
brand: 'bg-primary text-primary-foreground',
danger: 'bg-destructive text-white',
},
size: { sm: 'px-2 py-1', md: 'px-2.5 py-1.5' },
},
compoundVariants: [{ tone: 'brand', size: 'md', class: 'shadow-sm' }],
defaultVariants: { tone: 'subtle', size: 'md' },
})
- 场景:单槽组件(按钮、Badge、Input 等);与
cn组合解决冲突。
tailwind-variants (tv):内置 merge + slots
import { tv } from 'tailwind-variants'
export const card = tv({
base: 'rounded-2xl border bg-card/80 shadow-sm transition',
slots: {
header: 'flex items-center justify-between gap-2',
body: 'space-y-2 text-sm text-muted-foreground',
footer: 'flex items-center gap-2',
},
variants: {
tone: { neutral: 'border-border', brand: 'border-primary/40 shadow-lg', subtle: 'border-muted bg-muted/60' },
interactive: { true: 'hover:-translate-y-0.5 hover:shadow-md' },
},
defaultVariants: { tone: 'neutral', interactive: true },
})
- 场景:多槽组件(Card、Modal、List item),需要 slots/recipes 与自动 merge。
cva vs tv 对照
| 项 | cva | tailwind-variants |
|---|---|---|
| Merge | 无内置,需要 cn 包装 | 内置 tailwind-merge |
| 多槽 | 不支持 slots | 支持 slots/recipes |
| 心智负担 | 轻,语法简单 | 略重,类型更严格 |
| 适用 | 按钮/Badge 等单槽 | 卡片/模态/多槽组件,设计系统 |
从设计到产物(示意图)
Tokens:色板/间距/半径/排版等设计变量。
Variants/Builders:用 cva/tv 封装 size、tone、state 等变体。
Components/Content/JIT:组件消费 builder,content 精准扫描驱动 JIT 生成。
Merge/Output:tailwind-merge 清理冲突,产出可预期的最终样式。
实用检查清单
- 所有动态 class 是否经
cn(含tailwind-merge)合并? - 是否存在散落的
clsx/字符串拼接 class?是否可迁移到cva/tv? variants/defaultVariants/compoundVariants是否集中在 builder 文件,业务组件不再拼装状态?- 自定义 tokens 是否同步到 merge 规则?是否有用例验证覆盖顺序?
