key在Vue列表渲染時究竟起到了什麼作用

LoneYin發表於2019-04-19

Vue2+採用diff演算法來進行新舊vnode的對比從而更新DOM節點。而通常在我們使用v-for這個指令的時候,Vue會要求你給迴圈列表的每一項新增唯一的key,那麼這個key在渲染列表時究竟起到了什麼作用呢?

在解釋這一點之前,你最好已經瞭解Vuediff演算法的具體原理是什麼。

Vue2更新真實DOM的操作主要是兩種:建立新DOM節點並移除舊DOM節點更新已存在的DOM節點,這兩種方式裡建立新DOM節點的開銷肯定是遠大於移動已有DOM節點位置或更新DOM節點屬性的,所以在diff中邏輯都是為了減少新的建立而更多的去複用已有DOM節點來完成DOM的更新。

在新舊vnodediff過程中,key是判斷兩個節點是否為同一節點的首要條件:


// 參見Vue2原始碼 core/vdom/patch.js

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)
            )
        )
    )
}

複製程式碼

值得注意的是,如果新舊vnodekey值都未定義的話那麼兩個key都為undefineda.key === b.key 是成立的

接下來是在updateChildren方法中


// 參見Vue2原始碼 core/vdom/patch.js

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
            ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            ...
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            ...
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            ...
        } 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]
        }
    }
    ...
}
複製程式碼

設定key的可以在diff中更快速的找到對應節點,提高diff速度

updateChildren方法的while迴圈中,如果頭尾交叉對比沒有結果,即oldStartVnode存在且oldEndVnode存在且新舊children首尾四個vnode互不相同的條件下,會根據newStartVnodekey去對比oldCh陣列中的key,從而找到相應oldVnode

首先通過createKeyToOldIdx方法建立一個關於oldChmap

if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

function createKeyToOldIdx (children, beginIdx, endIdx) {
    let i, key
    const map = {}
    for (i = beginIdx; i <= endIdx; ++i) {
        key = children[i].key
        if (isDef(key)) map[key] = i
    }
    return map
}
複製程式碼

這個map中將所有定義了keyoldVnode在陣列中的index值作為鍵值,它的key作為鍵名儲存起來,然後賦給oldKeyToIdx

idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

function findIdxInOld (node, oldCh, start, end) {
    for (let i = start; i < end; i++) {
        const c = oldCh[i]
        if (isDef(c) && sameVnode(node, c)) return i
    }
}
複製程式碼

如果newStartVnodekey存在的話,就去oldKeyToIdx中尋找相同key所對應的index值,這樣就能拿到跟newStartVnodekey相同的oldVnodeoldCh陣列中的index,即得到了與newStartVnode對應的oldVnode。如果找不到的話,那麼idxInOld就為undefined

而如果newStartVnode並沒有設定key,則通過findIdxInOld方法遍歷oldCh來獲取與newStartVnode互為sameVnodeoldVnode,返回這個oldVnodeoldCh陣列的index。(前面介紹過,Vue在更新真實DOM時傾向於真實DOM節點的複用,所以在這裡還是會選擇去找對應的oldVnode,來更新已有的DOM節點)

這時候設定key的好處就顯而易見了,有key存在時我們可以通過map對映快速定位到對應的oldVnode然後進行patch,沒有key值時我們需要遍歷這個oldCh陣列然後去一一進行比較,相比之下肯定是key存在時diff更高效。

接下來就是更新DOM的過程,如果oldCh[idxInOld]存在且與newStartVnode互為sameVnode存在則先更新再移動,否則建立新的element

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)
    }
}
複製程式碼

那麼設定key值就一定能提高diff效率嗎?

答案是否定的


`<div v-for="i in arr">{{ i }}</div>`

// 如果我們的陣列是這樣的
[1, 2, 3, 4, 5]

// 它的渲染結果是這樣的
`<div>1</div>`  // key: undefined
`<div>2</div>`  // key: undefined
`<div>3</div>`  // key: undefined
`<div>4</div>`  // key: undefined
`<div>5</div>`  // key: undefined

// 將它打亂
[4, 1, 3, 5, 2]

// 渲染結果是這樣的 期間只發生了DOM節點的文字內容的更新
`<div>4</div>`  // key: undefined
`<div>1</div>`  // key: undefined
`<div>3</div>`  // key: undefined
`<div>5</div>`  // key: undefined
`<div>2</div>`  // key: undefined


// 如果我們給這個陣列每一項都設定了唯一的key
[{id: 'A', value: 1}, {id: 'B', value: 2}, {id: 'C', value: 3}, {id: 'D', value: 4}, {id: 'E', value: 5}]

// 它的渲染結果應該是這樣的
`<div>1</div>`  // key: A
`<div>2</div>`  // key: B
`<div>3</div>`  // key: C
`<div>4</div>`  // key: D
`<div>5</div>`  // key: E

// 將它打亂
[{id: 'D', value: 4}, {id: 'A', value: 1}, {id: 'C', value: 3}, {id: 'E', value: 5}, {id: 'B', value: 2}]

// 渲染結果是這樣的  期間只發生了DOM節點的移動
`<div>4</div>`  // key: D
`<div>1</div>`  // key: A
`<div>3</div>`  // key: C
`<div>5</div>`  // key: E
`<div>2</div>`  // key: B
複製程式碼

我們給陣列設定了key之後陣列的diff效率真的變高了嗎?

並沒有,因為在簡單模板的陣列渲染中,新舊節點的key都為undefined,根據sameVnode的判斷條件,這些新舊節點的keytag等屬性全部相同,所以在sameVnode(oldStartVnode, newStartVnode)這一步的時候就已經判定為對應的節點(不再執行頭尾交叉對比),然後直接進行patchVnode,根本沒有走後面的那些else。每一次迴圈新舊節點都是相對應的,只需要更新其內的文字內容就可以完成DOM更新,這種原地複用的效率無疑是最高的。

而當我們設定了key之後,則會根據頭尾交叉對比結果去執行下面的if else,進行判斷之後還需要執行insertBefore等方法移動真實DOM的節點的位置或者進行DOM節點的新增和刪除,這樣的查詢複用開銷肯定要比不帶key直接原地複用的開銷要高。

Vue文件中對此也進行了說明:

當 Vue.js 用 v-for 正在更新已渲染過的元素列表時,它預設用“就地複用”策略。如果資料項的順序被改變,Vue 將不會移動 DOM 元素來匹配資料項的順序, 而是簡單複用此處每個元素,並且確保它在特定索引下顯示已被渲染過的每個元素。

這個預設的模式是高效的,但是隻適用於不依賴子元件狀態或臨時 DOM 狀態 (例如:表單輸入值) 的列表渲染輸出。

建議儘可能在使用 v-for 時提供 key,除非遍歷輸出的 DOM 內容非常簡單,或者是刻意依賴預設行為以獲取效能上的提升。

所以,簡單列表的渲染可以不使用key或者用陣列的index作為key(效果等同於不帶key),這種模式下效能最高,但是並不能準確的更新列表項的狀態。一旦你需要儲存列表項的狀態,那麼就需要用使用唯一的key用來準確的定位每一個列表項以及複用其自身的狀態,而大部分情況下列表元件都有自己的狀態。

總結

key在列表渲染中的作用是:在複雜的列表渲染中快速準確的找到與newVnode相對應的oldVnode,提升diff效率

相關文章