從源碼解惑,爲何寫列表v-for必須設置key

問題描述

在列表中使用key已是老生常談的問題了,官方 最佳實踐 也大力推薦使用key。。。不對,官方原話是:javascript

在組件上老是必須用 key 配合 v-for,以便維護內部組件及其子樹的狀態。html

既然官方都說的這麼中肯了,那麼確定是很重要了。可是,當初不明真相的我,老是以爲寫和不寫也沒啥區別啊,反正都能渲染出來。寫着好麻煩啊~🥱,算了,下次,下次必定寫。。。vue

其實之因此看不出來區別是由於一切發生的太快了。。。直到有一天我打了一個斷點才發現區別大的不止一點點,事情是這樣的java

我寫了一個下面這樣的列表,開始有五個元素,分別是A,B,C,D,E。在組件mount事後兩秒向列表頭部插入一個元素F,而後對比一下使用key和不使用key的區別node

<div id="app">
  <ul>
<!-- <li v-for="item in list" :key="item">{{item}}</li>-->
        <li v-for="item in list">{{item}}</li>
  </ul>
  <h2>不使用key,仔細看上面元素的變化</h2>
</div>
複製代碼
const app = new Vue({
    data() {
      return {
        list: ['A', 'B', 'C', 'D', 'E']
      }
    },
    el: '#app',
    mounted() {
      setTimeout(() => {
        this.list.unshift('F')
      }, 2000)
    }
  })
複製代碼

不使用key

pic1

使用key

pic2

區別

  • 在不使用key的時候,一共進行了五次更新操做和一次新建插入(元素E)操做
    pic3
  • 使用key時,只作了一次新建插入(元素F)操做
    pic4

問題分析

聰明的你可能會問了,爲何呢?git

咱們知道,虛擬dom是一個樹結構,當組件數據變化時會執行組件的patchVnode方法,而後按照深度優先,同層比較的原則進行diff。若是新老節點都有子節點,則調用updateChildren方法進行子節點的比較,虛擬dom diff的核心算法就在這個方法裏面。github

vue中的虛擬dom 補丁算法是在 snapdom 的基礎上改造而來,最主要的優化就包括針對web場景,在web中,咱們最多進行的操做是在隊尾或者隊首插入元素,在遍歷查找新的元素對應的老元素以前,先進行新老首尾2x2=4次盲猜。web

爲了方便查看,我會在下面源碼中插入相關注釋算法

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }
    // 若是新老任何一組元素的遊標重合,就結束循環
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        // 針對web中常見操做,在遍歷查找新的元素對應的老元素以前,先進行新老首尾2x2=4次盲猜
        // 對比新開始和老開始
      } 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 {
        // 都沒有命中,建立一個老節點索引和key的映射表,循環查找
        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 {
            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)
    }
 }
複製代碼

講到這裏你們可能仍是不明白爲何要用key呢,其實答案就在盲猜時執行的sameVnode方法,廢話很少說,直接看代碼!app

// 判斷兩個vnode是不是相同vnode
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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
複製代碼

當咱們在列表開頭插入一個F元素時,若是不使用key,則key等於undefined,而undefined===undefined恆成立,而且tag都是li,都不是註釋節點,因此會把新插入的F(newStartVnode)和老的A(oldStartVnode)認爲是同一個節點執行更新,遊標向後移動一位,而且後續4個元素都按照這種邏輯更新,最後老節點先結束了(遊標先重合),新節點還剩一個元素E,就建立一個新的節點並插入,最終一共進行了五次更新操做和一次新建插入(元素E)操做。

pic3

若是咱們使用了key,對比newStartVnodeoldStartVnode時發現key不一樣,不是同一個元素,就繼續對比oldEndVnode newEndVnode發現他們key相同,都爲E,而且文本內容和其餘屬性也沒變,就直接複用而後遊標向前移動一位,最後老節點先結束了(遊標先重合),新節點還剩一個元素F,就建立一個新的節點並插入。最終只進行了一次新建插入(元素F)操做

pic4

引伸

使用key除了提升性能之外,還有一些其餘的使用場景,例如:

  1. 當你想爲一個元素添加動畫,好比上下移動,若是沒有使用key,這個元素頗有可能在updateChildren時被替換了,那麼元素的動畫可能不會完整的展現
  2. 當你在一個input上獲取焦點後,若是沒有使用key,通過updateChildren更新後,可能會失去焦點
  3. 其餘由於dom順序改變可能形成的bug

pic6

解決上述問題最直接最有效的辦法就是爲元素增長key
PS:下次必定要寫key 😂

最後,這篇文章屬於 從源碼解惑 系列文章中的一篇,歡迎你們關注、留言、拍磚

相關文章
相關標籤/搜索