前幾天聊了 ref、reactive、computed、watch,都是在處理「資料怎麼變」。今天換個角度,看資料變了以後,畫面上的東西要怎麼出現、怎麼消失。
Vue 提供兩個看起來很像的指令:v-if 和 v-show。很多教學會用一句話帶過:「v-if 會移除 DOM,v-show 只是切換 display」。這句話沒錯,但真正在專案裡踩到的坑,幾乎都是這句話的延伸後果,尤其是跟 v-for 搭在一起的時候。
今天我們把它們拆開來看。
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
</script>
<template>
<button @click="isOpen = !isOpen">切換</button>
<div v-if="isOpen">我是 v-if</div>
<div v-show="isOpen">我是 v-show</div>
</template>
在 isOpen 為 false 的時候,打開 DevTools 看 Elements 面板,會看到:
<button>切換</button>
<!--v-if-->
<div style="display: none;">我是 v-show</div>
v-if 的 div 整個不存在,只留一個註解當佔位符,讓 Vue 知道之後要把東西插回哪裡。v-show 的 div 還在,只是多了一個 inline style。把上面的程式碼貼到 SFC Playground,切到 JS 分頁,會看到類似這樣的輸出(簡化後):
// v-if
isOpen.value
? (openBlock(), createElementBlock("div", { key: 0 }, "我是 v-if"))
: createCommentVNode("v-if", true)
// v-show
withDirectives(
createElementVNode("div", null, "我是 v-show", 512),
[[vShow, isOpen.value]]
)
v-if 被編譯成一個三元運算子。條件不成立時,根本不會建立那個 div 的 VNode,只建立一個註解節點。它是 render function 層級的 if/else。v-show 則是先照常建立 div,再透過 withDirectives 掛上 vShow 這個指令,由指令去改 el.style.display。既然 v-if 是真的不產生,那放在裡面的元件也會真的被建立和銷毀。我們寫一個有自己狀態的子元件來驗證:
<!-- Counter.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const count = ref(0)
onMounted(() => console.log('mounted'))
onUnmounted(() => console.log('unmounted'))
</script>
<template>
<button @click="count++">點了 {{ count }} 次</button>
</template>
<Counter v-if="isOpen" />
<Counter v-show="isOpen" />
實際操作一次會發現:
| v-if | v-show | |
|---|---|---|
| 每次打開 | 觸發 mounted |
只有第一次觸發 |
| 每次關閉 | 觸發 unmounted |
不觸發 |
關掉再打開後的 count |
歸零 | 保留原本的數字 |
這也表示在元件裡用 setInterval、事件監聽這類東西時,v-if 會在 onUnmounted 給你清理的機會;v-show 藏起來的元件,計時器會一直在背景跑。
那如果想「拆掉又保留狀態」呢?Vue 有 <KeepAlive> 可以處理,這個之後再開一天來聊。
v-if 是惰性的:初始條件為 false 時什麼都不做,初始成本低;但每次切換都要重新建立或銷毀整個區塊。
v-show 一定會先渲染:就算初始是 false,DOM 和元件也都會建好,初始成本高;但之後的切換只是改一個 style,非常便宜。
所以常見的選擇原則是:
頻繁切換(tab、下拉選單、手風琴)→ v-show
很少變動,或大部分時候是 false(權限區塊、錯誤訊息、登入後才有的內容)→ v-if
<script setup>
import { ref } from 'vue'
const user = ref(null) // 等 API 回來才有資料
</script>
<template>
<div v-show="user">{{ user.name }}</div> <!-- 💥 Cannot read properties of null -->
<div v-if="user">{{ user.name }}</div> <!-- ✅ -->
</template>
v-show 會照常渲染裡面的內容,所以 user.name 一定會被執行。v-if 可以當守門員,v-show 不行。
<ul v-if="list">...</ul> <!-- list 是 [] 也會渲染 -->
<ul v-if="list.length">...</ul> <!-- ✅ -->
這其實是 JS 的 truthy/falsy 問題:[] 和 {} 都是 truthy,只有 0、''、null、undefined、NaN、false 是 falsy。
v-show 做的事只有兩件:條件為 false 時加上 inline 的 display: none;條件為 true 時把 display 還原成原本的值。所以:
<!-- Tailwind 的 hidden 就是 display: none,v-show 為 true 也不會出現 -->
<div class="hidden" v-show="isOpen">永遠看不到</div>
<!-- !important 的權重比 inline style 高,v-show 為 false 也藏不住 -->
<div class="!flex" v-show="isOpen">永遠藏不住</div>
用 Tailwind 或自己寫 utility class 的人特別容易遇到。
<template> 上,也沒有 v-else<template> 不會產生真實的 DOM,v-show 自然沒有 style 可以改。v-else 則是 v-if 那套三元運算的一部分,v-show 沒有這個結構。
Vue 3 允許元件有多個根節點(fragment),但 v-show 需要一個明確的元素來設定 style。對多根元件使用 v-show 時,Vue 會在 console 警告指令無法正常作用。
<script setup>
import { ref, nextTick } from 'vue'
const isOpen = ref(false)
const inputRef = ref(null)
async function open() {
isOpen.value = true
console.log(inputRef.value) // null,DOM 還沒建好
await nextTick()
inputRef.value.focus() // ✅
}
</script>
<template>
<input v-if="isOpen" ref="inputRef" />
</template>
v-if 為 false 時元素不存在,ref 就是 null。切成 true 後,要等 Vue 完成下一次更新才拿得到。如果換成 v-show,ref 從頭到尾都在。
前面的陷阱都還算直觀,真正讓人卡很久的是列表。我們用一個待辦清單當例子:
const todos = ref([
{ id: 1, title: '寫鐵人賽', done: false },
{ id: 2, title: '買牛奶', done: true },
{ id: 3, title: '倒垃圾', done: false },
])
需求是:只顯示還沒完成的項目。
直覺寫法:
<li v-for="todo in todos" v-if="!todo.done">{{ todo.title }}</li>
在 Vue 3 中,v-if 的優先級比 v-for 高。也就是 Vue 會先判斷 v-if,這時候 todo 還沒被 v-for 定義出來,於是得到錯誤:todo is undefined。
官方文件也明確建議不要把兩者放在同一個元素上,ESLint 的 vue/no-use-v-if-with-v-for 規則也會擋這種寫法。
<template> 包起來就沒問題了嗎?常見的修法是把 v-for 移到外層:
<template v-for="todo in todos" :key="todo.id">
<li v-if="!todo.done">{{ todo.title }}</li>
</template>
這樣可以動,但有兩個隱藏成本。
第一,每次重新渲染都會把整個陣列跑一遍。 就算只是元件裡某個不相關的狀態改變(例如頁面上另一個計數器),觸發重新渲染後,所有項目的 !todo.done 都會重新判斷一次。
第二,index 不再連續。
<template v-for="(todo, index) in todos" :key="todo.id">
<li v-if="!todo.done">{{ index + 1 }}. {{ todo.title }}</li>
</template>
畫面會顯示:
1. 寫鐵人賽
3. 倒垃圾
因為 index 是原陣列的位置,被 v-if 過濾掉的第 2 項還是佔著編號。
比較好的做法是用 computed 先過濾:
<script setup>
import { computed } from 'vue'
const activeTodos = computed(() => todos.value.filter(t => !t.done))
</script>
<template>
<li v-for="(todo, index) in activeTodos" :key="todo.id">
{{ index + 1 }}. {{ todo.title }}
</li>
</template>
computed 有快取,只有 todos 真的改變時才重新過濾;index 也是過濾後的位置,編號自然連續。這也呼應前面講 computed 時提到的:衍生資料交給 computed,template 只負責呈現。
<li v-for="todo in todos" :key="todo.id" v-show="!todo.done">{{ todo.title }}</li>
沒錯,v-show 只是一個指令,不會跟 v-for 搶優先級,這樣寫可以正常運作。但它帶來另外幾個問題。
(a) 全部的項目都在 DOM 裡。 列表有 1000 筆、只顯示 10 筆時,另外 990 個 <li> 還是被建立、還是佔記憶體。如果每個項目是一個元件,990 個元件實例也都活著。
(b) CSS 的 :nth-child 和 :last-child 會算錯。
li:nth-child(odd) { background: #f5f5f5; } /* 斑馬紋 */
li:last-child { border-bottom: none; } /* 最後一項不要底線 */
被 v-show 藏起來的 <li> 仍然是 DOM 裡的元素,CSS 選擇器照樣把它算進去。結果就是斑馬紋錯位,或最後一個看得到的項目多了一條底線。
v-if 或 computed 過濾就不會有這個問題:v-if 留下的佔位是註解節點,不是元素,:nth-child 不會算它。
(c) 「沒有資料」的提示永遠不會出現。
<li v-for="todo in todos" :key="todo.id" v-show="todo.title.includes(keyword)">
{{ todo.title }}
</li>
<p v-if="todos.length === 0">找不到符合的項目</p>
使用者搜尋一個不存在的關鍵字時,所有 <li> 都被藏起來了,但 todos.length 還是 3,所以提示文字不會出現。畫面上就是一片空白。
改成 computed 過濾後,判斷 filteredTodos.length === 0 就好了。
假設每個項目是一個元件,而元件裡有自己的狀態:
<!-- TodoItem.vue -->
<script setup>
import { ref } from 'vue'
const props = defineProps(['todo'])
const note = ref('') // 每個項目自己的備註,沒有存回父層
</script>
<template>
<li>{{ todo.title }} <input v-model="note" placeholder="備註" /></li>
</template>
<TodoItem v-for="(todo, index) in activeTodos" :key="index" :todo="todo" />
在「寫鐵人賽」的備註欄打了字,然後把它標記為完成。它從 activeTodos 被過濾掉以後,備註跑到了「倒垃圾」身上。
原因是 key 用的是 index。過濾前「寫鐵人賽」是 index 0,過濾後「倒垃圾」變成 index 0。Vue 看到 key 一樣,就認為是同一個元件,只更新了 props,元件內部的 note 則原封不動地留著。
有趣的是,如果這裡用的是 v-show,因為元素沒有被移除,位置不會變動,反而不會出現這個錯位問題。但這不是選 v-show 的理由,正確的解法是用穩定且唯一的值當 key,例如 todo.id。
<template v-for> 上<template v-for="todo in todos" :key="todo.id" v-show="!todo.done">
<li>{{ todo.title }}</li>
</template>
跟第五節說的一樣,<template> 不會產生真實元素,v-show 沒地方可以設定 style。這段程式碼不會報錯,但也不會有任何效果,是很難察覺的 bug。
| 寫法 | 能不能動 | 問題 |
|---|---|---|
v-for + v-if 同一元素 |
❌ | v-if 先執行,拿不到 item |
<template v-for> + 內層 v-if |
✅ | 每次渲染都重跑整個陣列、index 不連續 |
v-for + v-show 同一元素 |
✅ | DOM 全部保留、:nth-child 算錯、空狀態判斷失效 |
<template v-for> + v-show |
⚠️ | 沒有效果 |
computed 過濾 + v-for |
✅ | 大多數情況的首選 |
只有一種情況我會考慮在列表上用 v-show:項目數量少、切換非常頻繁,而且確定沒有用到 :nth-child 或空狀態判斷,例如固定幾個項目的篩選 tab。
v-if
v-if
<template> 或多根節點元件上嗎?→ 只能用 v-if
v-show
回到標題:v-if 是拆掉,v-show 只是藏起來。
今天列的每一個陷阱,本質上都是在問同一件事:這個元素(和它裡面的元件)現在到底存不存在?
user.name 會被執行、:nth-child 會算到它、length 也不會變。如果只能帶走一句話,我會選這句:
條件渲染交給 v-if 或 v-show,列表過濾交給 computed。