哈囉搭家,在昨天我們講到了列表渲染的用法 v-for
,今天會來認識 key
這個屬性、Vue 的渲染模式「虛擬 DOM」以及 v-for
搭配 method
的一些特性和技巧。
先給個!今天會提到的內容:
- 虛擬 DOM
- v-for 的就地更新
- key
- 陣列變化監聽
- 展示過濾或排序後的結果
我們開始~~~
記得我們說 v-for
在列表渲染的角色嗎?
v-for
是那位很會幫主管列資料的同事,但總是會出包,例如列了一份資料、主管又給它一份新的,上個廁所回來,就忘記哪份資料是需要更新的。
因此主管派了一位 key
小夥伴跟在它身邊提升它的工作效率!key
會幫它把每份資料貼上標籤,讓 v-for
需要比較資料時,可以準確識別,不會搞烏龍。
看看官方文件怎麼說它:
key 这个特殊的 attribute 主要作为 Vue 的虚拟 DOM 算法提示,在比较新旧节点列表时用于识别 vnode。
喔所以!key
是一種特殊的屬性。
不過「虛擬 DOM」、「比較新舊節點列表」是什麼行為?
官方文件:虛擬 DOM (Virtual DOM,簡稱 VDOM) 是一種編程概念,意為將目標所需的 UI 通過數據結構“虛擬”地表示出來,保存在內存中,然後將真實的 DOM 與之保持同步。這個概念是由 React 率先開拓,隨後被許多不同的框架採用,當然也包括 Vue。
是一種優化 DOM 操作的模式,也是 Vue 的渲染系統的基礎。
運作模式會像這樣:當數據資料更新時,Vue 會生成一個新的虛擬 DOM 樹,並將其和舊的虛擬 DOM 的節點進行比較,決定哪些部分需要更新、新增或刪除,這個比較過程叫做 patch(或 diffing / reconciliation)。
在我們寫原生 JS ,當我們有需要重新渲染畫面的情境時,
例如:我們有一個計數器,想要在點選了按鈕時數字加一,並想在多個畫面上更新最新數字。
常見的兩種做法為:
雖然蠻適合在小的應用做的,但總會有 某些資料沒有改變、還是會被重繪 的情況,所以這些操作通常成本比較高。
而在 Vue 中,虛擬 DOM 的優勢就是:只會將那些「實際需要更新」的部分應用到真正的 DOM 上,省去不必要的全頁面重繪。
我們理解了這個模式可以幫助系統提升效能,那 key
存在的必要呢?
給 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>
我們做了些什麼:
questions
為響應式陣列。deleted
函式:其中會刪除掉傳入的 array
的當下元素。<div>
使用 v-for
指令:將會遍歷 questions
的元素和索引值,依序渲染出 <label>{{ item }}<input type="text" id="input" /></label> <button @click="deleted(questions, index)">刪除</button>
這個區塊。先看一下瀏覽器的呈現:
現在我們繞回剛剛提過的概念:
v-for
就地更新:當數據改變時,會在「不更動 DOM 元素的順序」的前提下,把資料更新。input
上的資料為臨時 DOM,尚未被寫入數據以及 DOM 結構中。我們現在設立一個情境:
- 在午餐吃什麼的
input
填寫:波士頓龍蝦。- 點選午餐吃什麼的刪除鈕。
期望的是:2. 後,午餐吃什麼的整個區塊會被刪掉。
我們實際上來試試!
來源:網路
OK,嚇豹。
我們可以注意以下幾點來理解:
v-for
就地更新:input
臨時 DOM 狀態:(還好嗎小腦袋 QQ)
所以有感覺到,剛剛提到的:可能會發生錯誤的情境了嗎?
我們可以請 key
來幫忙。
官方文件:為了給 Vue 一個提示,以便它可以跟蹤每個節點的標識,從而重用和重新排序現有的元素,你需要為每個元素對應的塊提供一個唯一的 key attribute
:key="唯一值"
v-for
相同的模板上。v-for
就定義 :key
,除非特別想利用預設的渲染行為達到某種效能。這時候我們可能會想,目前 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
做上記號,因此也會刪掉整個「午餐吃什麼」區塊。
但結果可不是那麼容易!!
來源:網路
和原來的結果一樣呢⋯⋯我們先來想一下為什麼 index
作為唯一值會有錯。
刪除前區塊 | index | 刪除後區塊 | index |
---|---|---|---|
早餐吃什麼 | 0 | 早餐吃什麼 | 0 |
午餐吃什麼 | 1 | ||
晚餐吃什麼 | 2 | 晚餐吃什麼 | 1 |
patch
,當 Vue 認為「午餐吃什麼」和「晚餐吃什麼」的 index
相同,就覺得可以重用,晚餐區塊就依然會被覆蓋到午餐的區塊上去。綜合以上兩點,造成呈現的和我們期望的不同。
我們用樹狀圖來理解一下,在按下刪除的那一瞬間,DOM 樹做了什麼事:
(點選按鈕後,數據已經更新,才會進入 DOM 比較階段)
左樹:虛擬 DOM >> 點選按鈕那瞬間,存在的 DOM 樹。
右樹:新的 DOM >> 點選按鈕後,建立的要拿來跟舊的比較的樹。樹中的黃色區塊:我們認為「資料被刪掉、元素應該也被刪掉」的區塊。
波士頓龍蝦區塊代表:input 中尚未寫入虛擬 DOM 的文字內容。但實際上:Vue 會保留虛擬 DOM 的元素位置和結構的順序,去更新 DOM。
而 Vue 在 patch 時,注意到了這些問題:
- 沒有以 key 標示:「午餐吃什麼:」和「晚餐吃什麼:」,它們在 DOM 中的 index 都是 1。當刪除或更新其中一個項目時,Vue 不會知道誰是誰。
- 重用結構:「午餐吃什麼:」和「晚餐吃什麼:」index 都是 1,Vue 認為可以重用這個 DOM 結構。
- 填補差異:比較了其中差異,發現「晚餐吃什麼:」的文字不同,因此將這段文字,填補到午餐區塊的結構中。
我們以一個動畫來看看到底做了什麼事:
- 中間黃色刪除區塊,模擬:按下了刪除按鈕
- 新舊的樹 patch 後,重用舊有的 DOM 結構,因此將真實不同的部分「晚餐吃什麼:」這段文字填補到「重用」的區塊中,而 input 這個「波士頓龍蝦」內容未被寫進舊有 DOM 結構的元素,依舊留著。
- 畫面上才會有:波士頓龍蝦直接移到晚餐 input 的效果。
因此我們還是需要一個「唯一值」作為 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
遍歷時,Vue 能夠監聽陣列的變化,並自動更新 DOM。
我們接著看~
對陣列進行以下 method
操作時,Vue 可以自動檢測到變化:
以下 method
會 return 一個新陣列:
注意:為了確保響應性,使用以上 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>
我們設置:
letters
為一個響應式陣列,其中有 "a"
、"b"
、"c"
、"d"
、"e"
五個元素。filter
和 filterCorrect
兩個函式,皆會在按鈕的 click
事件觸發,其中:filter
會直接 return 一個新的陣列,並印出目前的陣列。filterCorrect
會對原有的陣列賦值,並印出目前的陣列。letter
中的元素。預設的畫面會是:
實際操作看看:
filter
直接 return 新的陣列,沒有改變原有陣列,會沒有作用。
而 filterCorrect
會是有成功過濾出 "a"
的。
官方文件:你可能認為這將導致 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
掉最後一個陣列元素。
這邊要注意:由於 pop
和 filter
是綁定於同一個響應式狀態上,因此會互相影響。
我們在瀏覽器上可見:
官方文件提到了這樣的需求:
我們希望顯示數組經過過濾或排序後的內容,而不實際變更或重置原始數據。
記得 computed
的特性嗎?computed
會在響應式系統的數據改變時,return 計算結果,而結果將是新的數據,不會影響到原本的資料。
所以將 v-for
搭配使用 computed
,就能夠實現不改變原始資料的行為。
const filter = computed(() =>{
letters.value = letters.value.filter((letter) => letter.match(/a/));
})
const filter = computed(() =>{
return letters.value.filter((letter) => letter.match(/a/));
})
上述程式碼將 letters
包成一個 computed
物件,會形成這樣的機制:
computed
return 的新的陣列,並不會修改原始的陣列,可以避免副作用(例如,雖然 filter()
會 return 一個新的陣列,但如果直接賦值,會修改到原始陣列)。因此 computed
就很像把邏輯跟介面分開了!
注意:在 computed
中使用 sort()
reverse()
時,會變更原始陣列,因此可以使用 ...
展開運算符展開陣列,建立新的陣列。
處理複雜資料結構(例如:二維陣列)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 的「事件處理」囉~~
https://github.com/Jamixcs/2024iThome-jamixcs/tree/main/src/components/day21