TailwindCSS 最佳實踐:clsx、tailwind-merge 與 cva 工具鏈 | TailwindCSS 完整教學
一路學到這裡,你已經會抽元件、會處理暗色模式,能寫出規模化的 TailwindCSS 介面了。但「能跑」和「好維護」之間,還隔著一整套慣例:class 該怎麼排序才不製造 diff 噪音?條件式樣式怎麼寫才乾淨?兩個衝突的 class 誰勝出?變體一多怎麼管?這篇要把這些散落的經驗法則收攏成一套完整的最佳實踐工具鏈——
prettier-plugin-tailwindcss、clsx、tailwind-merge、cva,以及把它們縫在一起的cn()工具,帶你把前面所學織成一套經得起團隊協作的工作流。
前言
前一篇 《元件抽象模式》 我們學會把散落、重複的 utility 收斂成「一處定義、多處使用」的可重用單位。但抽出好元件只是規模化的一環——當專案變大、團隊變多,你會撞上一批新問題:每個人 class 排列順序不同,git diff 一片紅;條件式樣式用字串硬拼,又醜又容易漏;兩個 class 衝突時到底誰勝出說不準;元件變體一多,className 邏輯像義大利麵。最佳實踐(Best Practices) 就是一套用來解決這些「規模化痛點」的規範與工具集合——它不是花俏的新功能,而是讓你的 Tailwind 專案長期可維護、經得起協作的基礎建設。
先用一個生活化的類比建立心智模型。寫 Tailwind class 就像整理一個共用的工具箱:一個人用的時候,工具隨手亂丟也無所謂;但一旦變成團隊共用,你就需要規則——每種工具固定放哪一格(class 排序)、拿工具的動作要標準化(條件式用 clsx)、同一格塞進兩把衝突的扳手時要有人做仲裁(tailwind-merge)、常用的組合工具要做成套件盒(cva 變體)。少了這些規則,工具箱短期還能用,但人一多、東西一雜,就會亂成一團。本篇要介紹的,正是這套讓工具箱「多人共用也不亂」的規則與工具。
本系列以 TailwindCSS v4 為預設版本。本篇你將學到:
- class 排序自動化:用
prettier-plugin-tailwindcss讓全團隊 class 順序一致、消除 diff 噪音 - 三件套工具鏈:
clsx(條件組合)、tailwind-merge(衝突去重)、cva(型別安全的變體),以及合體的cn()工具 - 兩大反模式:為什麼絕不能動態拼接 class、為什麼「字串長」不是縮短的理由
- 專案慣例:目錄結構、命名規範、
cn()工具的擺放位置,一套可直接照抄的骨架
本系列以 TailwindCSS v4 為預設版本,範例皆可直接執行。凡涉及 v3 差異之處,我會特別標註。
核心概念
最佳實踐工具鏈對照表
現代 Tailwind 專案(以 shadcn/ui 為代表)幾乎都圍繞一套固定的工具鏈運作。先用一張表建立全景,再逐一拆解:
| 工具 | 解決的問題 | 運作層次 | 何時需要 |
|---|---|---|---|
| prettier-plugin-tailwindcss | class 排列順序不一致、diff 噪音 | 格式化(存檔時) | 所有專案(必裝) |
| clsx | 條件式 class 拼接又醜又易漏 | 執行期(組字串) | 有條件樣式時 |
| tailwind-merge | class 衝突誰勝出說不準 | 執行期(去重) | 需合併/覆寫 class 時 |
| cva | 元件變體(size/variant)邏輯散亂 | 執行期(變體邏輯) | 做元件庫/設計系統時 |
| cn()(= twMerge + clsx) | 上面兩件事要一起做 | 執行期(組合工具) | 幾乎所有元件 |
這張表的關鍵是理解**「每個工具各管一件事、互不重疊」:prettier-plugin-tailwindcss 在存檔時動作,負責把 class 排整齊;clsx、tailwind-merge、cva 都在執行期**動作,分別負責「條件組合」「衝突去重」「變體邏輯」。它們合起來,就把「規模化 Tailwind 的日常摩擦」幾乎全部消化掉了。
值得先建立一個心態:這套工具鏈不是 Tailwind 官方硬性規定,而是社群在大量實戰中沉澱出來的最佳實踐共識——尤其是 shadcn/ui 這套廣受歡迎的元件方案,直接把 cn()(clsx + tailwind-merge)與 cva 訂為每個元件的標準骨架後,它幾乎就成了現代 Tailwind 專案的事實標準。所以你不必把它們當成「額外負擔」,而該當成「站在別人踩過的坑上」的捷徑:每一個工具都對應一個你遲早會遇到、且不解決就會反覆咬人的具體問題。理解它們各自解決什麼,比死記 API 更重要。
class 排序:prettier-plugin-tailwindcss
團隊協作的第一個摩擦,是每個人習慣的 class 排列順序不同——你寫 p-4 flex bg-white,同事寫 bg-white flex p-4,樣式一模一樣,git diff 卻標成「有改動」。這種假 diff會淹沒真正重要的變更。
官方的 prettier-plugin-tailwindcss 直接根治這件事:它依 Tailwind 推薦的順序自動排序 class(Layout → Position → Sizing → Spacing → Typography → Colors → Borders → Effects → 狀態 → 響應式 → 暗色…),並移除多餘空白,讓全團隊的 class 順序永遠一致。它支援 Prettier v3+,裝好、掛上 plugin 就生效,你不必記任何順序規則:
npm install --save-dev prettier prettier-plugin-tailwindcss
// .prettierrc
{
"plugins": ["prettier-plugin-tailwindcss"]
}
它預設只排序 class / className 屬性。如果你也想讓它排序 cva()、cn()、clsx() 這些函式呼叫裡的 class 字串(下面就會用到這些),要用 tailwindFunctions 告訴它:
// .prettierrc
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["cva", "cn", "clsx", "twMerge", "tw"]
}
這一步很容易被忽略,卻很重要:你等一下會把大量 class 寫進 cva() 與 cn() 裡,如果沒設 tailwindFunctions,那些字串就不會被排序,你辛苦裝的自動排序等於只覆蓋了一半。設好之後,連元件內部變體定義裡的 class 都會被一併整理,團隊的一致性才真正做到全面。順帶一提,prettier-plugin-tailwindcss 一定要放在 plugins 陣列的最後一個——它被設計成要在其他 Prettier plugin 之後執行,順序放錯可能導致排序失效或衝突。
關鍵術語
- clsx:一個輕量函式,負責「條件組合」——依 boolean 或物件條件把多段 class 拼成一個字串,
false/null/undefined會被自動忽略。 - tailwind-merge(twMerge):負責「衝突去重」——因為 Tailwind 所有 utility 特異性相同、後宣告者勝,
twMerge讓真正的後者勝出(如px-2 px-4→ 只留px-4),解決非預期覆寫。 - cva(class-variance-authority):負責「型別安全的變體邏輯」——把
size、variant、intent等變體集中定義,呼叫時像函式一樣傳參數。 - cn():社群慣例的工具函式,本質是
twMerge(clsx(...))——先用clsx做條件組合,再用twMerge去重,一次解決「條件式 + 衝突」兩件事。是 shadcn/ui 的基礎。 - 動態 class 拼接:反模式。用字串在執行期組 class(如
`bg-${color}-500`),Tailwind 掃不到完整字串、不會生成 CSS。
實作範例
理論看完,我們動手把工具鏈建起來——從最核心的 cn() 工具開始,再示範 cva 變體與 clsx 條件式的實際用法。
範例一:cn() 工具——每個專案的第一塊拼圖
cn() 是整套工具鏈的地基,幾乎每個 Tailwind 元件都會用到它。它把 clsx(條件組合)和 tailwind-merge(衝突去重)縫成一個函式,放在專案的 lib/utils.ts:
// lib/utils.ts:必備工具函式
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* 合併 TailwindCSS class,並智能解決衝突
* 用法:cn('px-4 py-2', isLarge && 'px-6', className)
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
為什麼要「兩個一起用」?看這個例子就懂了:
// 只用 clsx:條件對了,但 px-4 和 px-6 同時存在,衝突未解決
clsx('px-4 py-2', true && 'px-6')
// → 'px-4 py-2 px-6' ⚠️ px-4 和 px-6 都在,實際結果不可預期
// 用 cn():clsx 先組合,twMerge 再去重,後者(px-6)勝出
cn('px-4 py-2', true && 'px-6')
// → 'py-2 px-6' ✅ px-4 被正確覆寫掉
這裡的重點是理解 twMerge 為什麼不可或缺:Tailwind 的所有 utility 特異性(specificity)都相同,誰勝出完全取決於它們在最終 CSS 檔裡的生成順序,而非 class 字串裡的排列順序。這代表你在 HTML 上寫 px-4 ... px-6,並不保證 px-6 會贏——真正決定勝負的是 Tailwind 生成 .px-4 與 .px-6 這兩條規則的先後。twMerge 的價值就在於:它讀懂 Tailwind 的 class 分組,知道 px-4 與 px-6 是「同一組、會互斥」的,於是直接在字串層級把先出現的那個砍掉,只留最後一個。這樣一來,結果就變得可預測、與生成順序無關了。
排序原則是關鍵:base 樣式在前、條件在中、呼叫者傳進來的 className 放最後,確保外部覆寫可預測地勝出:
// components/Card.tsx:讓外部 className 能可靠地覆寫預設樣式
export function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
// base 在前、外部 className 在最後 → 外部覆寫一定勝出
className={cn('rounded-xl border border-gray-200 bg-white p-6 shadow-sm', className)}
{...props}
/>
)
}
// 使用端可靠地覆寫圓角與內距,不必擔心誰勝出
// <Card className="rounded-2xl p-8" /> → 實際套用 rounded-2xl p-8
範例二:cva 管理按鈕變體
當一個元件有多種變體(variant、size),用手寫字串拼接很快會失控。cva 讓你把變體集中宣告,還附帶 TypeScript 型別安全:
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// 集中定義:base 樣式 + 各變體的 class 對照
const buttonVariants = cva(
// base:所有按鈕共用
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-base',
},
},
// 沒傳 props 時的預設變體
defaultVariants: { variant: 'primary', size: 'md' },
}
)
// VariantProps 自動推導出 variant/size 的合法值,型別安全
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
// cva 產生變體字串,cn 再合併外部 className 並去重
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
// 使用:像傳 props 一樣選變體,TS 會擋掉不存在的值
// <Button variant="secondary" size="lg">送出</Button>
注意 cva 和 cn 是搭配使用的:cva 負責「依變體算出 class 字串」,cn 負責「把 cva 的結果和外部 className 合併去重」。這正是 shadcn/ui 每個元件的標準骨架。
cva 帶來的最大好處有三個。第一是型別安全:透過 VariantProps<typeof buttonVariants>,variant 與 size 的合法值會被自動推導成 TypeScript 型別,你若手殘傳了 variant="danger"(不存在的值),編譯期就會報錯,不必等到執行時才發現樣式沒生效。第二是單一真相來源:所有變體的 class 都集中在 buttonVariants 這一處宣告,要調整 primary 的顏色只需改一行,不必在散落各處的三元運算裡翻找。第三是可組合:cva 還支援 compoundVariants(複合變體,例如「當 variant=primary 且 size=lg 時再額外加某些 class」),讓複雜的變體交互也能宣告式地表達,而不是寫成一堆巢狀 if。當一個元件的變體超過兩三種、或變體之間有交互時,cva 幾乎是必選。
範例三:clsx 寫條件式樣式
不到需要 cva 的規模時,單純用 clsx(或 cn)寫條件式就很夠。clsx 支援 boolean 短路與物件語法兩種寫法:
import { cn } from '@/lib/utils'
function NavLink({ href, isActive, isDisabled, children }) {
return (
<a
href={href}
className={cn(
// base
'block rounded-md px-3 py-2 text-sm font-medium',
// boolean 短路:條件成立才加入
isActive && 'bg-blue-50 text-blue-700',
!isActive && 'text-gray-600 hover:bg-gray-50',
// 物件語法:key 是 class,value 是條件
{ 'pointer-events-none opacity-50': isDisabled },
)}
>
{children}
</a>
)
}
兩種寫法都比手動 `... ${isActive ? 'bg-blue-50' : ''}` 乾淨得多——false、null、undefined 都會被自動忽略,不會留下多餘空白或 undefined 字樣。什麼時候用 boolean 短路、什麼時候用物件語法?經驗法則是:單一條件對應單一 class(或一小串 class)時用短路,讀起來像自然句子;多個獨立開關集中在一起時用物件語法,每個 key-value 一目了然。兩者可以混用,clsx 完全接受在同一次呼叫裡同時傳字串、boolean 表達式與物件,你不必為了統一風格而勉強選一種。
常見錯誤與最佳實踐
反模式一:動態拼接 class(最致命)
這是 Tailwind 最常見、也最致命的錯誤,而且它特別陰險:因為問題在開發時往往不會發作。Tailwind 靠掃描原始碼中出現的完整 class 字串來決定生成哪些 CSS——它做的是純文字的靜態掃描,不執行你的 JS,看不懂執行期才組出來的字串。它只能看到 `bg-` 和 `-500` 這些散落的片段,永遠拼不出 bg-red-500 這個完整 token,於是對應的 CSS 根本不會進到最終產物。為什麼說陰險?因為在開發模式下有些工具鏈會生成較完整的 CSS 讓你「碰巧看得到」,一旦跑 production build 做了 tree-shaking,那些沒被掃到的 class 就集體消失——你在本機看好好的,上線卻整片樣式崩掉,還很難第一時間聯想到是這個原因。
<!-- ❌ 反模式:動態建構 class,Tailwind 掃不到完整字串,生產環境不生成 CSS -->
<div class="bg-{{ severity }}-500">警告</div>
<!-- ❌ 模板字串拼中間段:text-xl / text-sm 都組不出完整字串 -->
const cls = `text-${size === 'lg' ? 'xl' : 'sm'}`
<!-- ❌ 陣列 join:'bg-red-600' 在執行期才產生,原始碼裡不存在 -->
const cls = ['bg', color, '600'].join('-')
根治法:把所有可能的完整 class 都寫出來,通常用映射物件。 這樣每個完整字串都實際出現在原始碼裡,Tailwind 掃得到就會生成:
// ✅ 正確:映射物件,每個 class 都是完整名稱
const severityClasses = {
info: 'bg-blue-100 text-blue-800 border-blue-200',
success: 'bg-green-100 text-green-800 border-green-200',
warning: 'bg-amber-100 text-amber-800 border-amber-200',
error: 'bg-red-100 text-red-800 border-red-200',
}
<div className={cn('rounded-xl border px-4 py-3', severityClasses[severity])}>提示訊息</div>
// ✅ 三元運算也一樣:兩邊都要是完整 class,不拼半截
const sizeClass = size === 'lg' ? 'text-xl' : 'text-sm'
反模式二:因為「class 太長」就想縮短
第二個常見誤區,是看到一長串 class 就本能地想「藏起來」——濫用 @apply 把它包成 CSS class,或硬用字串拼接縮短。但長 class 字串本身不是問題:Tailwind 的哲學就是把樣式攤在 HTML 上換取「就地可讀」,一眼就能看出元素長什麼樣是優勢,不是缺陷。
正確的可讀性做法不是「縮短」,而是「排版與收斂」:
- 靠
prettier-plugin-tailwindcss自動排序,讓長字串有固定結構、掃視得快。 - 真正重複時才抽象(見上一篇《元件抽象模式》),而不是「只因為長」就抽——抽象的價值來自消除重複,不是消除長度。
- 變體多就交給
cva,把長字串收斂成宣告式的變體表,而非一坨三元運算。
換句話說:可讀性 > 簡潔性。別為了「眼睛清爽」而犧牲掉 Tailwind 最大的優點。
反模式三:忽略 class 衝突,不用 tailwind-merge
當你允許外部傳 className 覆寫元件預設樣式時,如果只用字串拼接或 clsx,衝突不會被解決——px-2 和 px-4 會同時存在,而 Tailwind 特異性相同,實際結果取決於 CSS 生成順序,不可預期。
// ❌ 只拼接:兩個 px 都在,外部覆寫不一定生效
<div className={`px-2 py-1 ${className}`} /> // className="px-6" → px-2 與 px-6 打架
// ✅ 用 cn():twMerge 讓後者(外部 className)可靠勝出
<div className={cn('px-2 py-1', className)} /> // className="px-6" → 乾淨地變成 px-6
陷阱:自訂 utility 需向 tailwind-merge 註冊。 若你用 v4 的 @theme / @utility 擴充了自訂 class(如自訂字級 text-tiny、text-huge),twMerge 不認得它們屬於哪一組,去重時可能誤刪。用 extendTailwindMerge 註冊:
import { extendTailwindMerge } from 'tailwind-merge'
// 告訴 twMerge:text-tiny / text-huge 屬於 font-size 這一組
export const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['tiny', 'huge'] }],
},
},
})
專案結構與命名慣例
最後,一套可直接照抄的骨架。核心是:基礎 UI 元件、業務元件、工具函式各有其位,cn() 固定放在 lib/utils.ts:
src/
├── components/
│ ├── ui/ # 基礎 UI 元件(Button、Input、Card…)
│ │ ├── Button.tsx # 內含 cva 變體定義
│ │ └── index.ts
│ └── features/ # 業務元件(UserCard、ProductList…)
├── lib/
│ └── utils.ts # cn() 工具函式(twMerge + clsx)
└── styles/
└── globals.css # @import "tailwindcss" + @theme / @utility
命名慣例的幾個要點:
- 自訂 utility 用「描述效果」的名稱,而非「描述外觀」:
scrollbar-hide、text-balance清楚且穩定;blue-box這種與具體顏色掛鉤的名稱,一改設計就名不符實。 - 元件變體用語義化 key:
variant: primary/secondary/ghost、size: sm/md/lg,而非variant: blue/gray——語義穩定、換色不改名。 - 基礎元件放
ui/、業務元件放features/:前者無業務邏輯、高度可複用;後者組合前者、承載業務。 - 搭配
eslint-plugin-tailwindcss(選用):可自動強制 class 排序、禁止衝突 class、警告任意值濫用,把最佳實踐從「靠自律」升級成「CI 上的硬約束」——這對多人團隊尤其值得,因為規範只要能被工具自動檢查,就不會因為誰忘了、誰趕工而失守。
把這幾層慣例疊起來,你會得到一個很舒服的分工:prettier 管排版、eslint 管紅線、cn() 管合併、cva 管變體、目錄結構管歸位。任何一個新成員進來,都能靠這套骨架快速上手,寫出風格一致、彼此可預測的程式碼——這正是「最佳實踐」真正的價值:它不是為了炫技,而是把「規模化協作」這件本來很痛的事,變得平淡無奇。
小結
這是 TailwindCSS 完整教學 系列的第二十八篇。上一篇 《元件抽象模式》 教我們把重複的 utility 收斂成可重用元件;而這一篇,我們把「規模化 Tailwind 的日常慣例」收攏成一套完整的工具鏈與規範。回顧幾個重點:
- class 排序:
prettier-plugin-tailwindcss自動排序、消除 diff 噪音,是所有專案的必裝件;用tailwindFunctions讓它也排序cva/cn/clsx裡的字串。 - 三件套 + cn():
clsx(條件組合)、tailwind-merge(衝突去重)、cva(型別安全變體),合成cn() = twMerge(clsx(...))——排序原則是 base 在前、外部className在最後,確保覆寫可預測。 - 兩大反模式:絕不動態拼接 class(Tailwind 掃不到,用映射物件寫完整名稱);別因「字串長」就縮短(可讀性 > 簡潔性,靠排序與抽象而非藏起來)。
- 陷阱:自訂 utility 要用
extendTailwindMerge向 tailwind-merge 註冊,否則去重時可能被誤刪。 - 專案慣例:
ui/放基礎元件、features/放業務元件、cn()固定在lib/utils.ts;命名描述「效果」與「語義」而非「外觀」。
掌握了這套工具鏈與慣例,你的 Tailwind 專案已經具備長期可維護、經得起團隊協作的體質。而 cva + cn() 這套骨架,其實正是現代元件庫的基礎——下一篇 《元件庫》 就要帶你認識以此為地基打造的成熟方案(如 shadcn/ui),看看別人如何把這些最佳實踐,織成一整套可直接取用的高品質元件。