iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

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

Vue 走過路過不要錯過 Day11 - v-for 與 key:為什麼不要用 index 當 key

  • 分享至 

  • xImage
  •  

寫 Vue 的人幾乎都聽過這句話:「v-for 要加 key,而且不要用 index。」

但如果再追問一句「為什麼?」,很多人(包括以前的我)只能回答「因為會出 bug」「因為效能比較差」。會出什麼 bug?差在哪裡?今天就來把這件事拆開來看。

一、先看一個怪 bug

先把下面這段丟到 SFC Playground 跑跑看:

<script setup>
import { ref } from 'vue'

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

function removeFirst() {
  todos.value.shift()
}
</script>

<template>
  <button @click="removeFirst">刪除第一項</button>
  <ul>
    <li v-for="(todo, index) in todos" :key="index">
      {{ todo.text }}
      <input placeholder="備註" />
    </li>
  </ul>
</template>

操作步驟:

  1. 在「買牛奶」旁邊的輸入框打上「要全脂的」
  2. 按下「刪除第一項」

結果你會看到:「買牛奶」確實不見了,但「要全脂的」這幾個字跑到「寫鐵人賽」旁邊了。

資料有錯嗎?打開 Vue Devtools 看,todos 裡面乾乾淨淨,就是 寫鐵人賽、倒垃圾 兩筆。資料是對的,錯的是畫面上的 DOM 被重複利用的方式。

現在把 :key="index" 改成 :key="todo.id",重新做一次,備註就會跟著「買牛奶」一起消失。這才是我們要的行為。


二、結論先講

key 是 Vue 在比對新舊畫面時,用來判斷「這是不是同一個節點」的身分證。

用 todo.id 當 key,身分證跟著「資料本身」走;用 index 當 key,身分證跟著「座位號碼」走。

人換了座位,Vue 看的卻是座位號碼,當然會認錯人。

接下來分三段說明:Vue 更新列表時到底在做什麼、index 當 key 具體會壞在哪、以及好的 key 該從哪裡來。


三、v-for 快速回顧

正式進入主題前,快速帶過幾個常用寫法:

<!-- 遍歷陣列:第二個參數是 index -->
<li v-for="(item, index) in items" :key="item.id">{{ item.name }}</li>

<!-- 遍歷物件:value、key、index 的順序 -->
<li v-for="(value, key, index) in profile" :key="key">{{ key }}: {{ value }}</li>

<!-- 數字範圍:從 1 開始,不是 0 -->
<span v-for="n in 5" :key="n">⭐</span>

另外一個 Vue 3 的小提醒:v-if 的優先級比 v-for 高。所以寫在同一個元素上時,v-if 會先執行,這時候還拿不到 v-for 的變數:

<!-- ❌ 會報錯:todo 還沒被定義 -->
<li v-for="todo in todos" v-if="!todo.done" :key="todo.id">...</li>

<!-- ✅ 用 computed 先過濾 -->
<li v-for="todo in undoneTodos" :key="todo.id">...</li>

四、Vue 更新列表時在做什麼

回想 Day 3 講的響應式:資料一變,元件就會重新執行 render,產生一份新的虛擬 DOM(vnode)。接著 Vue 要把「新的 vnode 列表」和「舊的 vnode 列表」做比對(diff),然後只對真實 DOM 做最少的修改。

問題來了:Vue 要怎麼知道新列表的第 N 項,對應到舊列表的哪一項?

沒有 key:就地更新

如果沒給 key,Vue 會採用「就地更新(in-place patch)」策略:新舊列表照順序一個一個對,第 1 個對第 1 個、第 2 個對第 2 個……能重用的 DOM 就重用,只改裡面的內容。多出來的就新建,少掉的就從尾巴刪。

在原始碼裡,這段邏輯叫做 patchUnkeyedChildren。

官方文件對這個策略的描述很精準:它很有效率,但只適用於列表輸出不依賴子元件狀態或暫時 DOM 狀態(例如表單輸入值)的情況。

有 key:用身分證找人

有 key 的話,Vue 會走 patchKeyedChildren,比對的依據就不是位置,而是 key:

  1. 先從頭開始比,key 相同就直接 patch,遇到不同就停
  2. 再從尾巴開始比,一樣 key 相同就 patch
  3. 中間剩下的部分,如果只是新增或刪除,直接處理
  4. 如果中間順序亂掉了,會建一張 key 對照表,再用「最長遞增子序列」找出哪些節點不用動、哪些需要搬移

演算法細節這篇先不深入,重點只有一個:有 key 的時候,Vue 會搬移 DOM 而不是改寫 DOM。


五、index 當 key 會出什麼事

1. 用開場的例子實際走一遍

刪除前:

key 資料 DOM
0 買牛奶 <li>①,input 裡有「要全脂的」
1 寫鐵人賽 <li>②
2 倒垃圾 <li>③

刪除後,新的 vnode 是:

key 資料
0 寫鐵人賽
1 倒垃圾

Vue 的判斷過程:

  • key 0 新舊都有 → 同一個節點,重用 <li>①,把文字從「買牛奶」改成「寫鐵人賽」
  • key 1 新舊都有 → 重用 <li>②,文字改成「倒垃圾」
  • key 2 不見了 → 刪掉 <li>③

發現了嗎?我們明明刪的是第一項,Vue 實際上刪掉的卻是最後一個 <li>。 而 <li>① 裡面那個 input,因為它的值不是由 Vue 的資料控制的,Vue 只改了文字、沒碰它,所以「要全脂的」就留在原地,變成「寫鐵人賽」的備註了。

換成 :key="todo.id":

  • 舊的 key 是 1, 2, 3,新的是 2, 3
  • 從尾巴比:3 對 3、2 對 2,都是同一個節點
  • 剩下 key 1 → 刪掉 <li>①

只動了一個 DOM,而且刪的是對的那一個。

換句話說,用 index 當 key,效果跟沒寫 key 幾乎一樣,都是照位置在對。寫了等於沒寫。

2. 子元件的內部狀態會錯位

不只 input,只要子元件有自己的狀態,都會遇到同樣的事:

<!-- Counter.vue -->
<script setup>
import { ref } from 'vue'
defineProps(['label'])
const count = ref(0)
</script>

<template>
  <div>{{ label }}:{{ count }} <button @click="count++">+1</button></div>
</template>
<Counter v-for="(item, index) in list" :key="index" :label="item.name" />

在第一個 Counter 按幾下 +1,再刪掉第一筆資料,你會發現剛剛累積的數字留在「新的第一個」身上。原因一樣:key 0 還在,Vue 就認定元件實例不用換,只更新 label 這個 prop,count 這個內部狀態原封不動。

除了 ref 狀態,focus 游標位置、捲軸位置、<video> 播放進度,也都是同樣的道理。

3. 動畫會亂掉

使用 <TransitionGroup> 時 key 是必填,Vue 也是靠 key 判斷哪個元素要播離場動畫。用 index 的話,刪第一項時播離場動畫的會是最後一項,其他項目則只是文字被換掉,看起來就是畫面在閃。

4. 效能反而比較差

很多人以為 index 當 key「至少有寫」,效能應該還可以。但從上面的例子就能看出來:

  • 用 id:刪一個 DOM
  • 用 index:刪一個 DOM,外加更新後面每一個節點的內容

列表越長、每一項越複雜,差距就越明顯。


六、那什麼時候 index 可以用?

不是說 index 一定不能用,而是要知道代價。符合以下條件時,用 index 沒什麼問題:

  • 列表是純展示,不會新增、刪除、排序
  • 每一項沒有自己的狀態(沒有 input、沒有帶狀態的子元件)

例如評分星星:

<span v-for="n in 5" :key="n">⭐</span>

這種列表位置就是它的身分,不會有換座位的問題。


七、好的 key 從哪裡來

✅ 後端給的 id

首選。資料庫的主鍵本來就是唯一且穩定的。

✅ 資料建立時就產生

前端自己新增的資料,可以在建立資料的當下就給它一個 id:

function addTodo(text) {
  todos.value.push({
    id: crypto.randomUUID(),
    text,
  })
}

注意 crypto.randomUUID() 需要在安全環境(HTTPS 或 localhost)才能使用。如果環境不支援,用遞增的計數器也可以。

❌ 在 template 裡產生

<!-- 千萬不要 -->
<li v-for="todo in todos" :key="Math.random()">...</li>

每次重新 render,key 都是新的,Vue 會認為每一項都是全新的節點,把整個列表砍掉重建。比 index 更糟。

其他規則

  • key 要用字串或數字這類原始值,不要用物件
  • 同一層的 key 必須唯一
  • Vue 3 中 <template v-for> 的 key 要寫在 <template> 本身,不是寫在裡面的子元素上

八、延伸:key 不只能用在 v-for

理解「key 是身分證」之後,就會發現它還有一個很實用的用法:換掉 key,強制元件重建。

<UserForm :key="userId" :user-id="userId" />

當 userId 從 A 換成 B,Vue 看到 key 不同,就會把舊的 UserForm 整個卸載、重新掛載一個新的。表單裡的輸入內容、內部狀態、onMounted 的邏輯全部重來一次。

這比在元件裡寫一堆 watch 去手動重置狀態乾淨得多。


九、小結

今天的重點其實只有一句話:

key 要跟著資料走,不要跟著位置走。

  • key 是 Vue diff 時辨認節點的身分證
  • 沒有 key 或用 index 當 key,Vue 都是照位置比對、就地更新
  • 就地更新會讓 input 值、子元件狀態、動畫留在原來的位置,造成錯位
  • 用 index 效能也不會比較好,刪前面的項目時反而要更新更多節點
  • 靜態、不重排、無狀態的列表可以用 index
  • 好的 key 來自後端 id,或是在建立資料時就產生
  • 換 key 可以強制元件重建

明天見!


上一篇
Vue 走過路過不要錯過 Day10 - v-if 與 v-show:一個拆掉,一個只是藏起來
下一篇
Vue 走過路過不要錯過 Day12 -v-on 與事件修飾符:.prevent、.stop 幫你省下的那幾行
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言