iT邦幫忙

2021 iThome 鐵人賽

DAY 22
1
Modern Web

不只懂 Vue 語法:Vue.js 觀念篇系列 第 22

不只懂 Vue 語法:為何 v-for 的 key 必須是唯一值?v-for 與 v-if 能否同時使用?

  • 分享至 

  • xImage
  •  

問題回答

v-for 的 key 必須是唯一值,才可以讓 Vue 在更新 v-for 所產生的列表時,能準確更新節點。相反,如果使用 index 作為 key,或者不綁定 key,Vue 就會以該節點的位置作為 key,有機會因為錯誤套用之前渲染過的節點而造成錯誤。

另外,Vue 官方不建議同時使用 v-forv-if,因為兩者在執行上的優次不同,而且有機會浪費渲染效能。

以下會再作詳情解說。

綁 index 就如沒有綁 key 一樣

關於 Vue 更新 v-for 的所產生的畫面,有幾個重點:

  • Vue 是採用「就地更新」來更新以 v-for 渲染的元素,並非移動 DOM 來完成。
  • Vue 會重用已經渲染的 DOM 節點,並利用該節點 key,對比舊節點與新節點的內容,來判斷是否要更新該節點。

因為「重用節點」、「只更新需要更新的節點」這兩個優勢,所以很多人才說:綁 key 可以提升 v-for 的渲染效能。Vue 官方文件提到,如果沒綁 key,就會用最小移動並且盡量原地修改的手法來更新資料。

但接下來的例子,會發現其實不論你是沒有綁 key,還是只綁 index 當作 key。一樣會出現同樣問題。這次我以綁定 index 作為示範。例子是參考了這裏的討論再作調整。

先分享完整程式碼:
https://codesandbox.io/s/todo-list-bang-index-zuo-wei-key-sfs9u?file=/src/App.vue

情況是:

  1. 我勾選了 "Buy dinner",該項目有移到已完成的區域
  2. 但未完成的 "Watch Netflix" 都被勾選了

對我們來說,待辦列表由這樣:

  • "Write blog"
  • "Buy dinner"
  • "Watch Netflix"

變成了:

  • "Write blog"
  • "Watch Netflix"

但 Vue 是使用遍歷來檢查每個節點。因此會做以下的事:

  • 第一個節點,"Writing blog" 沒變,保留就好。
  • 第二個節點,"Buy dinner" 變成了 "Watch Netflix",因此把文字改成了 "Watch Netflix"。但第二個節點是存在的,只不過改了文字。因此原地重用第二個節點的畫面,也就是顯示勾選的 checkbox。
  • 第三個節點沒了,直接銷毀。

最可怕的是,雖然第二個節點的資料(Watch Netflix)被勾選,但它的 completed 其實是 false:

再沿用以上情況,最後我取消勾選在完成區域裏的 "Buy dinner",會變成以下結果:

"Buy dinner" 仍然是勾選狀態。原理同上,因為對 Vue 來說,第二個節點就是有勾選狀態,你不過是換掉了文字內容,但第二個節點是存在的,並沒有被移除,所以會重用第二個節點的畫面。

解決方法:綁定唯一值的 key

要避免以上情況,就不能利用 index 來記錄一個節點的畫面狀態,而是使用唯一值。當我們使用唯一值,Vue 官方文件說明會做以下的事:

key 特殊 attribute 主要用做 Vue 的虚拟 DOM 算法的提示,以在比对新旧节点组时辨识 VNodes。...使用 key 时,它会基于 key 的顺序变化重新排列元素,并且那些使用了已经不存在的 key 的元素将会被移除/销毁。

關於虛擬 DOM 的意思,可參考此系列的文章:
什麼是 Virtual DOM?Vue 如何利用 Virtual DOM?

換言之,如果現在我以 id 當作唯一值,並成為每個節點的 key。從Vue 的文檔中,我們知道當 Vue 遍歷節點時,會做兩件事:

  • 把所有 id 的順序記錄起來作比對。
  • 用每個節點的 id 來比對舊節點與新節點。如果找不到該 id,就代表此元素已不存在,因此 Vue 會銷毀此節點。

回到例子,這次問題就被解決了。

修改後的程式碼:
https://codesandbox.io/s/todo-list-bang-id-zuo-wei-key-pfy1n?file=/src/App.vue

主要修改部分:

<li v-for="todo in incompletedList" :key="todo.id">
    <input type="checkbox" v-model="todo.completed" :id="todo.id" />
    <label :for="todo.id">{{ todo.title }}</label>
</li>

當我勾選了 "Buy dinner":

Vue 就會做以下的事:

  • 按 id 比對,例如比對 id: 1 的新舊節點,判斷是否有變化,有的話就重新渲染作更新。即使 "Buy dinner" 被勾選了,勾選的畫面只會套用在 id: 2 的節點上。因此 "Watch Netflix" 不會呈現勾選狀態,因為它的 id 是 3。
  • 把現在的 id 次序與之前的作比較,以待辦區域為例,舊的 id 次序是 1,2,3,現在是 1,3。Vue 就把在待辦裏,id: 2 的節點銷毀,並建立 id: 3 的節點。

v-if 和 v-for 為什麼不能同時使用?

Vue 官方文件有提到,不建議同時使用v-ifv-for

注意:

  • Vue 2:v-for 優先於 v-if
  • Vue 3:v-if 優先於 v-for

因為其中一個語法會優先被執行,加上效能的問題。所以 Vue 官方不建議同時使用。

以 Vue 2 為例,意思是先跑 v-for 呈現每筆資料,之後每筆資料都會套用 v-if。如果有 1000 筆資料,只有 1 筆是因為 v-if 而不會被渲染,那麼就浪費了 999 個 v-if 的計算。

像以下官方例子:

<li v-for="todo in todos" v-if="!todo.isComplete">
  {{ todo }}
</li>

每個 todo 都會被綁上 v-if,並計算是否要作顯示。

建議做法

Vue 官方 style guide 建議兩種常用做法:

  1. 先使用 computed 把資料處理好,再跑 v-for 渲染已處理好的資料。
  2. v-if 移到外層,內層用 v-for

第一種做法,之前的例子就有用到:

<ul>
  <li v-for="todo in completedList" :key="todo.id">
    <input type="checkbox" v-model="todo.completed" />
    {{ todo.title }}
  </li>
</ul>
  data() {
    return {
      todos: [
        {
          id: "1",
          title: "Write blog",
          completed: false,
        },
        ...
      ],
    };
  },
  computed: {
    completedList() {
      return this.todos.filter((item) => item.completed);
    },
  },

第二種做法在以上的例子就不能用,因為每個事項都有自己的完成狀態,沒法統一。

總結

  • 對於 v-for 渲染的畫面,Vue 使用「原地更新」的方法來作更新,盡量重用已經渲染過的節點。
  • v-for 需要綁定 key, 因為 Vue 會判斷該節點內容是否有變,以及會紀錄所有 key 的順序並作出更新。
  • key 必須要是唯一值,如果使用重複的值,可能會導致 Vue 錯誤套用節點的畫面。
  • Vue 官方不建議同時使用 v-ifv-for,因為兩者執行時有優次之分,而且有機會浪費渲染效能。

參考資料

vue中v-if和v-for不建议同时使用的坑
Vue2.0 v-for 中 :key 到底有什么用?
重新認識 Vue - 1-6 條件判斷與列表渲染


上一篇
不只懂 Vue 語法:試說明 computed 的 get 與 set 運作機制?
下一篇
不只懂 Vue 語法:試解釋遞迴元件的用法?
系列文
不只懂 Vue 語法:Vue.js 觀念篇31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言