iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 10 篇

Vue 走過路過不要錯過 Day10 - v-if 與 v-show:一個拆掉,一個只是藏起來

  • 分享至 

  • xImage
  •  

前言

前幾天聊了 ref、reactive、computed、watch,都是在處理「資料怎麼變」。今天換個角度,看資料變了以後,畫面上的東西要怎麼出現、怎麼消失。

Vue 提供兩個看起來很像的指令:v-if 和 v-show。很多教學會用一句話帶過:「v-if 會移除 DOM,v-show 只是切換 display」。這句話沒錯,但真正在專案裡踩到的坑,幾乎都是這句話的延伸後果,尤其是跟 v-for 搭在一起的時候。

今天我們把它們拆開來看。


一、先看 DOM:同一個開關,兩種消失

<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 決定的是「要不要產生這個東西」,v-show 決定的是「產生之後要不要讓人看到」。後面所有的差異和陷阱,都可以從這一點推導出來。

三、生命週期與狀態:拆掉就是真的沒了

既然 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


五、單一元素時就會遇到的陷阱

1. v-show 裡面的內容還是會被執行

<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 不行。

2. 空陣列是 truthy

<ul v-if="list">...</ul>        <!-- list 是 [] 也會渲染 -->
<ul v-if="list.length">...</ul> <!-- ✅ -->

這其實是 JS 的 truthy/falsy 問題:[] 和 {} 都是 truthy,只有 0、''、null、undefined、NaN、false 是 falsy。

3. v-show 會跟 CSS 打架

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 的人特別容易遇到。

4. v-show 不能用在 <template> 上,也沒有 v-else

<template> 不會產生真實的 DOM,v-show 自然沒有 style 可以改。v-else 則是 v-if 那套三元運算的一部分,v-show 沒有這個結構。

5. v-show 在多根節點元件上會失效

Vue 3 允許元件有多個根節點(fragment),但 v-show 需要一個明確的元素來設定 style。對多根元件使用 v-show 時,Vue 會在 console 警告指令無法正常作用。

6. v-if 與 template ref 的時間差

<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 從頭到尾都在。


六、遇上 v-for:誤區最多的地方

前面的陷阱都還算直觀,真正讓人卡很久的是列表。我們用一個待辦清單當例子:

const todos = ref([
  { id: 1, title: '寫鐵人賽', done: false },
  { id: 2, title: '買牛奶', done: true },
  { id: 3, title: '倒垃圾', done: false },
])

需求是:只顯示還沒完成的項目。

誤區 1:v-if 和 v-for 寫在同一個元素上

直覺寫法:

<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 規則也會擋這種寫法。

誤區 2:用 <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 只負責呈現。

誤區 3:那用 v-show 就沒有優先級問題了吧?

<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 就好了。

誤區 4:用 index 當 key,再搭配過濾

假設每個項目是一個元件,而元件裡有自己的狀態:

<!-- 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。

誤區 5:v-show 放在 <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-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
  • 要過濾列表嗎?→ computed,不要在 template 裡用 v-if 或 v-show 過濾
  • 頻繁切換,而且內容一開始就能安全渲染嗎?→ v-show

結語

回到標題:v-if 是拆掉,v-show 只是藏起來。

今天列的每一個陷阱,本質上都是在問同一件事:這個元素(和它裡面的元件)現在到底存不存在?

  • 存在,所以 user.name 會被執行、:nth-child 會算到它、length 也不會變。
  • 不存在,所以 ref 是 null、狀態會重置、index key 會對到別人。
    把這個問題想清楚,就不用死記哪個情境要用哪個了。下次遇到「畫面沒出現」或「東西藏不起來」的 bug,先打開 DevTools 看看:它是被拆掉了,還是只是被藏起來?答案通常就在那裡。

如果只能帶走一句話,我會選這句:

條件渲染交給 v-if 或 v-show,列表過濾交給 computed。



上一篇
Vue 走過路過不要錯過 Day09 - 從 {{ }} 到 v-bind:動態綁定 class 與 style
下一篇
Vue 走過路過不要錯過 Day11 - v-for 與 key:為什麼不要用 index 當 key
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言