前幾天我們用 Proxy 做出迷你 reactive,也認識了 ref、reactive、watch。資料已經會「變」了,但使用者看不到資料,只看得到畫面。今天要講的就是資料和畫面之間的那座橋:模板語法。
<script setup>
import { ref } from 'vue'
const name = ref('Fabio')
const user = ref({ age: 30 })
</script>
<template>
<p>哈囉,{{ name }}</p>
<p>{{ name.toUpperCase() }}</p>
<p>{{ user }}</p>
</template>
大括號裡可以放「一個 JavaScript 表達式」。只要能算出一個值就行,例如三元運算、呼叫函式、陣列方法;但 if、for、宣告變數這類陳述句不行。
有幾個細節容易被忽略:
{{ user }} 會顯示 { "age": 30 };而 null 和 undefined 會顯示成空字串,不會真的印出 "undefined"。<b>粗體</b> 會原封不動顯示成文字,這是 Vue 預設幫你擋掉 XSS。真的要塞 HTML 得改用 v-html,而且只能用在你信任的內容上。Math、Date 這類白名單可以用,但你自己掛在 window 上的東西不行。<!-- ❌ 不會動,id 就是字面上的 "{{ dynamicId }}" -->
<div id="{{ dynamicId }}"></div>
這就是 v-bind 登場的原因。
<img v-bind:src="imgUrl" />
<img :src="imgUrl" /> <!-- 縮寫 -->
<img :src /> <!-- 3.4+ 同名縮寫,等於 :src="src" -->
冒號後面那串,Vue 會當成 JavaScript 表達式來求值,而不是字串。所以 :count="1" 拿到的是數字 1,count="1" 拿到的是字串 "1"。傳 props 時,這個差別很重要。
這是最常被忽略的地方。Vue 3 的規則是:
| 綁定的值 | 一般屬性(id、aria-*) | 布林屬性(disabled、checked) |
|---|---|---|
null / undefined |
移除屬性 | 移除屬性 |
false |
保留,值為 "false" |
移除屬性 |
"" 空字串 |
保留,值為空 | 保留(也就是啟用) |
所以想「拿掉」一個屬性時,請給 null,不要給 false。像 :aria-hidden="false" 會真的輸出 aria-hidden="false",這對無障礙來說反而是正確的行為。
<script setup>
import { ref } from 'vue'
const inputAttrs = { type: 'email', placeholder: '請輸入 Email', required: true }
const attrName = ref('title')
</script>
<template>
<input v-bind="inputAttrs" />
<div :[attrName]="'我是動態屬性名'"></div>
</template>
v-bind="物件" 在包裝元件、轉傳一堆屬性時非常好用。:[attrName] 則是動態參數,連「要綁哪個屬性」都可以是變數。
一般屬性綁的是字串,但 class 和 style 太常需要「條件式組合」,所以 Vue 對這兩個屬性額外支援物件和陣列。
<div class="btn" :class="{ active: isActive, 'is-error': hasError }"></div>
這裡有兩件事要注意:靜態的 class 和 :class 可以並存,Vue 會把兩者合併;含 - 的 class 名要加引號。
<div :class="[sizeClass, isActive ? 'active' : '']"></div>
<div :class="[sizeClass, { active: isActive }]"></div> <!-- 陣列裡也能放物件 -->
<script setup>
import { computed } from 'vue'
const props = defineProps({ type: String, disabled: Boolean })
const btnClass = computed(() => ({
'btn-primary': props.type === 'primary',
'btn-danger': props.type === 'danger',
'is-disabled': props.disabled
}))
</script>
<template>
<button :class="btnClass"><slot /></button>
</template>
這樣模板保持乾淨,邏輯集中在一個地方,還能享有 computed 的快取。
<MyButton class="mt-4" />
如果 MyButton 只有一個根元素,mt-4 會自動加到那個根元素上,並和元件內部原本的 class 合併,這叫屬性透傳(fallthrough attributes)。如果元件有多個根元素,Vue 不知道要給誰,就得自己用 $attrs.class 指定。
<!-- ❌ Tailwind 掃描原始碼時看不到完整 class 名,這些樣式可能根本沒被產生 -->
<div :class="`bg-${color}-500`"></div>
Tailwind 在建置時是用文字掃描找出你用到的 class,它不會執行你的 JavaScript。所以 class 名必須以完整字串出現在原始碼裡:
// ✅ 用對照表,每個 class 都完整寫出來
const colorMap = {
red: 'bg-red-500',
blue: 'bg-blue-500'
}
<div :class="colorMap[color]"></div>
這個問題不是 Vue 造成的,但在 Vue + Tailwind 專案裡非常常見:開發時看起來正常(剛好別處有用到同一個 class),上線後樣式卻消失了。
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
<div :style="{ 'font-size': size + 'px' }"></div> <!-- kebab-case 要加引號 -->
幾個重點:
fontSize: 16 不會變成 16px,要自己加上去。:style="[baseStyle, overrideStyle]",後面的會蓋掉前面的。{ display: ['-webkit-box', 'flex'] }。<div class="card" :style="{ '--theme-color': themeColor }"></div>
<style>
.card {
border-color: var(--theme-color);
}
</style>
JS 負責提供動態的值,CSS 負責決定怎麼使用這個值,分工更清楚。
<style> 裡用 v-bind()SFC 還有一個很多人沒注意到的功能:
<script setup>
import { ref } from 'vue'
const themeColor = ref('#42b883')
</script>
<template>
<p class="title">Hello</p>
</template>
<style scoped>
.title {
color: v-bind(themeColor);
}
</style>
它背後的原理正是上一段的 CSS 變數:Vue 會把 themeColor 轉成一個帶 hash 的 CSS 變數,掛在元件根元素的 inline style 上,而且是響應式的。樣式寫在 CSS 裡,值由 JS 控制,兩邊都不用妥協。
還記得 Day 2 提到的 SFC Playground 嗎?把這段貼進去:
<div :class="{ active: isActive }">hi</div>
在右邊的 JS 分頁,會看到類似這樣的編譯結果:
_createElementVNode("div", {
class: _normalizeClass({ active: $setup.isActive })
}, "hi", 2 /* CLASS */)
這裡有兩個值得一看的地方:
normalizeClass:不管你給的是字串、物件還是陣列,最後都會被整理成一個普通的 class 字串。:style 則對應 normalizeStyle。2 /* CLASS */:這是 patchFlag。編譯器在編譯階段就知道「這個節點只有 class 是動態的」,所以資料變動時,Vue 只比對 class,其他部分都跳過。isActive 被 Proxy 追蹤 → 值改變 → 觸發元件重新渲染 → patchFlag 告訴 Vue 只需要更新 class。從資料到畫面的整條路,到這裡就接起來了。{{ }} 負責文字內容,是安全的 textContent,不能用在屬性上v-bind 負責屬性,值是 JS 表達式;想移除屬性請給 null,而不是 false
:class 和 :style 支援物件與陣列,條件一多就搬進 computed<style> 裡的 v-bind() 交給 CSS 處理