iT邦幫忙

2024 iThome 鐵人賽

DAY 21
2
Modern Web

欸你是要進 Vue 了沒?系列 第 21

欸你是要進 Vue 了沒? - Day21:Vue 列表渲染之 v-for 和它の關鍵小夥伴 key && 虛擬 DOM

  • 分享至 

  • xImage
  •  

哈囉搭家,在昨天我們講到了列表渲染的用法 v-for,今天會來認識 key 這個屬性、Vue 的渲染模式「虛擬 DOM」以及 v-for 搭配 method 的一些特性和技巧。

先給個!今天會提到的內容:

  • 虛擬 DOM
  • v-for 的就地更新
  • key
  • 陣列變化監聽
  • 展示過濾或排序後的結果

我們開始~~~
/images/emoticon/emoticon32.gif

前情小提要

記得我們說 v-for 在列表渲染的角色嗎?

v-for 是那位很會幫主管列資料的同事,但總是會出包,例如列了一份資料、主管又給它一份新的,上個廁所回來,就忘記哪份資料是需要更新的。
因此主管派了一位 key 小夥伴跟在它身邊提升它的工作效率!
key 會幫它把每份資料貼上標籤,讓 v-for 需要比較資料時,可以準確識別,不會搞烏龍。

看看官方文件怎麼說它:

key 这个特殊的 attribute 主要作为 Vue 的虚拟 DOM 算法提示,在比较新旧节点列表时用于识别 vnode。

喔所以!key 是一種特殊的屬性。
不過「虛擬 DOM」、「比較新舊節點列表」是什麼行為?

虛擬 DOM

官方文件:虛擬 DOM (Virtual DOM,簡稱 VDOM) 是一種編程概念,意為將目標所需的 UI 通過數據結構“虛擬”地表示出來,保存在內存中,然後將真實的 DOM 與之保持同步。這個概念是由 React 率先開拓,隨後被許多不同的框架採用,當然也包括 Vue。

是一種優化 DOM 操作的模式,也是 Vue 的渲染系統的基礎。

運作模式會像這樣:當數據資料更新時,Vue 會生成一個新的虛擬 DOM 樹,並將其和舊的虛擬 DOM 的節點進行比較,決定哪些部分需要更新、新增或刪除,這個比較過程叫做 patch(或 diffing / reconciliation)。

補充:常見畫面渲染策略

在我們寫原生 JS ,當我們有需要重新渲染畫面的情境時,

例如:我們有一個計數器,想要在點選了按鈕時數字加一,並想在多個畫面上更新最新數字。

常見的兩種做法為:

  1. 手動更新:選取特定的 DOM 元素,手動修改內容、屬性,進而更新畫面。
  2. 重繪整個區塊(全部重新渲染):當觸發了某種行為(例如:按鈕被點擊)後,就以新的資料去重新生成整個區塊的 HTML。

雖然蠻適合在小的應用做的,但總會有 某些資料沒有改變、還是會被重繪 的情況,所以這些操作通常成本比較高。

虛擬 DOM 優勢

而在 Vue 中,虛擬 DOM 的優勢就是:只會將那些「實際需要更新」的部分應用到真正的 DOM 上,省去不必要的全頁面重繪。


我們理解了這個模式可以幫助系統提升效能,那 key 存在的必要呢?
v-for 提示,是為了提示什麼?

我們必須先知道一下 v-for 它的渲染更新策略。

v-for 的就地更新

看看官方文件怎麼說:

Vue 默認按照“就地更新”的策略來更新通過 v-for 渲染的元素列表。當數據項的順序改變時,Vue 不會隨之移動 DOM 元素的順序,而是就地更新每個元素,確保它們在原本指定的索引位置上渲染。

默認模式是高效的,但只適用於列表渲染輸出的結果不依賴子組件狀態或者臨時 DOM 狀態 (例如表單輸入值) 的情況。

也就是:當數據改變時,會在「不更動 DOM 元素的順序」的前提下,把資料更新。

情境理解

假設你的 todolist 系統上有這些項目:

待辦事項:
□ 吃飯
□ 寫鐵人賽
□ 睡覺

已完成:

當你把「寫鐵人賽」標記為完成時,會希望「睡覺」挪到第二格,「寫鐵人賽」移動到下方。

待辦事項:
□ 吃飯
// 這邊是寫鐵人賽原本的位置,Vue 會重用結構,把新的元素就地更新在這
□ 睡覺

已完成:
v 寫鐵人賽 // 這邊則是新增一個節點

但對 Vue 來說,它並「不會重新排列」項目。
它並不是執行我們預期的行為:把「寫鐵人賽」移到底部、重新排列列表的項目,而是把「睡覺」覆蓋在「寫鐵人賽」「舊有」的位置上,並再新增一個新的節點渲染「寫鐵人賽」。

由於我們沒有將項目元素標列上「記號」,Vue 並不清楚我們要刪除和更新的確切是哪些元素,所以在當我們希望列表的項目要更改順序時,可能會導致非預期的行為。Vue 可能會認為新舊項目是相同的,所以重用現有的 DOM。

而 key 能夠幫忙 DOM 提供「識別」這些元素。

到這邊可能有點抽象⋯⋯!
我們實際在 input 上使用 v-for 看看。

範例

補充:表單輸入值對 Vue 來說只是一個臨時 DOM 的狀態,尚未被寫入數據以及 DOM 結構中。

<script setup>
import { ref } from "vue";
const questions = ref(["早餐吃什麼:", "午餐吃什麼:", "晚餐吃什麼:"]);
const deleted = function (array, index) {
  array.splice(index, 1);
};
</script>
<template>
  <div for="input" v-for="(item, index) in questions">
    <label>{{ item }}<input type="text" id="input" /></label>
    <button @click="deleted(questions, index)">刪除</button>
  </div>
</template>

我們做了些什麼:

  1. 定義了 questions 為響應式陣列。
  2. 定義 deleted 函式:其中會刪除掉傳入的 array 的當下元素。
  3. 模板中於 <div> 使用 v-for 指令:將會遍歷 questions 的元素和索引值,依序渲染出 <label>{{ item }}<input type="text" id="input" /></label> <button @click="deleted(questions, index)">刪除</button> 這個區塊。

先看一下瀏覽器的呈現:
https://ithelp.ithome.com.tw/upload/images/20241004/20169139aLAf7IgEb1.png

現在我們繞回剛剛提過的概念:

  1. v-for 就地更新:當數據改變時,會在「不更動 DOM 元素的順序」的前提下,把資料更新。
  2. 表單輸入值為臨時 DOM 狀態:input 上的資料為臨時 DOM,尚未被寫入數據以及 DOM 結構中。

我們現在設立一個情境:

  1. 在午餐吃什麼的 input 填寫:波士頓龍蝦。
  2. 點選午餐吃什麼的刪除鈕。

期望的是:2. 後,午餐吃什麼的整個區塊會被刪掉。

https://ithelp.ithome.com.tw/upload/images/20241004/20169139SLd4q2Vs5l.png

我們實際上來試試!

https://ithelp.ithome.com.tw/upload/images/20241004/20169139vCSDaf9K1n.png

來源:網路

OK,嚇豹。

我們可以注意以下幾點來理解:

  1. v-for 就地更新:
    刪掉的是第二個元素(午餐的區塊),Vue 會保留目前的元素位置、DOM 結構的順序,以這個基礎更新數據,讓第三個元素(晚餐的區塊)取代它的位置。
  2. input 臨時 DOM 狀態:
    「波士頓龍蝦」這個內容是臨時 DOM 的狀態,並未真正成為 DOM 結構(虛擬 DOM 中並不存在這個內容),所以虛擬 DOM 在操作時不會理會它。
  3. 右方 HTML Elements 更新過程:
    「午餐吃什麼:」文字直接改為「晚餐吃什麼:」,因為是重用了舊有的 DOM 結構,所以會直接在舊有的結構上更新新的資料。

(還好嗎小腦袋 QQ)
所以有感覺到,剛剛提到的:可能會發生錯誤的情境了嗎?
我們可以請 key 來幫忙。

key

定義

官方文件:為了給 Vue 一個提示,以便它可以跟蹤每個節點的標識,從而重用和重新排序現有的元素,你需要為每個元素對應的塊提供一個唯一的 key attribute

語法

:key="唯一值"

特性

  • key 值必須是唯一的。
  • 只放原始值(勿放物件)。
  • 需放在與 v-for 相同的模板上。
  • 官方推薦:有使用 v-for 就定義 :key,除非特別想利用預設的渲染行為達到某種效能。

index 作為唯一值範例

這時候我們可能會想,目前 questions 是一個陣列,那我以 index 作為唯一值,綁定看看,我們來試試看!

<script setup>
import { ref } from "vue";
const questions = ref(["早餐吃什麼:", "午餐吃什麼:", "晚餐吃什麼:"]);
const deleted = function (array, index) {
  array.splice(index, 1);
};
</script>
<template>
  <div for="input" v-for="(item, index) in questions" :key="index">
    <label>{{ item }}<input type="text" id="input" /></label>
    <button @click="deleted(questions, index)">刪除</button>
  </div>
</template>

這邊我們在 v-for 上的元素新增了一個 :key="" 指令,並用元素的 index 作為綁定唯一值。

我們期望:

Vue 在 patch 的時候,就會知道為我們幫 input 做上記號,因此也會刪掉整個「午餐吃什麼」區塊。

但結果可不是那麼容易!!

https://ithelp.ithome.com.tw/upload/images/20241004/20169139puTG4YTifb.jpg

來源:網路

結果非預期原因?

和原來的結果一樣呢⋯⋯我們先來想一下為什麼 index 作為唯一值會有錯。

  1. index 其實是會變動的
    刪除陣列中的項目時,剩餘項目的索引會改變。
刪除前區塊 index 刪除後區塊 index
早餐吃什麼 0 早餐吃什麼 0
午餐吃什麼 1
晚餐吃什麼 2 晚餐吃什麼 1
  1. DOM 結構重用
    就地更新機制會保留目前的元素的位置去 patch,當 Vue 認為「午餐吃什麼」和「晚餐吃什麼」的 index 相同,就覺得可以重用,晚餐區塊就依然會被覆蓋到午餐的區塊上去。

綜合以上兩點,造成呈現的和我們期望的不同。

樹狀圖理解 虛擬 DOM 操作

我們用樹狀圖來理解一下,在按下刪除的那一瞬間,DOM 樹做了什麼事:
(點選按鈕後,數據已經更新,才會進入 DOM 比較階段)

左樹:虛擬 DOM >> 點選按鈕那瞬間,存在的 DOM 樹。
右樹:新的 DOM >> 點選按鈕後,建立的要拿來跟舊的比較的樹。

樹中的黃色區塊:我們認為「資料被刪掉、元素應該也被刪掉」的區塊。
波士頓龍蝦區塊代表:input 中尚未寫入虛擬 DOM 的文字內容。

但實際上:Vue 會保留虛擬 DOM 的元素位置和結構的順序,去更新 DOM。
https://ithelp.ithome.com.tw/upload/images/20241004/20169139yNId06dJuN.png

而 Vue 在 patch 時,注意到了這些問題:

  1. 沒有以 key 標示:「午餐吃什麼:」和「晚餐吃什麼:」,它們在 DOM 中的 index 都是 1。當刪除或更新其中一個項目時,Vue 不會知道誰是誰。
  2. 重用結構:「午餐吃什麼:」和「晚餐吃什麼:」index 都是 1,Vue 認為可以重用這個 DOM 結構。
  3. 填補差異:比較了其中差異,發現「晚餐吃什麼:」的文字不同,因此將這段文字,填補到午餐區塊的結構中。
    https://ithelp.ithome.com.tw/upload/images/20241004/20169139013fqKLbdw.png

我們以一個動畫來看看到底做了什麼事:

  1. 中間黃色刪除區塊,模擬:按下了刪除按鈕
  2. 新舊的樹 patch 後,重用舊有的 DOM 結構,因此將真實不同的部分「晚餐吃什麼:」這段文字填補到「重用」的區塊中,而 input 這個「波士頓龍蝦」內容未被寫進舊有 DOM 結構的元素,依舊留著。
  3. 畫面上才會有:波士頓龍蝦直接移到晚餐 input 的效果。

key 唯一值正確範例

因此我們還是需要一個「唯一值」作為 DOM 的更動依據。

我們這個例子可以直接拿 questions 中的 question 作為 key 值,綁定如 :key="item.question"

<script setup>
import { ref } from "vue";
const questions = ref([
  { question: "早餐吃什麼:" },
  { question: "午餐吃什麼:" },
  { question: "晚餐吃什麼:" },
]);
const deleted = function (array, index) {
  array.splice(index, 1);
};
</script>
<template>
  <div for="input" v-for="(item, index) in questions" :key="item.question">
    <label>{{ item.question }}<input type="text" id="input" /></label>
    <button @click="deleted(questions, index)">刪除</button>
  </div>
</template>

看到我們午餐區塊是完整的整段被刪掉了嗎 🥹🥹🥹!
右方的 HTML Elements 的午餐區塊也是整段被銷毀,而晚餐區塊替換上。

組件上使用 v-for

官方文件:這一小節假設你已了解組件的相關知識,或者你也可以先跳過這裡,之後再回來看。

(這邊本菜先跳過)

陣列變化監聽

這邊文件帶我們到另一個世界,據說 v-for 遍歷時,Vue 能夠監聽陣列的變化,並自動更新 DOM。

我們接著看~

會改變原陣列的 method

對陣列進行以下 method 操作時,Vue 可以自動檢測到變化:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

不會改變原陣列的 method

以下 method 會 return 一個新陣列:

  • filter()
  • concat()
  • slice()

注意:為了確保響應性,使用以上 method 時,需要將新陣列「賦值」給原始陣列。
(但也要注意這會修改到原始資料)

範例一

先帶大家來看一下以 filter() 為範例,有無重新賦值的差異:

<script setup>
import { ref } from "vue";

const letters = ref(["a", "b", "c", "d", "e"]);
const filter = function () {
  letters.value.filter((letter) => letter.match(/a/));
  console.log(letters.value);
};
const filterCorrect = function () {
  letters.value = letters.value.filter((letter) => letter.match(/a/));
  console.log(letters.value);
};
</script>

<template>
  <div class="wrap">
    <div>
      <div>filter result:</div>
      <li v-for="(letter, index) in letters" :key="index">{{ letter }}</li>
      <button @click="filter">run filter</button>
      <button @click="filterCorrect">run filter Correct</button>
    </div>
  </div>
</template>

我們設置:

  1. letters 為一個響應式陣列,其中有 "a""b""c""d""e" 五個元素。
  2. 分別定義了 filterfilterCorrect 兩個函式,皆會在按鈕的 click 事件觸發,其中:
  • filter 會直接 return 一個新的陣列,並印出目前的陣列。
  • filterCorrect 會對原有的陣列賦值,並印出目前的陣列。
  1. 模板中會渲染目前 letter 中的元素。

預設的畫面會是:
https://ithelp.ithome.com.tw/upload/images/20241004/20169139Xf6uhFU6pX.png

實際操作看看:

filter 直接 return 新的陣列,沒有改變原有陣列,會沒有作用。
filterCorrect 會是有成功過濾出 "a" 的。

補充其 DOM 行為

官方文件:你可能認為這將導致 Vue 丟棄現有的 DOM 並重新渲染整個列表——幸運的是,情況並非如此。Vue 實現了一些巧妙的方法來最大化對 DOM 元素的重用,因此用另一個包含部分重疊對象的數組來做替換,仍會是一種非常高效的操作。

是在說 filter() 操作陣列元素的時候,雖然是 return 一個新的陣列,但它並沒有捨棄了原有的 DOM 結構,而是生成了一個新的 DOM 結構做比對,和上述提到的「就地更新」模式是一樣的。

範例二

我們以正確的 filter() 再搭配 pop() 來玩玩看:

<script setup>
import { ref } from "vue";

const letters = ref(["a", "b", "c", "d", "e"]);
const pop = function () {
  letters.value.pop();
};
const filter = function () {
  letters.value = letters.value.filter((letter) => letter.match(/a/));
};
</script>

<template>
  <div class="wrap">
    <div>
      <div>pop result:</div>
      <li v-for="(letter, index) in letters" :key="index">{{ letter }}</li>
      <button @click="pop">run pop</button>
    </div>
    <div>
      <div>filter result:</div>
      <li v-for="(letter, index) in letters" :key="index">{{ letter }}</li>
      <button @click="filter">run filter</button>
    </div>
  </div>
</template>

以上的 pop 函式:對執行 letters pop(),並在按鈕的 click 事件觸發,會 pop 掉最後一個陣列元素。
這邊要注意:由於 popfilter 是綁定於同一個響應式狀態上,因此會互相影響。

我們在瀏覽器上可見:

展示過濾或排序後的結果

官方文件提到了這樣的需求:

我們希望顯示數組經過過濾或排序後的內容,而不實際變更或重置原始數據。

記得 computed 的特性嗎?
computed 會在響應式系統的數據改變時,return 計算結果,而結果將是新的數據,不會影響到原本的資料。
所以將 v-for 搭配使用 computed,就能夠實現不改變原始資料的行為。

computed 範例

const filter = computed(() =>{
  letters.value = letters.value.filter((letter) => letter.match(/a/));
})

上述程式碼將 letters 包成一個 computed 物件,會形成這樣的機制:

  1. 只有當「其中綁定的響應式狀態」發生變化時,才重新執行。
  2. 不會更動到原始的資料:computed return 的新的陣列,並不會修改原始的陣列,可以避免副作用(例如,雖然 filter() 會 return 一個新的陣列,但如果直接賦值,會修改到原始陣列)。

因此 computed 就很像把邏輯跟介面分開了!

computed 與展開運算符

注意:在 computed 中使用 sort() reverse() 時,會變更原始陣列,因此可以使用 ... 展開運算符展開陣列,建立新的陣列。

function 範例

處理複雜資料結構(例如:二維陣列)computed 是無法做到的,可以使用 function 的方式:

<script setup>
import { ref } from "vue";

const letters = ref([
  ["a", "b", "c"],
  ["d", "a", "f"],
]);

function filterA(array) {
  return array.filter((letter) => letter.match(/a/));
}
</script>

<template>
  <ul v-for="(letterArray, index) in letters">
    <li v-for="innerLetter in filterA(letterArray)">{{ innerLetter }}</li>
  </ul>
</template>

在這個範例,我們用函式 filterA 來處理原本的陣列資料,而 v-for 仍然可以監聽並渲染更新後的數據!

小結

學習這章時突然需要非常多外力資料支援,又是資訊量超乎預期的一篇⋯⋯!(但看著迷惑漸漸消散也是蠻有趣的)
搭家也可以多參考以下放的參考資料✨✨

明天我們要進入 Vue 的「事件處理」囉~~
/images/emoticon/emoticon69.gif/images/emoticon/emoticon69.gif

範例 code ⬇️

https://github.com/Jamixcs/2024iThome-jamixcs/tree/main/src/components/day21

參考資料


上一篇
欸你是要進 Vue 了沒? - Day20:Vue 列表渲染之那個很會幫主管列資料的 v-for 同事
下一篇
欸你是要進 Vue 了沒? - Day22:Vue 事件處理之 v-on @我知道你什麼事都聽到了
系列文
欸你是要進 Vue 了沒?22
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言