Vue 整合:Vite 裝 Tailwind v4 + :class 綁定 | TailwindCSS 完整教學
Vue 整合 和 React 一樣,把 TailwindCSS 用好的關鍵不在「裝進去」,而在「怎麼在元件裡優雅地綁 class」。在 Vue + Vite 專案裡,TailwindCSS v4 只要一個
@tailwindcss/viteplugin 就能裝好;而真正讓 SFC 可維護的,是三件事:用:class的物件/陣列語法依條件切換樣式、釐清scoped style與 Tailwind 的分工,以及記牢在<style>用@apply前必須先@reference。這一篇帶你走完安裝、:class綁定、scoped 共存與 Nuxt 路徑,並拆解動態 class 拼接這個最致命的坑。
前言
Vue 是當今前端三大框架之一,而 TailwindCSS 幾乎是它最自然的搭檔。上一篇《React 整合》我們在純 Vite + React 的環境裡,把 utility class 收進可維護的元件庫;這一篇換一個生態系——Vue 的單檔元件(Single File Component,簡稱 SFC)有自己的一套結構:<template>、<script setup>、<style scoped> 三段各司其職。所謂「Vue 整合」,講的不只是把 Tailwind 裝進 Vite,更重要的是:如何在 Vue 的模板語法下把 class 綁得又乾淨又動態,同時讓 Tailwind 與 Vue 的 scoped style 各自待在最擅長的位置。
先用一個生活化的類比。如果說 React 裡的 className 是一整條「寫死的字串」,那 Vue 的 :class 就像一台調音台——上面有一排開關和推桿。你不需要重寫整條字串,只要撥動對應的開關({ 'bg-blue-600': isActive }),某一組樣式就開或關;或者把幾組推桿排成一列(陣列語法),同時調配多個樣式群組。這台調音台讓「依狀態切換樣式」變得直覺,是 Vue 綁定 Tailwind 最順手的地方。
但調音台再好用,也有一個常被忽略的插孔:當你想在 <style> 區塊裡用 @apply 把一串 utility 打包成一個語意化的 class 時,這個區塊預設是接不到 Tailwind 主機的——你得先用 @reference 把線接上,@apply 才會通電。這是 Vue(和 Svelte)在 v4 專屬的眉角,也是本篇最重要的一課。
本系列以 TailwindCSS v4 為預設版本。本篇你將學到:
- v4 安裝:用
@tailwindcss/vite一個 plugin 把 Tailwind 裝進 Vue + Vite,以及 Nuxt 的兩條整合路徑 :class綁定:用物件語法、陣列語法、computed三種方式在 SFC 裡依狀態動態切換 class- scoped style 共存:釐清哪些樣式該交給 Tailwind utility、哪些該留給
<style scoped> @reference眉角:為什麼在 SFC 的<style>用@apply、theme()必須先@reference- 常見陷阱:動態 class 拼接為什麼會讓樣式憑空消失,以及 v4 的
@source inline解法
本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。
核心概念
v4 + Vite 的整合:一個 plugin 就夠
先看清楚資料怎麼流。在 Vue + Vite 專案裡,一段 Tailwind class 從你寫下到變成瀏覽器裡的樣式,走的是這條路:你在 .vue 的 <template> 裡寫了 class="bg-white p-4" → Vite 的建置流程觸發 @tailwindcss/vite 這個 plugin → Tailwind 的 Oxide 引擎掃描專案原始碼(包含 .vue 檔的 template)、只生成用到的 utility → 產出最終 CSS 注入頁面。
這裡的關鍵是:v4 為 Vite 環境準備了專屬的 @tailwindcss/vite plugin,官方明確推薦在純 Vite 專案(涵蓋 React、Vue、Svelte、SolidJS)使用它。它整合最緊密、設定最少——不需要 postcss.config.js、不需要手動配置 content globs,效能也優於走 PostCSS 的路徑。這和上一篇的分界要牢記:純 Vite → @tailwindcss/vite;只有當專案已有非用不可的既有 PostCSS pipeline,才退而使用 @tailwindcss/postcss。
至於 CSS 入口,v4 一律收斂成單行 @import "tailwindcss",取代 v3 那三行 @tailwind base/components/utilities 指令。整個安裝的心智模型很單純:把 plugin 掛進 Vite、把那一行 @import 寫進 CSS、確認 main.ts 有載入這支 CSS,剩下的掃描與生成 Oxide 引擎會自動接手。
至於 Nuxt,它底層就是 Vite,因此有兩條整合路徑,各有適用場景,列表比較一下:
| 路徑 | 做法 | 適用情境 |
|---|---|---|
| 第一方 Vite plugin | 在 nuxt.config.ts 的 vite.plugins 掛 @tailwindcss/vite | 想要最貼近官方、最少依賴,完全掌控設定 |
| 社群模組 | 安裝 @nuxtjs/tailwindcss,自動處理 PostCSS 與 CSS 注入 | 想要開箱即用、模組幫你打理設定(需確認版本已支援 v4) |
這張表的結論:追求最少依賴與官方一致性,就掛 @tailwindcss/vite;想要模組幫你打理一切,就用 @nuxtjs/tailwindcss——但務必確認它已跟上 v4 的 @import "tailwindcss" 與 CSS-first 設定,若模組尚未支援,直接掛第一方 plugin 最穩。
:class 綁定:Vue 的樣式調音台
在 React 裡,動態 class 靠字串拼接或 cn() 工具處理;在 Vue,模板本身就提供了強大的 :class 綁定(v-bind:class 的縮寫),讓你不用寫任何工具函式就能依狀態切換樣式。它有三種主要寫法,理解它們的分工,是把 Tailwind 用在 Vue 裡的核心:
- 物件語法:
:class="{ 'class 字串': 條件 }"。key 是完整的 class 字串,value 是 boolean,條件為true時該組 class 才套用。這是「依狀態開關某組樣式」最直覺的寫法。 - 陣列語法:
:class="[classA, classB]"。把多個 class 來源排成一列,適合「組合多個樣式群組」,陣列元素可以是字串、三元運算式,甚至巢狀的物件。 computed集中管理:當條件變多,直接寫在 template 裡會很擁擠,這時把 class 邏輯抽到<script setup>的computed屬性,回傳一個物件或陣列,template 只需:class="buttonClass",可讀性最好。
還有一個 Vue 貼心的細節:靜態 class 與動態 :class 可以並存,Vue 會自動把兩者合併。所以你能把「永遠都要的基礎樣式」寫在靜態 class,把「依狀態變動的部分」交給 :class,兩邊各司其職。這種分工讓 template 讀起來一目了然:靜態的是骨架,動態的是狀態。
這裡也順帶點出 Vue 相較 React 的一個體感差異。React 裡沒有內建的 class 條件語法,想依狀態切換 class 得靠三元運算式、模板字串,或引入 clsx、cn() 這類工具函式;而 Vue 把「條件挑 class」直接做進了模板語言——物件語法本質上就是 clsx 的內建版,你不必額外裝套件就能享有同樣的便利。不過要留意,Vue 的 :class 並沒有內建 tailwind-merge 的衝突解決能力:如果你的物件語法裡同時把兩個互斥的 utility(例如 px-4 和 px-8)都設成 true,兩者會一起輸出,最終誰勝出仍由 CSS 生成順序決定。所以在 Vue 裡避免衝突的正解,是讓條件互斥——用 size === 'sm'、size === 'md' 這種彼此排他的條件,確保同一時間只有一組 class 為 true,而不是靠某個工具事後去重。這是 Vue 與 React 生態在處理 Tailwind 動態 class 時,思路上一個關鍵的分野。
scoped style 與 Tailwind 的分工
Vue 的 <style scoped> 會為元件內的每個選擇器加上獨一無二的屬性標記,讓樣式只作用於當前元件,不會外洩污染其他元件。這和 Tailwind 的 utility class 是兩種不同哲學,但它們不是競爭關係,而是互補——關鍵是搞清楚各自的守備範圍。
絕大多數樣式應該交給 Tailwind utility:布局(flex/grid)、間距(padding/margin)、顏色、字體、響應式、hover/focus 偽類,這些用 utility class 寫在 template 裡最快、最直觀,也不必在兩個地方來回跳。
<style scoped> 則留給 Tailwind 不擅長的場景:多 keyframe 的複雜 CSS 動畫、需要 :deep() 深度選擇器去覆蓋第三方元件的樣式、局部作用域的 CSS 變數、或複雜的 nth-child 選擇器。這些用 utility 表達會很彆扭,寫進 scoped style 反而清晰。
一句話總結分工原則:能用 utility 就用 utility,只有當 Tailwind 表達不了或表達得很醜時,才動用 <style scoped>。這條界線劃清楚,你的元件就不會出現「一半樣式在 template、一半在 style,改個東西要兩邊找」的混亂。
值得一提的是 :deep() 這個 scoped style 專屬的深度選擇器。Vue 的 scoped 機制會給元件內的元素加上唯一屬性,但這個屬性不會延伸到子元件或第三方元件的內部——這正是它「不外洩」的代價。當你需要覆蓋一個引入的 UI 元件庫(例如某個 date picker)內部的樣式時,一般的 scoped 選擇器打不進去,這時就得用 :deep() 包住目標選擇器,告訴 Vue「這條規則要穿透到子元件內」。這類「穿透覆蓋第三方元件」的需求,幾乎不可能用 Tailwind utility 在 template 上完成,因為你根本碰不到那個子元件的 template,所以它天生就是 <style scoped> 的守備範圍。理解這一點,你就明白為什麼即使全面採用 Tailwind,<style scoped> 仍有它不可取代的位置。
實作範例
理論說完,實際把 Tailwind v4 裝進一個 Vue + Vite 專案,並示範 :class 綁定、scoped 共存,以及在 <style> 用 @apply 前如何 @reference。
範例一:安裝——@tailwindcss/vite 一個 plugin 到位
先建立專案並安裝依賴。用官方腳手架建立 Vue + TypeScript 專案,再裝上 Tailwind v4 的 Vite plugin:
# 建立 Vue 3 + Vite + TypeScript 專案
npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
# 安裝 TailwindCSS v4 與其 Vite plugin
npm install tailwindcss @tailwindcss/vite
接著把 plugin 掛進 vite.config.ts,和 vue() 並列即可。注意這裡不需要 postcss.config.js:
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
vue(),
tailwindcss(), // Tailwind v4 Vite plugin,免 postcss.config.js、免 content 設定
],
});
然後在 CSS 入口寫上那一行 @import,這是 v4 唯一必要的一行:
/* src/style.css — v4 單行入口,取代 v3 的三行 @tailwind 指令 */
@import "tailwindcss";
最後確認 main.ts 有把這支 CSS 載進來——漏了這一步,樣式完全不會出現:
// src/main.ts
import "./style.css"; // 載入 Tailwind,漏掉則所有樣式失效
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
如果是 Nuxt 專案,最貼近官方的做法是在 nuxt.config.ts 裡把第一方 plugin 掛進 Vite,再指定一支含 @import "tailwindcss" 的 CSS:
// nuxt.config.ts — Nuxt 直接掛第一方 @tailwindcss/vite
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
css: ["~/assets/css/main.css"], // 這支 CSS 內含 @import "tailwindcss";
vite: {
plugins: [tailwindcss()],
},
});
範例二::class 綁定——物件、陣列、computed 三種語法
Tailwind 裝好後,先看 :class 的三種核心寫法。它們解決同一件事——依狀態切換樣式——但適用不同複雜度:
<script setup lang="ts">
import { ref, computed } from "vue";
const isActive = ref(false);
const hasError = ref(false);
const size = ref<"sm" | "md" | "lg">("md");
// computed:條件變多時,把 class 邏輯集中管理,template 才不會擁擠
const buttonClass = computed(() => ({
// 基礎樣式(永遠套用)
"inline-flex items-center justify-center rounded-lg font-medium transition-colors": true,
// 尺寸(依 size 切換,每個都是完整可掃描的 class 字串)
"h-8 px-3 text-sm": size.value === "sm",
"h-10 px-4 text-sm": size.value === "md",
"h-12 px-6 text-base": size.value === "lg",
// 狀態
"bg-blue-600 text-white hover:bg-blue-700": isActive.value,
"bg-gray-100 text-gray-700 hover:bg-gray-200": !isActive.value,
}));
</script>
<template>
<!-- 物件語法:key 是完整 class,value 是條件,最直覺的「開關某組樣式」 -->
<button :class="{
'bg-blue-600 text-white': isActive,
'bg-gray-100 text-gray-700': !isActive,
'border border-red-500': hasError,
}">
物件語法按鈕
</button>
<!-- 陣列語法:組合多個樣式群組,元素可以是字串、三元、巢狀物件 -->
<div :class="[
'p-4 rounded-lg',
isActive ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200',
hasError && 'border-red-500',
]">
陣列語法容器
</div>
<!-- computed:最清晰,template 只留一個綁定 -->
<button :class="buttonClass">computed 按鈕</button>
<!-- 靜態 class 與動態 :class 並存,Vue 會自動合併 -->
<span
class="px-2 py-1 rounded-full text-xs font-medium"
:class="isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
>
{{ isActive ? "啟用" : "停用" }}
</span>
</template>
範例三:可重用元件——用 computed 封裝變體
把 :class 綁定的概念收進一個可重用的按鈕元件。這裡用 props 決定外觀與尺寸,再用 computed 組出最終 class——效果類似 React 裡的 cva,但完全用 Vue 原生手法完成:
<!-- src/components/ui/Button.vue -->
<script setup lang="ts">
import { computed } from "vue";
interface Props {
variant?: "primary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
size: "md",
disabled: false,
});
// 用完整字串的對照表,確保每個 class 都能被 Tailwind 靜態掃描到
const variantClasses = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50",
ghost: "text-gray-700 hover:bg-gray-100",
};
const sizeClasses = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
// computed 陣列:基礎樣式 + 依 props 取出的變體 class
const buttonClass = computed(() => [
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500",
variantClasses[props.variant],
sizeClasses[props.size],
props.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
]);
</script>
<template>
<button :class="buttonClass" :disabled="disabled">
<slot />
</button>
</template>
範例四:scoped style 與 Tailwind 共存
大多數樣式用 utility,只把 Tailwind 表達不了的部分(複雜動畫、深度選擇器)留給 <style scoped>:
<template>
<div class="p-4 rounded-xl bg-white shadow-sm">
<!-- 布局、間距、顏色全用 Tailwind utility -->
<div class="spinning-icon w-8 h-8 text-blue-500">
<svg viewBox="0 0 24 24"><!-- ... --></svg>
</div>
</div>
</template>
<style scoped>
/* 只把 Tailwind 無法優雅表達的樣式寫進 scoped style */
/* 複雜的多 keyframe 動畫 */
.spinning-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* :deep() 深度選擇器:覆蓋第三方元件內部樣式 */
:deep(.third-party-component) {
padding: 0 !important;
}
</style>
範例五:在 用 @apply——必須先 @reference
這是 Vue 在 v4 最重要的眉角。假設你想在 <style> 裡把一串 utility 用 @apply 打包成語意化的 class,直接寫會失效——因為 SFC 的 <style> 區塊是獨立處理的,看不到主入口的 Tailwind 主題:
<!-- ❌ 錯誤:@apply 找不到 Tailwind 定義,樣式不會出現 -->
<style scoped>
.card-title {
@apply text-2xl font-bold text-blue-600; /* 失效!區塊看不到 Tailwind */
}
</style>
解法是在該 <style> 區塊最上方加一行 @reference,把主 CSS 的參照引進來。@reference 只匯入參照、不會重複輸出 CSS,所以不會讓打包體積膨脹:
<!-- ✅ 正確:先 @reference 主 CSS,@apply 才認得 Tailwind 主題 -->
<style scoped>
/* 引入主入口 CSS 的參照(路徑指向你 @import "tailwindcss" 的那支檔案) */
@reference "../assets/main.css";
.card-title {
@apply text-2xl font-bold text-blue-600; /* 現在正常運作 */
}
</style>
如果你只需要參照 Tailwind 的內建 token(沒有用到自訂主題),也可以直接引用套件名,更簡潔:
<style scoped>
/* 只需內建 token 時,直接 @reference "tailwindcss" 即可 */
@reference "tailwindcss";
.badge {
@apply inline-flex px-2 py-1 rounded-full text-xs font-medium;
}
</style>
常見錯誤與最佳實踐
坑一::style 用 @apply 卻忘了 @reference
這是從 v3 遷移到 v4 後,Vue 開發者最常撞到的牆:「我的 <style scoped> 裡 @apply 明明沒改,升級後突然全部失效了。」原因就是 v4 改變了 SFC <style> 的處理方式——每個區塊被當成獨立 CSS 檔,不再自動繼承主入口的 Tailwind 上下文。
只要記住一條規則就能避開:任何在 SFC <style>(或 CSS Module)裡使用 @apply、@variant、theme() 的地方,該區塊頂部都必須先 @reference。指向主 CSS 就能連自訂主題一起參照;只需內建 token 就 @reference "tailwindcss"。忘了這行,@apply 就會安靜地失效,連錯誤訊息都不一定給你。
這裡再多釐清一個容易混淆的點:@reference 為什麼「不會重複輸出 CSS」?因為它的作用純粹是讓當前 <style> 區塊看見 Tailwind 的定義(主題變數、utility 定義、變體),好讓 @apply 能查到對應的樣式並展開,但它本身不會把 Tailwind 的 base、utilities 那一大包 CSS 再輸出一次。這和你在主入口寫的 @import "tailwindcss" 是不同性質的兩件事:@import 是「真的把 Tailwind 這包 CSS 產生出來注入頁面」,而 @reference 是「只借用定義來解析 @apply,不產生額外輸出」。所以就算你在二十個元件的 <style> 裡都寫了 @reference,打包後的 CSS 也不會因此膨脹二十倍——這是 v4 特意設計的機制,讓你能安心在每個 SFC 裡使用 @apply 而不必擔心體積問題。反過來說,如果你誤把 @import "tailwindcss" 寫進 SFC 的 <style>(而不是 @reference),那才會真的重複輸出整包 CSS,造成樣式膨脹與潛在的重複載入問題。
坑二:動態 class 拼接——樣式憑空消失
這是所有前端框架共通、也是 Vue 專案裡最高頻的陷阱。Tailwind 靠靜態掃描原始碼決定生成哪些 utility,它不會執行你的 JavaScript。所以任何用變數「組」出來的 class 中間片段,它都掃不到:
<script setup lang="ts">
const color = "red";
</script>
<template>
<!-- ❌ 無效:Tailwind 掃不到 text-red-600,原始碼裡只有 text-、-600 碎片 -->
<p :class="`text-${color}-600`">Hello</p>
</template>
問題的根源是:原始碼裡從頭到尾不存在 text-red-600 這個完整字串。正確做法是用 :class 的物件語法,讓每個候選 class 都以完整、可被掃描的形式存在:
<template>
<!-- ✅ 有效:完整 class 字串以查找表形式靜態存在,Tailwind 掃得到 -->
<p :class="{
'text-red-600': color === 'red',
'text-green-600': color === 'green',
}">Hello</p>
</template>
若 class 確實來自執行期資料(例如來自 CMS 的顏色設定),沒辦法在原始碼窮舉,v4 改用 CSS 的 @source inline(...) 主動保留它們——這取代了 v3 的 safelist 陣列:
/* 主 CSS 入口:用 @source inline 強制保留這些動態 class(取代 v3 safelist) */
@import "tailwindcss";
@source inline("{bg,text}-{red,green,blue}-{500,600}");
鐵律:永遠不要用變數去拼接 class 名稱的中間片段。 要動態,就用物件語法把完整字串寫出來;真的無法窮舉時,才用 @source inline 保留。
坑三:誤把 Nuxt 模組當唯一解、忽略版本相容
不少 Nuxt 開發者習慣性 npm install @nuxtjs/tailwindcss 就上,卻沒注意這是社群模組,不一定即時跟上 Tailwind v4 的 CSS-first 設定與 @import "tailwindcss" 語法。若模組版本尚未支援 v4,你可能會遇到 @import 無效、自訂主題讀不到等奇怪問題。
穩健做法:先確認模組版本明確支援 v4;若不確定或想要最少依賴,直接在 nuxt.config.ts 掛第一方 @tailwindcss/vite(如範例一的 Nuxt 寫法),因為 Nuxt 底層就是 Vite,第一方 plugin 一定跟得上 v4 的最新特性,也免去第三方模組的相容性風險。
最佳實踐小結
- 安裝走 Vite plugin:Vue + Vite 專案用
@tailwindcss/vite,一個 plugin 到位;CSS 入口收斂成單行@import "tailwindcss",並確認main.ts有載入。 :class三語法分場景:單一狀態切換用物件語法、組合多群組用陣列語法、條件複雜就抽到computed;靜態class放骨架、動態:class放狀態。- scoped style 只補位:布局/間距/顏色交給 Tailwind,只有複雜動畫、
:deep()、局部 CSS 變數才動用<style scoped>。 @apply前先@reference:SFC<style>用@apply/theme()一定要先@reference主 CSS 或tailwindcss,這是 v4 最容易踩的坑。- 絕不動態拼接 class:用物件語法讓每個 class 完整出現在原始碼裡;真的動態就用
@source inline保留。 - Nuxt 優先第一方 plugin:社群模組要確認支援 v4,不確定就直接掛
@tailwindcss/vite。
小結
這是 TailwindCSS 完整教學 系列的第四十一篇。上一篇《React 整合》,我們在純 Vite + React 裡把 utility class 收進可維護的元件庫,靠 cn() 合併 class、cva 打造變體系統;這一篇換到 Vue 生態系,聚焦在 SFC 結構下如何綁定動態 class、如何讓 scoped style 與 Tailwind 共存,以及那個最容易踩的 @reference 眉角。回顧幾個重點:
- v4 + Vite 安裝:用專屬的
@tailwindcss/viteplugin,一個 plugin 到位,免postcss.config.js、免 content 設定;Nuxt 底層即 Vite,可掛第一方 plugin 或用@nuxtjs/tailwindcss模組(需確認支援 v4)。 :class綁定:物件語法開關某組樣式、陣列語法組合多群組、computed集中複雜邏輯,是 Vue 綁定 Tailwind 最順手的三種手法。- scoped style 分工:能用 utility 就用 utility,只把複雜動畫、
:deep()、局部 CSS 變數留給<style scoped>。 @reference眉角:SFC 的<style>是獨立處理的,用@apply/theme()前必須先@reference,否則安靜失效。- 三大坑:忘了
@reference、動態 class 拼接(用物件語法或@source inline)、Nuxt 模組版本相容,對照就能穩定避開。
掌握了 :class 綁定與 @reference 眉角,你就有了在任何 Vue 或 Nuxt 專案裡把 Tailwind 用好的完整基礎。下一篇《Flutter Web 對照》,我們暫時離開 JavaScript 生態,換一個截然不同的視角——看 Flutter 這套「一切皆 Widget」的框架如何處理樣式,以及它的思路和 Tailwind 的 utility-first 有哪些耐人尋味的異同,幫你在跨框架時建立更立體的樣式觀。