面試官:為什么 Vue 中不要用 index 作為 key?(diff 算法詳解)
以下文章來源于前端從進(jìn)階到入院 ,作者ssh
前言
Vue 中的 key 是用來做什么的?為什么不推薦使用 index 作為 key?常常聽說這樣的問題,本篇文章帶你從原理來一探究竟。
示例
以這樣一個(gè)列表為例:
<ul>
<li>1</li>
<li>2</li>
</ul>
那么它的 vnode 也就是虛擬 dom 節(jié)點(diǎn)大概是這樣的。
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
]
}
假設(shè)更新以后,我們把子節(jié)點(diǎn)的順序調(diào)換了一下:
{
tag: 'ul',
children: [
+ { tag: 'li', children: [ { vnode: { text: '2' }}] },
+ { tag: 'li', children: [ { vnode: { text: '1' }}] },
]
}
很顯然,這里的 children 部分是我們本文 diff 算法要講的重點(diǎn)(敲黑板)。
首先響應(yīng)式數(shù)據(jù)更新后,觸發(fā)了 渲染 Watcher 的回調(diào)函數(shù) vm._update(vm._render())去驅(qū)動視圖更新,
vm._render() 其實(shí)生成的就是 vnode,而 vm._update 就會帶著新的 vnode 去走觸發(fā) __patch__ 過程。
我們直接進(jìn)入 ul 這個(gè) vnode 的 patch 過程。
對比新舊節(jié)點(diǎn)是否是相同類型的節(jié)點(diǎn):
1. 不是相同節(jié)點(diǎn):
isSameNode為false的話,直接銷毀舊的 vnode,渲染新的 vnode。這也解釋了為什么 diff 是同層對比。
2. 是相同節(jié)點(diǎn),要盡可能的做節(jié)點(diǎn)的復(fù)用(都是 ul,進(jìn)入??)。
會調(diào)用src/core/vdom/patch.js下的patchVNode方法。
如果新 vnode 是文字 vnode
就直接調(diào)用瀏覽器的 dom api 把節(jié)點(diǎn)的直接替換掉文字內(nèi)容就好。
如果新 vnode 不是文字 vnode
如果有新 children 而沒有舊 children
說明是新增 children,直接 addVnodes 添加新子節(jié)點(diǎn)。
如果有舊 children 而沒有新 children
說明是刪除 children,直接 removeVnodes 刪除舊子節(jié)點(diǎn)
如果新舊 children 都存在(都存在 li 子節(jié)點(diǎn)列表,進(jìn)入??)
那么就是我們 diff算法 想要考察的最核心的點(diǎn)了,也就是新舊節(jié)點(diǎn)的 diff 過程。
通過
// 舊首節(jié)點(diǎn)
let oldStartIdx = 0
// 新首節(jié)點(diǎn)
let newStartIdx = 0
// 舊尾節(jié)點(diǎn)
let oldEndIdx = oldCh.length - 1
// 新尾節(jié)點(diǎn)
let newEndIdx = newCh.length - 1
這些變量分別指向舊節(jié)點(diǎn)的首尾、新節(jié)點(diǎn)的首尾。
根據(jù)這些指針,在一個(gè) while 循環(huán)中不停的對新舊節(jié)點(diǎn)的兩端的進(jìn)行對比,直到?jīng)]有節(jié)點(diǎn)可以對比。
在講對比過程之前,要講一個(gè)比較重要的函數(shù):sameVnode:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
)
)
}
它是用來判斷節(jié)點(diǎn)是否可用的關(guān)鍵函數(shù),可以看到,判斷是否是 sameVnode,傳遞給節(jié)點(diǎn)的 key 是關(guān)鍵。
然后我們接著進(jìn)入 diff 過程,每一輪都是同樣的對比,其中某一項(xiàng)命中了,就遞歸的進(jìn)入 patchVnode 針對單個(gè) vnode 進(jìn)行的過程(如果這個(gè) vnode 又有 children,那么還會來到這個(gè) diff children 的過程 ):
舊首節(jié)點(diǎn)和新首節(jié)點(diǎn)用 sameNode 對比。
舊尾節(jié)點(diǎn)和新首節(jié)點(diǎn)用 sameNode 對比
舊首節(jié)點(diǎn)和新尾節(jié)點(diǎn)用 sameNode 對比
舊尾節(jié)點(diǎn)和新尾節(jié)點(diǎn)用 sameNode 對比
如果以上邏輯都匹配不到,再把所有舊子節(jié)點(diǎn)的 key 做一個(gè)映射表,然后用新 vnode 的 key 去找出在舊節(jié)點(diǎn)中可以復(fù)用的位置。
然后不停的把匹配到的指針向內(nèi)部收縮,直到新舊節(jié)點(diǎn)有一端的指針相遇(說明這個(gè)端的節(jié)點(diǎn)都被patch過了)。
在指針相遇以后,還有兩種比較特殊的情況:
有新節(jié)點(diǎn)需要加入。如果更新完以后,oldStartIdx > oldEndIdx,說明舊節(jié)點(diǎn)都被 patch 完了,但是有可能還有新的節(jié)點(diǎn)沒有被處理到。接著會去判斷是否要新增子節(jié)點(diǎn)。
有舊節(jié)點(diǎn)需要刪除。如果新節(jié)點(diǎn)先patch完了,那么此時(shí)會走 newStartIdx > newEndIdx 的邏輯,那么就會去刪除多余的舊子節(jié)點(diǎn)。
為什么不要以index作為key?
假設(shè)我們有這樣的一段代碼:
<div id="app">
<ul>
<item
:key="index"
v-for="(num, index) in nums"
:num="num"
:class="`item${num}`"
></item>
</ul>
<button @click="change">改變</button>
</div>
<script src="./vue.js"></script>
<script>
var vm = new Vue({
name: "parent",
el: "#app",
data: {
nums: [1, 2, 3]
},
methods: {
change() {
this.nums.reverse();
}
},
components: {
item: {
props: ["num"],
template: `
<div>
{{num}}
</div>
`,
name: "child"
}
}
});
</script>
其實(shí)是一個(gè)很簡單的列表組件,渲染出來 1 2 3 三個(gè)數(shù)字。我們先以 index 作為key,來跟蹤一下它的更新。
我們接下來只關(guān)注 item 列表節(jié)點(diǎn)的更新,在首次渲染的時(shí)候,我們的虛擬節(jié)點(diǎn)列表 oldChildren 粗略表示是這樣的:
[
{
tag: "item",
key: 0,
props: {
num: 1
}
},
{
tag: "item",
key: 1,
props: {
num: 2
}
},
{
tag: "item",
key: 2,
props: {
num: 3
}
}
];
在我們點(diǎn)擊按鈕的時(shí)候,會對數(shù)組做 reverse 的操作。那么我們此時(shí)生成的 newChildren 列表是這樣的:
[
{
tag: "item",
key: 0,
props: {
+ num: 3
}
},
{
tag: "item",
key: 1,
props: {
+ num: 2
}
},
{
tag: "item",
key: 2,
props: {
+ num: 1
}
}
];
發(fā)現(xiàn)什么問題沒有?key的順序沒變,傳入的值完全變了。這會導(dǎo)致一個(gè)什么問題?
本來按照最合理的邏輯來說,舊的第一個(gè)vnode 是應(yīng)該直接完全復(fù)用 新的第三個(gè)vnode的,因?yàn)樗鼈儽緛砭蛻?yīng)該是同一個(gè)vnode,自然所有的屬性都是相同的。
但是在進(jìn)行子節(jié)點(diǎn)的 diff 過程中,會在 舊首節(jié)點(diǎn)和新首節(jié)點(diǎn)用sameNode對比。 這一步命中邏輯,因?yàn)楝F(xiàn)在新舊兩次首部節(jié)點(diǎn) 的 key 都是 0了,
然后把舊的節(jié)點(diǎn)中的第一個(gè) vnode 和 新的節(jié)點(diǎn)中的第一個(gè) vnode 進(jìn)行 patchVnode 操作。
這會發(fā)生什么呢?我可以大致給你列一下:首先,正如我之前的文章props的更新如何觸發(fā)重渲染?里所說,在進(jìn)行 patchVnode 的時(shí)候,會去檢查 props 有沒有變更,如果有的話,會通過 _props.num = 3 這樣的邏輯去更新這個(gè)響應(yīng)式的值,觸發(fā) dep.notify,觸發(fā)子組件視圖的重新渲染等一套很重的邏輯。
然后,還會額外的觸發(fā)以下幾個(gè)鉤子,假設(shè)我們的組件上定義了一些dom的屬性或者類名、樣式、指令,那么都會被全量的更新。
updateAttrs
updateClass
updateDOMListeners
updateDOMProps
updateStyle
updateDirectives
而這些所有重量級的操作(虛擬dom發(fā)明的其中一個(gè)目的不就是為了減少真實(shí)dom的操作么?),都可以通過直接復(fù)用 第三個(gè)vnode 來避免,是因?yàn)槲覀兺祽袑懥?index 作為 key,而導(dǎo)致所有的優(yōu)化失效了。
為什么不要用隨機(jī)數(shù)作為key?
<item
:key="Math.random()"
v-for="(num, index) in nums"
:num="num"
:class="`item${num}`"
/>
其實(shí)我聽過一種說法,既然官方要求一個(gè) 唯一的key,是不是可以用 Math.random() 作為 key 來偷懶?這是一個(gè)很雞賊的想法,看看會發(fā)生什么吧。
首先 oldVnode 是這樣的:
[
{
tag: "item",
key: 0.6330715699108844,
props: {
num: 1
}
},
{
tag: "item",
key: 0.25104533240710514,
props: {
num: 2
}
},
{
tag: "item",
key: 0.4114769152411637,
props: {
num: 3
}
}
];
更新以后是:
[
{
tag: "item",
+ key: 0.11046018699748683,
props: {
+ num: 3
}
},
{
tag: "item",
+ key: 0.8549799545696619,
props: {
+ num: 2
}
},
{
tag: "item",
+ key: 0.18674467938937478,
props: {
+ num: 1
}
}
];
可以看到,key 變成了完全全新的 3 個(gè)隨機(jī)數(shù)。
上面說到,diff 子節(jié)點(diǎn)的首尾對比如果都沒有命中,就會進(jìn)入 key 的詳細(xì)對比過程,簡單來說,就是利用舊節(jié)點(diǎn)的 key -> index 的關(guān)系建立一個(gè) map 映射表,然后用新節(jié)點(diǎn)的 key 去匹配,如果沒找到的話,就會調(diào)用 createElm 方法 重新建立 一個(gè)新節(jié)點(diǎn)。
具體代碼在這:
// 建立舊節(jié)點(diǎn)的 key -> index 映射表
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 去映射表里找可以復(fù)用的 index
idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 一定是找不到的,因?yàn)樾鹿?jié)點(diǎn)的 key 是隨機(jī)生成的。
if (isUndef(idxInOld)) {
// 完全通過 vnode 新建一個(gè)真實(shí)的子節(jié)點(diǎn)
createElm();
}
也就是說,咱們的這個(gè)更新過程可以這樣描述:123 -> 前面重新創(chuàng)建三個(gè)子組件 -> 321123 -> 刪除、銷毀后面三個(gè)子組件 -> 321。
發(fā)現(xiàn)問題了吧?這是毀滅性的災(zāi)難,創(chuàng)建新的組件和銷毀組件的成本你們曉得的伐……本來僅僅是對組件移動位置就可以完成的更新,被我們毀成這樣了。
總結(jié)
經(jīng)過這樣的一段旅行,diff 這個(gè)龐大的過程就結(jié)束了。
我們收獲了什么?
用組件唯一的 id(一般由后端返回)作為它的 key,實(shí)在沒有的情況下,可以在獲取到列表的時(shí)候通過某種規(guī)則為它們創(chuàng)建一個(gè) key,并保證這個(gè) key 在組件整個(gè)生命周期中都保持穩(wěn)定。
別用 index 作為 key,和沒寫基本上沒區(qū)別,因?yàn)椴还苣銛?shù)組的順序怎么顛倒,index 都是 0, 1, 2 這樣排列,導(dǎo)致 Vue 會復(fù)用錯(cuò)誤的舊子節(jié)點(diǎn),做很多額外的工作。
千萬別用隨機(jī)數(shù)作為 key,不然舊節(jié)點(diǎn)會被全部刪掉,新節(jié)點(diǎn)重新創(chuàng)建,你的老板會被你氣死。
作者:ssh
歡迎關(guān)注微信公眾號 :前端陽光