TailwindCSS 元件抽象模式:@apply、@utility 與框架元件三選一 | TailwindCSS 完整教學

2026/08/27
TailwindCSS 元件抽象模式:@apply、@utility 與框架元件三選一 | TailwindCSS 完整教學

一路學到這裡,你已經能寫出響應式、有狀態、還會明暗切換的介面——但你八成也發現一件事:同一顆按鈕、同一張卡片的那串 utility class,在每個頁面都要再抄一遍,又臭又長還容易不一致。這篇 TailwindCSS 要解決的正是這個規模化痛點:重複的 class 該怎麼收斂? 我們會把三種抽象策略——框架元件@apply、v4 的 @utility——攤開來對比,講清楚何時該抽象、何時該保持 utility inline,並釐清 @apply 的正確與濫用時機。

前言

前一篇 《暗色模式》 我們讓同一套介面學會「隨白天黑夜切換氣氛」。但也正是在把每個元素都補上 dark: 配對的過程裡,一個更根本的問題浮上檯面:我們手上累積了大量重複的 class 組合。 一顆送出按鈕可能是這樣的:

<button class="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg
               text-sm font-medium text-white bg-blue-600 hover:bg-blue-700
               focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
               active:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed
               transition-all duration-150">
  送出
</button>

這串樣式如果在專案裡出現五次,某天要把圓角從 rounded-lg 改成 rounded-xl,你就得找出五個地方一一改動——漏改一個,介面就不一致了。這就是元件抽象(Component Abstraction) 要解決的核心問題:如何把散落、重複的 utility 收斂成「一處定義、多處使用」的可重用單位,讓專案規模變大時依然乾淨、好維護?

先用一個生活化的類比建立心智模型。Utility class 就像散裝的樂高積木:一顆一顆很靈活,你能拼出任何形狀;但當你發現「同一台小汽車」要拼十次,每次都從散裝積木開始,就太累了。這時聰明的做法是把常拼的組合做成一個「預組模組」——之後要用汽車,拿模組就好。而做「預組模組」的方式有三種:你可以在框架層把它包成一個 <Button> 元件(最推薦)、可以用 @applyCSS 層捏出一個 .btn class、也可以用 v4 的 @utility 定義一個能吃變體的自訂 utility。三者各有適用場景,選錯了反而會失去 Tailwind 的優勢。

本系列以 TailwindCSS v4 為預設版本。元件抽象在 v4 有幾個關鍵變化:定義自訂 utility 改用 @utility 指令(取代 v3 的 @layer utilities),而在 Vue/Svelte scoped 或 CSS Modules 裡用 @apply 需要先 @reference。本篇你將學到:

  • 重複 class 的問題本質:為什麼問題不是「字串太長」,而是「複製了樣式」而非「抽取了元件」
  • 三種抽象策略對比:框架元件、@apply、v4 @utility 各自的運作原理、優缺點與適用場景
  • @apply 的正確與濫用:Adam Wathan 的官方立場,以及四個最常見的誤用
  • 何時該抽象、何時別抽象:單一真相來源 vs. 過早抽象的取捨,以及多游標編輯與迴圈渲染這兩個「其實不需要抽象」的解法

本系列以 TailwindCSS v4 為預設版本,範例皆可直接執行。凡涉及 v3 差異之處,我會特別標註。

核心概念

問題不是「字串太長」,而是「複製了樣式」

在談解法之前,先精準定位問題。很多人看到那串長長的 class 第一反應是「太醜了,把它藏起來」——但這其實搞錯了重點。長字串本身不是問題,Tailwind 的哲學就是把樣式攤在 HTML 上換取「就地可讀」;真正的問題是當你把這串樣式用「複製貼上」散佈到多個地方,你就製造了多個需要同步維護的副本

換句話說,痛點的根源是缺少「單一真相來源(Single Source of Truth)」。改一次要改五處,是因為那串樣式被複製成了五份,而不是被抽取成一個共用單位。理解這一點很關鍵,因為它直接決定了正確的解法方向:我們要做的是「抽取一個可重用的元件」,而不是「把字串換個地方藏起來」。 這兩件事聽起來很像,但前者建立了單一真相來源,後者只是把長字串搬到 CSS 檔裡——後面會看到,後者往往還附帶失去變體、失去掃描優化等副作用。

三種抽象策略對照表

既然要抽取可重用單位,方法有三種。先用一張表建立全景,再逐一拆解:

策略運作層次支援變體/props保留就地可讀需要框架典型場景
框架元件(React/Vue/Svelte)元件層(JS/模板)✅(props 完整封裝)✅(元件內仍是 utility)有 UI 框架的專案(首選)
@applyCSS 層❌(產生普通 class)純 HTML、第三方/Markdown HTML、全域標籤樣式
v4 @utilityCSS 層(utilities layer)✅(自動吃 hover/dark/md)無框架、需全域可攜的自訂 utility

三者的核心差異在於**「在哪一層建立單一真相來源」:框架元件把樣式與邏輯(props、狀態、事件)一起封裝在元件層**,是 Tailwind 官方最推薦的做法;@apply@utility 都在 CSS 層建立共用 class,差別在 @apply 產生的是「不吃變體的普通 class」,而 @utility 產生的是「像內建 utility 一樣能吃變體、放進正確 cascade layer 的自訂 utility」。

運作原理:三種策略各自在做什麼

框架元件的原理最直觀:你把那串 utility 連同邏輯包進一個元件,對外只暴露 props。之後每次要按鈕,寫 <Button variant="primary">送出</Button> 就好——樣式定義在元件內部唯一一處,改樣式只改元件。關鍵是:元件內部依然是純 utility,你沒有失去 Tailwind 的任何優勢(變體、掃描優化、就地可讀都還在,只是「就地」變成了「元件內」)。這就是為什麼它是首選。

@apply 的原理是「展開並貼上」:它把你列出的 utility 的樣式,直接展開寫進某個 CSS 選擇器裡。

/* @apply 把這幾個 utility 的樣式「展開」進 .btn-primary */
@layer components {
  .btn-primary {
    @apply inline-flex items-center px-4 py-2 rounded-lg
           text-sm font-medium text-white bg-blue-600
           hover:bg-blue-700 disabled:opacity-50 transition-colors;
  }
}

結果是一個普通的 .btn-primary class。它能用,但有個致命限制:它是「普通 class」,不會自動支援變體——你沒辦法寫 md:btn-primaryhover:btn-primary 讓它在中斷點或 hover 時生效(裡面內建的 hover:bg-blue-700 是寫死的,不是可外加的變體)。

v4 @utility 的原理則是「註冊一個真正的自訂 utility」。這是 v4 CSS-first 哲學下的新指令,取代 v3 的 @layer utilities:

/* v3 寫法:用 @layer utilities */
@layer utilities {
  .tab-4 { tab-size: 4; }
}

/* v4 寫法:用 @utility 指令 */
@utility tab-4 {
  tab-size: 4;
}

@utility 定義的 class 有兩個關鍵優勢,讓它適合當「可攜的元件抽象」:第一,自動獲得所有變體支援——hover:md:dark: 會自動套用到你的自訂 utility;第二,被正確放進 utilities cascade layer,特異性與內建 utility 一致,可預期地被其他 utility 覆寫。

關鍵術語

  • 框架元件抽取(Component Extraction):把重複的 markup + 樣式包成 React/Vue/Svelte 元件,以 props 暴露變化。Tailwind 官方文件明列為首選抽象方式。
  • @apply:CSS 指令,把現有 utility 的樣式展開進一個選擇器。產生「普通 class」,不自動支援變體。是官方定位的「逃生艙」。
  • @utility(v4):v4 指令,定義真正的自訂 utility,自動支援變體、放進 utilities layer。取代 v3 的 @layer utilities 與 plugin 的 addUtilities
  • @reference(v4):在 Vue/Svelte scoped <style> 或 CSS Modules 裡用 @apply必須先加的一行,用來引入主設定供參考(不重複輸出 CSS),否則 @apply 找不到 utility 定義而失效。
  • 單一真相來源(Single Source of Truth):同一份樣式只定義一處、多處引用。是「該不該抽象」的判準核心。
  • 就地可讀(Colocation):樣式與 markup 寫在一起、看 HTML 即知外觀。@apply/@utility 會犧牲它,框架元件則把「就地」收斂進元件內。

實作範例

理論看完,我們用同一顆按鈕,分別用三種策略寫一遍做對照,你就能直觀感受差異與取捨。假設需求是:一顆主要按鈕,支援 primary/secondary 兩種變體。

寫法一:框架元件(React,首選)

把樣式與變體邏輯封裝進元件,對外只暴露 variant prop:

// components/Button.tsx
import { type ButtonHTMLAttributes } from 'react'

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary'
}

// 各變體專屬的 utility(單一真相來源就在這裡)
const variantClasses = {
  primary:   'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
}

export function Button({ variant = 'primary', className = '', children, ...props }: ButtonProps) {
  return (
    <button
      {...props}
      className={`inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg
                  text-sm font-medium transition-colors
                  focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
                  disabled:opacity-50 disabled:cursor-not-allowed
                  ${variantClasses[variant]} ${className}`}
    >
      {children}
    </button>
  )
}

// 使用:樣式定義只有一處,改樣式只改這個檔案
// <Button variant="primary">送出</Button>
// <Button variant="secondary">取消</Button>

注意:元件內部依然是純 utility,Tailwind 的所有優勢原封不動;variant 這種「帶 props 的變化」是 @apply 根本做不到的。這也是為什麼有框架時它是首選。

寫法二:@apply(無框架時的逃生艙)

如果是純 HTML 靜態站、沒有框架可抽元件,才用 @apply 在 CSS 捏出共用 class:

/* app.css */
@import "tailwindcss";

@layer components {
  /* 共用基礎 */
  .btn {
    @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg
           text-sm font-medium transition-colors
           focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed;
  }
  /* 變體要各寫一個 class——因為 @apply 帶不了 props */
  .btn-primary   { @apply bg-blue-600 text-white hover:bg-blue-700; }
  .btn-secondary { @apply bg-gray-100 text-gray-900 hover:bg-gray-200; }
}
<!-- HTML 端變乾淨了,但看 class 已經不知道按鈕長什麼樣(失去就地可讀) -->
<button class="btn btn-primary">送出</button>
<button class="btn btn-secondary">取消</button>

代價很明顯:變體得手動一個個寫(btn-primarybtn-secondary),而且看 HTML 已經無法一眼得知外觀,要回 CSS 查。

在 Vue/Svelte scoped style 或 CSS Modules 裡用 @apply,v4 必須先 @reference,否則 @apply 找不到 utility 定義:

<!-- Button.vue -->
<template>
  <button class="btn-primary"><slot /></button>
</template>

<style scoped>
/* v4:scoped/module 的 CSS 看不到主設定,必須先 @reference 引入 */
@reference "../app.css";       /* 有自訂 theme 就指向你的主 CSS */
/* @reference "tailwindcss"; */ /* 若無自訂 theme,可直接參考預設 */

.btn-primary {
  @apply inline-flex items-center px-4 py-2 rounded-lg
         bg-blue-600 text-white hover:bg-blue-700 transition-colors;
}
</style>

寫法三:v4 @utility(需要全域可攜、又要吃變體時)

如果你需要一個「像內建 utility 一樣、能被 hover:/dark:/md: 修飾」的可攜 class,用 @utility:

/* app.css */
@import "tailwindcss";

/* 定義自訂 utility:自動獲得變體支援、放進正確的 cascade layer */
@utility btn {
  @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg
         text-sm font-medium bg-blue-600 text-white transition-colors;
}
<!-- 關鍵差異:btn 能被變體修飾,像內建 utility 一樣 -->
<button class="btn hover:bg-blue-700 dark:bg-blue-500 md:px-6">送出</button>

對照寫法二的 .btn 你會發現:@utility 定義的 btn 能吃 hover:dark:md: 這些變體(@apply 產生的普通 class 做不到)。這正是官方推薦用 @utility 取代 v3 老做法的原因。但它仍然失去就地可讀,所以有框架時,框架元件依舊優先。

常見錯誤與最佳實踐

五個最容易踩的元件抽象坑

坑一:只因為「覺得 class 字串太長」就用 @apply 藏起來。 這是最普遍的誤用。Adam Wathan 的官方立場是:「@apply is an escape hatch. Use it only when using a component from a template language that doesn’t support component extraction.」——它是逃生艙,只在模板語言無法抽元件時才用。原因:字串長不是問題,複製樣式才是;把長字串搬進 CSS 會讓你失去變體、就地可讀與掃描優化。根治法:有框架就抽框架元件,別為了「眼睛清爽」而 @apply

/* ❌ 錯誤:只因為覺得長,就把一次性的樣式包成 class */
.card { @apply bg-white rounded-xl shadow-sm border border-gray-200 p-6; }
/* 若這張卡片只出現一兩次,直接 inline 就好,抽象反而是負擔 */

坑二:用 @apply 想表達「元件內父子關係」,結果表達不出來。 @apply 只能把 utility 展開進單一選擇器,無法處理「父層 hover 時改子層樣式」這種關係(那是 group-* 變體的工作)。原因:@apply 沒有「元件內多元素連動」的概念。根治法:需要父子連動就用框架元件包起來,在裡面用 group/peer 變體。

/* ❌ 錯誤:@apply 無法表達「卡片 hover 時標題變色」這種關係 */
.card-title { @apply text-xl font-bold; }
/* .card:hover .card-title { ... } ← 這種父子連動 @apply 幫不上忙 */

坑三:過早抽象(Premature Abstraction)——只出現一次就急著抽成元件。 一個樣式組合還沒真正重複,就先包成 <Card>.card,結果每個實際使用又都要傳一堆 props 微調,反而更複雜。原因:抽象的價值來自「消除重複」,沒有重複就沒有價值,只有成本。根治法:遵循「出現第三次再抽象」的經驗法則,前一兩次保持 utility inline。

坑四:v4 在 Vue/Svelte scoped 或 CSS Modules 裡用 @apply 卻忘了 @reference,樣式失效。 主 CSS 裡 @apply 正常,一搬進 <style scoped> 就報錯或沒效果。原因:scoped/module 的 CSS 被獨立編譯,看不到你的 Tailwind 主設定。根治法:在該區塊最上面加 @reference "你的主 css";(無自訂 theme 時可用 @reference "tailwindcss";)。

<style scoped>
/* ✅ 先 @reference,@apply 才找得到 utility 定義 */
@reference "../app.css";
.badge { @apply px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700; }
</style>

坑五:v4 還去 @layer utilities 或寫 plugin 才能加自訂 utility,繞了遠路。 從 v3 升上來的人習慣用 @layer utilitiesaddUtilities plugin,在 v4 其實有更簡潔的 @utility 指令。原因:v4 CSS-first 哲學把這件事收斂成一個指令。根治法:自訂 utility 一律用 @utility name { ... },自動吃變體、進對 layer。

/* ❌ v4 還這樣繞:@layer utilities(普通 class,變體支援不完整) */
/* @layer utilities { .content-auto { content-visibility: auto; } } */

/* ✅ v4 正確做法:@utility,自動獲得變體與正確 cascade layer */
@utility content-auto { content-visibility: auto; }

「其實不需要抽象」的兩個解法:多游標與迴圈渲染

在急著抽象之前,先想想是不是根本不需要。Adam Wathan 特別提過兩個「重複但不必抽象」的情況:

  • 重複發生在同一畫面、少量幾處 → 用編輯器多游標(multi-cursor)編輯。 如果那串 class 只是在同一個檔案裡連續出現幾次(例如一排三顆功能相同的按鈕),與其抽成元件,不如用編輯器的多游標一次改完。抽象在這裡是殺雞用牛刀。
  • 重複來自「同一份資料的清單」→ 用迴圈渲染,而非抽元件。 如果十張卡片其實是 items.map(...) 出來的,那樣式自然只寫在迴圈本體「一處」,天然就是單一真相來源——你根本沒有重複,自然不需要抽象。

這兩個解法的共同精神是:「消除重複」不一定要靠抽象,有時靠「更好的編輯方式」或「更好的資料結構」就解決了。 先問「這個重複是真的需要一個可重用元件,還是只是渲染方式不對?」再決定。

元件抽象最佳實踐清單

  • 有框架就抽框架元件:React/Vue/Svelte 專案,首選把重複 markup 抽成元件,以 props 暴露變化——樣式與邏輯一起封裝,不失去任何 Tailwind 優勢。
  • @apply 當逃生艙用:只在純 HTML、第三方/Markdown HTML、或全域標籤樣式(bodyah1)這些「無法抽框架元件」的場景才用。
  • 需要可攜又吃變體的 class → v4 @utility:比 @apply 更好,自動支援 hover:/dark:/md: 且進對 cascade layer;取代 v3 的 @layer utilitiesaddUtilities
  • scoped/module CSS 用 @apply 記得 @reference:Vue <style scoped>、Svelte、CSS Modules 裡先加 @reference "你的主 css";,否則 @apply 失效(v4 專屬坑)。
  • 別過早抽象:遵循「第三次出現再抽象」,前一兩次保持 utility inline;沒有真正的重複,抽象只有成本沒有價值。
  • 重複不一定要抽象:同畫面少量重複用多游標編輯,清單型重複用迴圈渲染——先確認這是不是「渲染方式問題」而非「需要元件」。
  • 管理變體可用 CVA / cn():框架元件裡變體一多,可用 class-variance-authority 管理 variant 字串、用 clsx + tailwind-merge(cn())做條件式合併與衝突解決(進階,本系列後續會談)。

小結

這是 TailwindCSS 完整教學 系列的第二十七篇,也為 TW-6「元件模式」 章節正式揭開序幕。上一篇 《暗色模式》 讓介面學會「隨白天黑夜切換氣氛」,也讓我們累積了大量重複的 class;而這一篇,我們學會把散落的 utility 收斂成可重用、好維護的元件。回顧幾個重點:

  • 問題本質:痛點不是「字串太長」,而是「複製了樣式、缺少單一真相來源」;解法是「抽取可重用單位」,不是「把長字串藏起來」。
  • 三種策略:框架元件(有框架時首選,樣式+邏輯一起封裝、不失優勢)、@apply(逃生艙,產生不吃變體的普通 class,限純 HTML 等場景)、v4 @utility(可攜且自動吃變體,取代 v3 @layer utilities)。
  • @apply 的正確與濫用:Adam Wathan 定位它為逃生艙;別因字串長就用它、別拿它表達父子關係;v4 在 scoped/module CSS 裡用它記得先 @reference
  • 何時別抽象:過早抽象是負擔,遵循「第三次再抽象」;同畫面少量重複用多游標、清單型重複用迴圈渲染,常常根本不需要抽象。
  • v4 差異:自訂 utility 用 @utility(取代 @layer utilities/addUtilities);scoped/CSS Modules 的 @apply@reference

掌握了元件抽象,你已經能讓 Tailwind 專案在規模變大時依然乾淨。但「抽出好元件」只是規模化的一環——命名、class 排序、目錄結構、效能與可維護性,還有一整套值得內化的慣例。下一篇 《最佳實踐》 就要把這些散落的經驗法則收攏成一份完整的清單,帶你把前面所學織成一套能長期維護、經得起團隊協作的 Tailwind 工作流。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →