【面試題解析】從 Vue 源碼分析 key 的作用

最近看了面試題中有一個這樣的題,v-for 爲什麼要綁定 key?

Vue 中 key 很多人都弄不清楚有什麼作用,甚至還有些人認爲不綁定 key 就會報錯。

其實沒綁定 key 的話,Vue 還是可以正常運行的,報警告是因爲沒通過 Eslint 的檢查。

接下來將通過源碼一步步分析這個 key 的作用。

Virtual DOM

Virtual DOM 最主要保留了 DOM 元素的層級關係和一些基本屬性,本質上就是一個 JS 對象。相對於真實的 DOM,Virtual DOM 更簡單,操作起來速度更快。

如果需要改變 DOM,則會通過新舊 Virtual DOM 對比,找出需要修改的節點進行真實的 DOM 操作,從而減小性能消耗。

Diff

傳統的 Diff 算法需要遍歷一個樹的每個節點,與另一棵樹的每個節點對比,時間複雜度爲 O(n²)。

Vue 採用的 Diff 算法則通過逐級對比,大大降低了複雜性,時間複雜度爲 O(n)。

VNode 更新過程

VNode 更新首先會經過 patch 函數,patch 函數源碼如下:

/* vue/src/core/vdom/patch.js */
function patch (oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // somecode
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

vnode 表示更新後的節點,oldVnode 表示更新前的節點,通過對比新舊節點進行操作。

1、vnode 未定義,oldVnode 存在則觸發 destroy 的鉤子函數

2、oldVnode 未定義,則根據 vnode 創建新的元素

3、oldVnode 不爲真實元素並且 oldVnode 與 vnode 爲同一節點,則會調用 patchVnode 觸發更新

4、oldVnode 爲真實元素或者 oldVnode 與 vnode 不是同一節點,另做處理

接下來會進入 patchVnode 函數,源碼如下:

function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  if (oldVnode === vnode) {
    return
  }

  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // clone reused vnode
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  // somecode

  const oldCh = oldVnode.children
  const ch = vnode.children

  // somecode

  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

1、vnode 的 text 不存在,則會比對 oldVnode 與 vnode 的 children 節點進行更新操作

2、vnode 的 text 存在,則會修改 DOM 節點的 text

接下來在 updateChildren 函數內就可以看到 key 的用處。

key 的作用

key 的作用主要是給 VNode 添加唯一標識,通過這個 key,可以更快找到新舊 VNode 的變化,從而進一步操作。

key 的作用主要表現在以下這段源碼中。

/* vue/src/core/vdom/patch.js */
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

  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  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]
    } 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 {
      // 以上 4 種均匹配不到,通過 key 生成 key -> index 的 map(生成一次)
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      /**
       * 有 key 通過 key 比較,時間複雜度 O(n)
       * 無 key 時,每個 vnode 均需要遍歷比較,時間複雜度  O(n²)
       */
      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)
  }
}

updateChildren 過程爲:

1、分別用兩個指針(startIndex, endIndex)表示 oldCh 和 newCh 的頭尾節點

2、對指針所對應的節點做一個兩兩比較,判斷是否屬於同一節點

3、如果4種比較都沒有匹配,那麼判斷是否有 key,有 key 就會用 key 去做一個比較;無 key 則會通過遍歷的形式進行比較

4、比較的過程中,指針往中間靠,當有一個 startIndex > endIndex,則表示有一個已經遍歷完了,比較結束

總結

從 VNode 的渲染過程可以得知,Vue 的 Diff 算法先進行的是同級比較,然後再比較子節點。

子節點比較會通過 startIndex、endIndex 兩個指針進行兩兩比較,再通過 key 比對子節點。如果沒設置 key,則會通過遍歷的方式匹配節點,增加性能消耗。

所以不綁定 key 並不會有問題,綁定 key 之後在性能上有一定的提升。

綜上,key 主要是應用在 Diff 算法中,作用是爲了更快速定位出相同的新舊節點,儘量減少 DOM 的創建和銷燬的操作。

希望以上內容能夠對各位小夥伴有所幫助,祝大家面試順利。

補充(2020/03/16)

Vue 的文檔中對 key 的說明如下:

key 的特殊屬性主要用在 Vue 的虛擬 DOM 算法,在新舊 nodes 對比時辨識 VNodes。如果不使用 key,Vue 會使用一種最大限度減少動態元素並且儘可能的嘗試就地修改/複用相同類型元素的算法。而使用 key 時,它會基於 key 的變化重新排列元素順序,並且會移除 key 不存在的元素。

關於就地修改,關鍵在於 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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

可以看出,當 key 未綁定時,主要通過元素的標籤等進行判斷,在 updateChildren 內會將 oldStartVnode 與 newStartVnode 判斷爲同一節點。

如果 VNode 中只包含了文本節點,在 patchVnode 中可以直接替換文本節點,而不需要移動節點的位置,確實在不綁定 key 的情況下效率要高一丟丟。

某些情況下不綁定 key 的效率更高,那爲什麼大部分Eslint的規則還是要求綁定 key 呢?

因爲在實際項目中,大多數情況下 v-for 的節點內並不只有文本節點,那麼 VNode 的字節點就要進行銷燬和創建的操作。

相比替換文本帶來的一丟丟提升,這部分會消耗更多的性能,得不償失。

瞭解了就地修改,那麼我們在一些簡單節點上可以選擇不綁定 key,從而提高性能。


如果你喜歡我的文章,希望可以關注一下我的公衆號【前端develop】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章