TailwindCSS 最佳實踐:clsx、tailwind-merge 與 cva 工具鏈 | TailwindCSS 完整教學

2026/08/28
TailwindCSS 最佳實踐:clsx、tailwind-merge 與 cva 工具鏈 | TailwindCSS 完整教學

一路學到這裡,你已經會抽元件、會處理暗色模式,能寫出規模化的 TailwindCSS 介面了。但「能跑」和「好維護」之間,還隔著一整套慣例:class 該怎麼排序才不製造 diff 噪音?條件式樣式怎麼寫才乾淨?兩個衝突的 class 誰勝出?變體一多怎麼管?這篇要把這些散落的經驗法則收攏成一套完整的最佳實踐工具鏈——prettier-plugin-tailwindcssclsxtailwind-mergecva,以及把它們縫在一起的 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-tailwindcssclass 排列順序不一致、diff 噪音格式化(存檔時)所有專案(必裝)
clsx條件式 class 拼接又醜又易漏執行期(組字串)有條件樣式時
tailwind-mergeclass 衝突誰勝出說不準執行期(去重)需合併/覆寫 class 時
cva元件變體(size/variant)邏輯散亂執行期(變體邏輯)做元件庫/設計系統時
cn()(= twMerge + clsx)上面兩件事要一起做執行期(組合工具)幾乎所有元件

這張表的關鍵是理解**「每個工具各管一件事、互不重疊」:prettier-plugin-tailwindcss存檔時動作,負責把 class 排整齊;clsxtailwind-mergecva 都在執行期**動作,分別負責「條件組合」「衝突去重」「變體邏輯」。它們合起來,就把「規模化 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):負責「型別安全的變體邏輯」——把 sizevariantintent 等變體集中定義,呼叫時像函式一樣傳參數。
  • 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-4px-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 管理按鈕變體

當一個元件有多種變體(variantsize),用手寫字串拼接很快會失控。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>

注意 cvacn搭配使用的:cva 負責「依變體算出 class 字串」,cn 負責「把 cva 的結果和外部 className 合併去重」。這正是 shadcn/ui 每個元件的標準骨架。

cva 帶來的最大好處有三個。第一是型別安全:透過 VariantProps<typeof buttonVariants>,variantsize 的合法值會被自動推導成 TypeScript 型別,你若手殘傳了 variant="danger"(不存在的值),編譯期就會報錯,不必等到執行時才發現樣式沒生效。第二是單一真相來源:所有變體的 class 都集中在 buttonVariants 這一處宣告,要調整 primary 的顏色只需改一行,不必在散落各處的三元運算裡翻找。第三是可組合:cva 還支援 compoundVariants(複合變體,例如「當 variant=primarysize=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' : ''}` 乾淨得多——falsenullundefined 都會被自動忽略,不會留下多餘空白或 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-2px-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-tinytext-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-hidetext-balance 清楚且穩定;blue-box 這種與具體顏色掛鉤的名稱,一改設計就名不符實。
  • 元件變體用語義化 key:variant: primary/secondary/ghostsize: 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),看看別人如何把這些最佳實踐,織成一整套可直接取用的高品質元件。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →