前言
在vue中使用v-for時,一直有幾個疑問:
- v-for為什么要加key
- 為什么有時候用index作為key會出錯
帶着這個疑問,結合各種博客和源碼,終於有了點眉目。
virtual dom
要理解diff的過程,先要對virtual dom有個了解,這里簡單介紹下。
【作用】
我們都知道重繪和回流,回流會導致dom重新渲染,比較耗性能;而virtual dom就是用一個對象去代替dom對象,當有多次更新dom的動作時,不會立即更新dom,而是將變化保存到一個對象中,最終一次性將改變渲染出來。
【形式】
<div>
<p></p>
<span></span>
</div>
以上代碼轉換成virtual dom就是如下形式(當然省去了很多其他屬性)
{
tag: 'div',
children: [
{
tag: 'p'
},
{
tag: 'span'
}
]
}
diff原理
首先當然是附上這張經典的圖
圖中很清楚的說明了,diff的比較過程只會在同層級比較,不會跨級比較。
整體的比較過程可以理解為一個層層遞進的過程,每一級都是一個vnode數組,當比較其中一個vnode時,若children不一樣,就會進入updateChildren函數(其主要入參為newChildren和oldChildren,此時newChildren和oldChildren為同級的vnode數組);然后逐一比較children里的節點,對於children的children,再循環以上步驟。
updateChildren就是diff最核心的算法,源碼如下(簡要了解就行):
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
diff算法是一個交叉對比的過程,大致可以簡要概括為:頭頭比較、尾尾比較、頭尾比較、尾頭比較。具體過程可以參見這邊博文,里面用例子講的很清楚。
為什么要加key
可以回頭看下上面的源碼,有一個sameVnode
函數,其源碼可以簡化為如下:
function sameVnode (a, b) {
return (
a.key === b.key && a.tag === b.tag
)
}
也就是說,判斷兩個節點是否為同一節點(也就是是否可復用),標准是key相同且tag相同。
以下圖的改變(圓圈代表一個vnode,所有node的tag相同)為例
- 當不加key時,key都是undefined,默認相同,此時就會按照上一節diff算法的就地復用來進行比較:
以上,B復用A的結構,C復用B的結構,D復用C的結構,E復用D的結構,刪除E;然后如果數據有變化,再更新數據
說明:復用是指dom結構復用,如果數據有更新,之后會再進行數據更新
- 如果加上唯一識別的key
以上,B、C、D、E全部可以復用,刪除A即可
從以上的對比可以看出,加上key可以最大化的利用節點,減少性能消耗
為什么不建議用index作為key
在工作中,發現很多人直接用index作為key,好像幾乎沒遇到過什么問題。確實,index作為key,在表現上確實幾乎沒有問題,但它主要有兩點不足:
1)index作為key,其實就等於不加key
2)index作為key,只適用於不依賴子組件狀態或臨時 DOM 狀態 (例如:表單輸入值) 的列表渲染輸出(這是vue官網的說明)
第一點
對於第一點,還是以上面的例子來說明
雖然加上了key,但key是就是當前的順序索引值,此時sameNode(A,B)依然是為true,所以與不加key時一樣,A復用B的結構並將數據更新為B。下面以一個demo來說明
<div id="demo">
<div v-for="(item,index) in list" :key="index">
<item-box :data=item></item-box>
<button @click="delClick(index)">刪除</button>
</div>
</div>
Vue.component('item-box',{
template:'<span>{{data.msg}}</span>',
props: ['data'],
})
var demo = new Vue({
el: '#demo',
data() {
return {
list: [
{
msg: 'k1',
id: 1
},
{
msg: 'k2',
id: 2
},
{
msg: 'k3',
id:3
}
]
}
},
methods: {
delClick (index) {
this.list.splice(index, 1)
}
}
})
操作:刪除k1
-
不加key,或用index作為key,變化過程是
圖a圖b
圖c
圖d
也就是
經過對比,復用1、復用2、刪除3
(圖b),更新1的數據
(圖c),更新2的數據
(圖d) -
將demo中的key值由
index
改為item.id
,則變化過程是
也就是
經對比,復用2,復用3,刪除1
小結:從以上對比可知,用index做key,與不用key是一樣的。由於把源碼貼出來比較不易懂,所以只是把debugger源碼的結果貼出來了,感興趣的可以自己去debugger這個過程,理解的會更好。
第二點
第二點有兩種情況,我們首先看依賴子組件狀態的情況
【依賴子組件狀態】
還是剛剛的例子,做一點修改
Vue.component('item-box',{
template:'<button @click="itemClick">{{status}}</button>',
props: ['data'],
data () {
return {
status: 'no'
}
},
methods: {
itemClick () {
this.status = 'yes'
}
}
})
也就是將template里面的數據由props改為data,即子組件內部的數據。
操作:點擊第一個no和第二個no,然后點擊第一個刪除,奇怪的事出現了
- 不加key,或用index作為key,結果是
本來應該刪除第一個的,好像把第三個給刪了。是這樣么?是的。這個過程相當於第一點里面的圖b,但卻少了后續數據更新的過程了。為什么不更新數據了呢?因為,數據更新這個步驟是當依賴list的數據發生變化,再根據訂閱模式中添加的依賴來依次更新數據(此處可以了解下雙向綁定)。可以粗暴的理解為,不依賴於list的數據,此處不關心,不會去更新,流程就停留在圖b了,因此我們看到的就是錯誤的表現了。
- 將demo中的key值由
index
改為item.id
此時就是預期的結果啦
小結:以上就是官網里提到的,就地復用的原則不適用於依賴子組件狀態的場景,以上例子中,status就是子組件的狀態,與外部無關
【依賴臨時dom狀態】
修改剛剛的demo
<div id="demo">
<div v-for="(item,index) in list" :key="index">
<input type="text">
<button @click="delClick(index)">刪除</button>
</div>
</div>
操作:在輸入框中分別輸入1、2、3,然后刪除1
- 不加key,或用index作為key,結果是
不用多說了,一樣的道理,因為這是input的臨時狀態,與list無關,所以停留在圖b的狀態就不會繼續有數據更新了,我們看到的就是圖b的樣子了
- 將demo中的key值由
index
改為item.id
更不用多說了,這里就是對的了
總結
- diff算法默認使用“就地復用”的策略,是一個首尾交叉對比的過程。
- 用index作為key和不加key是一樣的,都采用“就地復用”的策略
- “就地復用”的策略,只適用於不依賴子組件狀態或臨時 DOM 狀態 (例如:表單輸入值) 的列表渲染輸出。
- 將與元素唯一對應的值作為key,可以最大化利用dom節點,提升性能
參考:
https://www.zhihu.com/question/61064119/answer/183717717
https://www.jianshu.com/p/342e2d587e69
https://codepen.io/vvpvvp/pen/oZKpgE
https://www.jianshu.com/p/cd39cf4bb61d