TailwindCSS 類別偵測機制:內容掃描、safelist 與動態 class 陷阱完整解析 | TailwindCSS 完整教學
TailwindCSS 最讓新手困惑的一件事,往往是「我明明寫了 class,樣式卻憑空消失」。答案藏在它的類別偵測(class detection)機制裡——Tailwind 靠的是內容掃描(content scanning),一種字串層級的搜尋,而不是執行你的程式碼。這是 TailwindCSS 完整教學 系列的第三篇,帶你看懂掃描器怎麼「讀」你的原始碼,並徹底避開
text-${color}這類動態拼接的陷阱。
前言
類別偵測(class detection)指的是 TailwindCSS 在建構時,從你的原始碼裡「找出」哪些 utility class 被使用,藉此決定要產生哪些 CSS 規則的過程。它的關鍵在於:Tailwind 不解析語法樹(AST)、也不執行 JavaScript,而是做一種快速字串掃描——只把實際以完整字面字串出現的 class 納入輸出。
用一個現實世界的類比來理解:想像掃描器是一位只認得完整單字的圖書館員。你請他把書架上所有寫著「蘋果」的卡片挑出來,他會逐字比對——看到「蘋」和「果」分別寫在兩張不同卡片上,他不會自己把它們拼成「蘋果」,因為他的工作是比對字面,不是推理組合。同理,當你寫 `text-${color}-500` 時,掃描器看到的是 text- 和 -500 兩個片段,它不會、也無法在腦中執行你的變數把它拼成 text-red-500。這正是無數「class 憑空消失」bug 的根源。
本篇你將學到:
- 內容掃描的本質——為什麼它是「字串掃描」而非「語法解析」,以及這個設計決策的代價
- 動態拼接 class 的完整陷阱清單,以及對應的安全寫法(映射物件、
clsx/cn) - v4 的自動內容偵測如何取代 v3 的
content陣列,以及@source指令的各種用法 - 如何用 safelist(v4 的
@source inline())確保 class 不被 purge,以及一套排查流程
本系列以 TailwindCSS v4 為預設版本,遇到與 v3 有明顯差異之處會特別標註。
核心概念
掃描而非解析:偵測的本質
上一篇《架構與 Oxide 引擎》我們拆解了 v4 建構管線的五個階段,其中第二階段的 Scanner(掃描器) 就是本篇的主角。這裡要強調一個容易被誤會的重點:掃描器不是 JavaScript 或 HTML 的解析器,它只做字串層面的搜尋。
掃描流程(概念)
══════════════════════════════════════════════════════════════
原始檔案:
┌─────────────────────────────────────────────┐
│ <button class="px-4 py-2 bg-brand │
│ text-white rounded-md │
│ hover:bg-brand/90"> │
│ Click me │
│ </button> │
└─────────────────────────────────────────────┘
│
│ 字串掃描(非 HTML 解析)
▼
┌─────────────────────────────────────────────┐
│ 提取到的 class candidates(候選字串): │
│ px-4, py-2, bg-brand, text-white, │
│ rounded-md, hover:bg-brand/90 │
└─────────────────────────────────────────────┘
│
│ 驗證:哪些是有效的 utility?
▼
┌─────────────────────────────────────────────┐
│ 確認為有效 utility → 產生對應 CSS │
│ .px-4 { padding-left: 1rem; ... } │
│ .bg-brand { background-color: var(...); } │
│ .hover\:bg-brand\/90:hover { ... } │
└─────────────────────────────────────────────┘
掃描器把整份原始碼當成一大團文字,用啟發式規則切出一個個「看起來像 class」的候選字串(candidate),再逐一驗證哪些是有效的 utility,最後只為有效的那些產生 CSS。關鍵推論是:因為它是字串掃描而非程式執行,任何需要在**運行時(runtime)**才能確定的 class 名稱,都無法在建構時被偵測到。
這裡有一個容易被忽略、卻能省下大量除錯時間的洞見:因為掃描器不理解語法,它其實不在乎你把 class 寫在哪裡。無論你的 bg-blue-500 是寫在 HTML 的 class 屬性、JSX 的 className、Vue 的 :class、後端模板(如 .py、.rb、.php)的字串,甚至是 JavaScript 的一般變數宣告裡,只要它是一段完整、連續的字面字串,掃描器都能認出來。反過來說,就算你把 class 端端正正寫在標準的 class 屬性裡,只要它是被拼接出來的,掃描器一樣看不到。「位置對不對」從來不是重點,「是不是完整字面字串」才是唯一的判準。 記住這個原則,你就能理解為什麼有些看起來「很不正規」的寫法反而有效,而某些「看起來很標準」的寫法卻失效。
值得一提的是,這個掃描邏輯在 v3 是用 JavaScript 加正則表達式實作,在 v4 則由上一篇提到的 Rust 版 Oxide 引擎接手。實作語言換了、速度快了一個數量級,但偵測的根本規則沒有改變——依舊是「找完整字面字串」。因此本篇談的所有陷阱與解法,在 v3 與 v4 通用,差別只在配置與 safelist 的語法(後面會逐一標註)。
為什麼字串拼接會破壞偵測
理解了掃描的本質,就能解釋本篇最重要的現象——為什麼 `text-${color}-500` 會失效。讓我們從掃描器的視角逐步拆解:
掃描器視角分析
══════════════════════════════════════════════════════════════
你的程式碼:
const color = 'red'
const cls = `text-${color}-500`
掃描器掃到的字串(字面值):
"color"、"cls"、"text-"、"color"、"-500"
→ 沒有任何一個是完整的 utility class
你期望的行為:
在執行時 → 拼成 text-red-500 ✅
實際情況:
建構時掃描 → text-red-500 這個完整字串「從未出現」在原始碼中
→ 不產生 .text-red-500 的 CSS
→ 最終 HTML 上 class 屬性有值,但沒有對應樣式 ❌
這是設計決策,不是 bug:
Tailwind 選擇建構時靜態分析(換來零運行時),
代價就是 class 必須以完整字串出現在原始碼中。
這一點值得反覆強調:它是設計決策,不是 bug。Tailwind 用「class 必須完整出現」這個限制,換來了「零運行時、CSS 體積精簡、可靜態分析快取」等好處。掃描器不執行你的 JavaScript,自然也就無從得知 color 變數在執行時會是什麼值。
為什麼 Tailwind 甘願接受這個限制?因為對立面的代價更高。如果要支援任意動態 class,掃描器就必須在建構時「執行」你的程式碼來推斷所有可能的值——這在理論上等同於要解決不可判定的問題(想想從 API 或使用者輸入來的值,建構時根本不存在),實務上則意味著要把整個 JavaScript 執行環境搬進建構流程,效能與可預測性都會崩塌。Tailwind 選了另一條路:建構時只做靜態分析,把「動態」的責任交還給開發者,由你負責確保用到的 class 以完整字串出現。這也是為什麼許多 CSS-in-JS 方案(如 styled-components)能做到完全動態,代價卻是把樣式計算推到瀏覽器運行時、增加 JavaScript bundle 體積。兩種取捨沒有絕對優劣,但你必須理解 Tailwind 站在哪一邊,才能順著它的設計走、而不是跟它對抗。
安全 vs 不安全:一條清楚的分界線
把上面的原理翻譯成實務準則,就是一條非常清楚的分界線——完整 class 字串有沒有原原本本地出現在原始碼裡:
靜態分析的根本限制
══════════════════════════════════════════════════════════════
✅ 安全:完整 class 字串出現在原始碼中
<!-- HTML -->
<div class="bg-red-500"> ← 完整字串 ✅
<div class="hover:bg-green-400"> ← 完整字串 ✅
// JavaScript:三元運算子的兩端都是完整字串
const cls = condition
? 'bg-red-500' ← 完整字串 ✅
: 'bg-blue-500' ← 完整字串 ✅
❌ 不安全:動態拼接,掃描器只看到片段
const cls = `bg-${color}-500` ← 看到:bg-、-500 → 無效 ❌
const cls = 'bg-' + color + '-500' ← 看到:bg-、-500 → 無效 ❌
const cls = ['bg', color, '500'].join('-') ← 分散片段 → 無效 ❌
根本原因:掃描器只看檔案中出現的「字串字面值」,不執行程式。
只要記住這條線,大部分的 class 偵測問題都能事先避免:三元運算子安全(因為兩端都是完整字串),字串模板與字串相加不安全(因為完整 class 被拆成了片段)。
這裡有個新手常見的困惑值得澄清:三元運算子 condition ? 'bg-red-500' : 'bg-blue-500' 明明也「依條件變化」,為什麼它安全、而 `bg-${color}-500` 卻不安全?兩者的差異不在於「有沒有動態」,而在於完整字串有沒有實際寫在原始碼裡。三元運算子的兩個分支 'bg-red-500' 與 'bg-blue-500' 都是原原本本、一字不差地出現在你的檔案中,掃描器兩個都能掃到,於是兩個對應的 CSS 規則都會被產生,執行時挑哪一個由瀏覽器決定——這對掃描器完全沒有負擔。而字串模板的完整結果 bg-red-500 從未以連續字串形式出現過,它是被 ${} 從中間切開的。分界線永遠是這一句話:掃描器要能在你的原始碼裡「看見」那一整串 class。
關鍵術語速覽
- 內容掃描(content scanning):Tailwind 從原始碼提取 class 候選字串的過程。
- 候選字串(candidate):掃描器切出來、還沒驗證的「看起來像 class」的字串。
- purge / tree-shaking:把沒用到的 class 排除在輸出之外——在 JIT 時代這不是額外步驟,而是「只產生用到的」的自然結果。
- safelist:明確登記「不管有沒有被掃描到,都強制產生」的一組 class,v4 用
@source inline()達成。 @source:v4 的 CSS 指令,用來告訴掃描器「額外去掃這個路徑」或「強制納入這些 class」。
補充一個常見的觀念釐清:在 JIT 時代,「purge」這個詞其實已經有點名不副實。在舊時代(Tailwind v1 與早期 v2)確實是先產生全量 CSS、再靠 PurgeCSS 事後「清除」沒用到的規則,所以叫 purge。但 JIT 引擎的做法是打從一開始就只產生用到的 class,根本沒有「先全部生出來再刪掉」這個步驟——它是「加法」而非「減法」。因此在 v4 語境下,與其說「怎麼避免 class 被 purge 掉」,更精確的說法是「怎麼確保 class 有被掃描到、進而被產生出來」。本篇後面談的所有手法(映射物件、@source、safelist),本質上都是在回答同一個問題:讓那一整串完整的 class 字串,確實出現在掃描器看得到的地方。
實作範例
理論看完,我們來看實際的程式碼——先示範會踩雷的動態拼接,再給出對應的安全寫法,最後是 v4 的 @source 與 safelist 設定。
反例:會失效的動態拼接
以下這個 React 元件看起來很直覺,但它產生的樣式在正式建構後會全部消失:
// ❌ 不安全:用字串模板拼接 class,掃描器看不到完整字串
function BadBadge({ color, children }) {
return (
// 開發時若有殘留全量 CSS 可能「碰巧」有效,正式建構後必定失效
<span className={`bg-${color}-500 text-white`}>
{children}
</span>
)
}
// 其他同樣不安全的常見寫法:
const size = 'lg'
const cls1 = 'px-' + size // ❌ 字串相加 → px-lg 拼不出來
const { themeClass } = await fetchTheme() // ❌ 來自 API,建構時未知
const saved = localStorage.getItem('theme') // ❌ 運行時才有值
這些寫法的共同點是:完整的 class 字串在原始碼裡根本不存在,它只在執行時才被組合出來,而掃描器不執行程式。
正例一:完整 class 字串的映射物件
最推薦的解法是把每個可能的完整 class 字串預先寫成映射物件(lookup object)。掃描器能清楚看到每一個完整字串:
// ✅ 安全:物件的每個值都是完整 class 字串
const colorVariants = {
red: 'bg-red-500 text-white',
blue: 'bg-blue-500 text-white',
green: 'bg-green-500 text-white',
gray: 'bg-gray-100 text-gray-900',
}
function Badge({ color, children }) {
// 用鍵查表,而不是拼接字串
return <span className={colorVariants[color]}>{children}</span>
}
差別的本質在於:colorVariants[color] 是查表,而 `bg-${color}-500` 是拼接。查表用到的 bg-red-500 等字串完整寫在原始碼裡,掃描器看得到;拼接則否。
正例二:clsx / cn 條件合併
在稍大的元件庫中(例如 shadcn/ui 風格),常用 clsx 搭配 tailwind-merge 組成一個 cn 工具來做條件式合併。重點同樣是每個 class 都是完整字串字面值:
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
// cn 工具:合併條件 class 並解決衝突
function cn(...inputs) {
return twMerge(clsx(inputs))
}
function Button({ variant, size, disabled, className, children }) {
return (
<button
className={cn(
// 基礎樣式(完整字串)
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
// 變體:key 是完整 class 字串,value 是布林條件
{
'bg-brand text-white hover:bg-brand/90': variant === 'primary',
'border border-gray-200 bg-white text-gray-900 hover:bg-gray-50': variant === 'secondary',
},
// 尺寸(每個值都是完整字串)
{
'px-3 py-1.5 text-xs': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
},
// 狀態與外部傳入
disabled && 'opacity-50 pointer-events-none',
className,
)}
disabled={disabled}
>
{children}
</button>
)
}
注意 clsx 的物件語法裡,key 才是 class 字串、value 是條件({ 'bg-brand': isPrimary }),這樣掃描器仍能從原始碼看到 bg-brand 這個完整字串。
v4 的自動內容偵測
在進到 safelist 之前,先看 v4 最大的便利——自動內容偵測(automatic content detection)。v3 需要你在 config 裡手動維護 content 陣列,v4 則用一套啟發式規則自動搞定:
v4 自動偵測規則
══════════════════════════════════════════════════════════════
✅ 自動掃描:
├── 從 CSS 入口檔(@import "tailwindcss" 的位置)往上找專案根目錄
├── 掃描所有文字型檔案(.html, .jsx, .tsx, .vue, .svelte, .py, .rb 等)
├── 尊重 .gitignore 規則(.gitignore 裡的檔案不掃)
└── 自動排除:
├── node_modules/(黑名單)
├── .git/(黑名單)
├── 二進位檔案(圖片、字型、影片等)
└── 鎖定檔(package-lock.json、yarn.lock 等)
⚠️ 需要手動追加(用 @source 指令):
├── node_modules 內的 UI 函式庫(預設被排除)
├── 建構產物目錄(若 class 只在其中出現)
└── 專案根目錄以外的路徑
對照 v3 的心智負擔——忘了把某個副檔名加進 content,class 就默默不生成——v4 的自動偵測讓「掃描範圍」在絕大多數專案裡不再需要操心。
這個「尊重 .gitignore」的設計其實相當聰明。你的 .gitignore 早已幫你列好了「不屬於原始碼」的東西——建構產物、快取、依賴目錄——這些正好也是掃描器該跳過的。與其讓你再維護一份幾乎重複的排除清單,v4 直接複用 .gitignore,一舉兩得。這也帶出一個實務上的小提醒:如果你有一份版控在案、確實含有 class 的檔案,卻因為某種原因被 .gitignore 排除了(例如產生器輸出但你又想 commit 的檔案),那麼掃描器也會一併略過它,這時就要靠下面的 @source 手動補回來。理解「掃描範圍 = 專案原始碼樹 − .gitignore − 黑名單」這個公式,你就能預測任何一個檔案會不會被掃到。
@source 指令:擴充與收斂掃描範圍
當 class 出現在自動偵測掃不到的地方時,用 @source 補上。它有幾種常用形態,全部寫在入口 CSS 裡:
/* src/index.css */
@import "tailwindcss";
/* ① 追加掃描一個預設被排除的路徑(如某個 UI 函式庫) */
@source "../node_modules/my-ui-lib";
/* ② 排除某些路徑(v4 進階語法) */
@source not "./src/generated/**";
/* ③ 設定掃描基準路徑(monorepo 常用) */
/* 寫在 @import 那一行: */
/* @import "tailwindcss" source("../src"); */
/* ④ 完全關閉自動偵測,只掃你明確登記的來源 */
/* @import "tailwindcss" source(none); */
/* @source "../admin"; */
/* @source "../shared"; */
v3 差異:v3 是在
tailwind.config.js的content陣列列出所有 glob(如content: ['./src/**/*.{js,jsx,ts,tsx}'])。v4 把這件事移進 CSS,改用@source指令,並讓「不設定」成為預設可用的狀態。
safelist:用 @source inline() 強制保留 class
當 class 名稱存在於資料庫、CMS、API 回傳值或第三方函式庫中,它們永遠不會以字面字串出現在你的原始碼裡,自動偵測必然掃不到。這時就需要 safelist——在 v4 裡用 @source inline() 達成:
/* src/index.css */
@import "tailwindcss";
/* 單一 class */
@source inline("bg-red-500");
/* 多個 class(用空格分隔) */
@source inline("bg-red-500 bg-blue-500 bg-green-500 text-white text-gray-900");
/* glob 展開(更簡潔),會自動展開成多個 class */
@source inline("{bg,text,border}-{red,blue,green,yellow}-{400,500,600}");
/* 上一行展開為 bg-red-400 bg-red-500 ... 共 3×4×3 = 36 個 class */
/* 為動態色彩系統建立完整 safelist */
@source inline("{bg,text}-{brand,accent,neutral}-{50,100,200,300,400,500,600,700,800,900}");
v3 差異:v3 的 safelist 寫在
tailwind.config.js的safelist: [...]陣列裡(也支援用 regexpattern)。v4 一律改用 CSS 裡的@source inline(...),語法更貼近「登記來源」的心智模型。
常見錯誤與最佳實踐
最容易踩的陷阱
陷阱一(頭號殺手):動態拼接 class。 前面已詳述,這裡再列幾個容易「偽裝成安全」的變形:
// ❌ 隱蔽的拼接——看起來像在寫條件,實際仍是拼接
const type = 'error'
const borderClass = 'border-' + type // → 'border-error',掃描器只看到 border-
// ❌ Object.keys 動態生成
const theme = { primary: '#3b82f6', accent: '#f59e0b' }
Object.keys(theme).map(key => `bg-${key}`) // 全是動態拼接
// ✅ 正解:改成完整字串的條件
const borderClass = type === 'error' ? 'border-red-500' : 'border-gray-200'
陷阱二:從 localStorage / Cookie / API 讀取 class 名稱。 這些值運行時才存在,掃描器看不到。解法是把「可能出現的有限值」用 @source inline() 全部 safelist 起來。
陷阱三:檔案在掃描範圍外。 class 寫對了但檔案被 .gitignore 排除、或在 node_modules 裡、或副檔名不被辨識。用 @source 追加即可。
class 沒生成?五步排查流程
當某個 class 就是不出現在輸出 CSS,照這個順序查,幾乎都能定位問題:
排查步驟
══════════════════════════════════════════════════════════════
步驟 1:確認 class 是完整字串字面值
✅ class="text-red-500" ❌ class={`text-${color}-500`}
步驟 2:確認檔案在掃描範圍內
• 被 .gitignore 排除了嗎? • 在 node_modules 裡嗎?(需 @source)
• 副檔名是否可辨識?(.html/.jsx/.tsx/.vue 等)
步驟 3:確認 class 本身有效
• 是 Tailwind 提供的 utility 嗎?拼字對嗎?
• 是否是 v3 舊 class 在 v4 已被重命名?
步驟 4:用 @source inline() 強制納入測試
@source inline("text-red-500");
→ 若加了就有效,代表問題出在「掃描範圍」而非 class 本身
步驟 5:清除快取並重建
rm -rf node_modules/.cache/
npx @tailwindcss/cli -i input.css -o output.css --watch
步驟 4 是一個很好的二分法診斷:強制納入後若生效,代表 class 本身有效、問題在掃描範圍;若仍無效,代表 class 名稱本身有誤(拼錯或已被 v4 重命名)。
決策樹:如何安全地做動態樣式
最後給一個決策原則,涵蓋絕大多數「要根據狀態切換樣式」的需求:
決策原則
══════════════════════════════════════════════════════════════
需求:根據 props / 狀態動態套用不同樣式
方案 A(首選):預先定義映射物件
const colors = { red: 'bg-red-500', blue: 'bg-blue-500' }
<div className={colors[colorProp]}>
方案 B(可用):safelist + 已知有限值
@source inline("{bg}-{red,blue,green}-500");
<div className={`bg-${colorProp}-500`}> // colorProp 只有 3 種可能
方案 C(避免):無限制的動態值
<div className={`bg-${anyUserInput}`}> // 完全無法預測,掃不到
原則:
├── 可預期的有限值 → 方案 A(映射物件)或方案 B(safelist)
└── 真正無限 / 任意的值 → 別用 utility class,改用 inline style:
<div style={{ backgroundColor: userColor }}>
為什麼「真正無限的值」要退回 inline style,而不是硬用 safelist 撐?因為 safelist 的每一個 class 都會實際產生一條 CSS 規則進最終輸出。如果你為了一個「可能是任意色碼」的需求,把上百種顏色全部 safelist 起來,就等於親手抵銷了 Tailwind「只產生用到的 class」帶來的體積優勢,CSS 檔會無謂地膨脹。safelist 適合的是「可預期、數量有限」的集合(例如一個色票系統的十來種主色);一旦可能值真的無界(使用者自選色、來自後端的任意數值),utility class 就不是對的工具了,直接用 style={{ }} 把值傳給瀏覽器才是正解——這正好也回到了前一節「Tailwind 把動態責任交還給開發者」的設計哲學。
一句話總結最佳實踐:能查表就別拼接;非拼接不可時,就用 @source inline() 把有限的可能值全部登記起來;真正無限的動態值則交給 inline style。
小結
這是 TailwindCSS 完整教學 系列的第三篇。上一篇《架構與 Oxide 引擎》我們看懂了 v4 建構管線的五個階段,這一篇則聚焦在管線的第二階段——Scanner(掃描器)到底怎麼看你的程式碼。回顧幾個重點:
- 類別偵測靠的是字串掃描,不是執行程式。 掃描器把原始碼當文字搜尋完整的 class 字串,凡是運行時才能確定的 class 都偵測不到。
- 動態拼接是頭號陷阱。
`text-${color}-500`之所以失效,是因為完整字串text-red-500從未出現在原始碼——這是設計決策換來零運行時的代價,不是 bug。 - 安全寫法是查表而非拼接:映射物件、
clsx/cn條件合併,關鍵都在於每個 class 都是完整字串字面值。 - v4 用自動內容偵測取代 v3 的
content陣列,尊重.gitignore並自動排除node_modules;需要時用@source擴充或收斂掃描範圍。 - safelist 在 v4 改用
@source inline(...)(v3 是 config 的safelist陣列),用來強制保留來自 CMS、API、資料庫或第三方函式庫、掃描不到的 class。
掌握了掃描器的脾氣後,你會發現許多「玄學 bug」其實都有清楚的因果。下一篇 《TailwindCSS 配置系統》,我們將深入 v4「配置即 CSS」的核心——@theme 指令如何定義設計 Token、CSS 變數如何貫穿整個系統,以及它與 v3 tailwind.config.js 的根本差異。掃描器決定了「哪些 class 會被產生」,而配置系統則決定了「這些 class 產生出什麼樣的值」,兩者正是理解 Tailwind 運作的一體兩面。