v-for為什么要加key,能用index作為key么


前言

在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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM