v-for 到底為啥要加上 key?

語言: CN / TW / HK

theme: devui-blue

我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第1篇文章,點選檢視活動詳情

看了一些講解 v-for 中加 key 的文章,發現都描述的很籠統,甚至有很多不準確的,那不妨自力更生,這次直接從 vue3 的原始碼入手,帶你瞭解真相,洞悉真理。

注:全文基於 vue v3.2.38 版本原始碼

先看看官方文件對 key 的描述:

Vue 預設按照“就地更新”的策略來更新通過 v-for 渲染的元素列表。當資料項的順序改變時,Vue 不會隨之移動 DOM 元素的順序,而是就地更新每個元素,確保它們在原本指定的索引位置上渲染。

預設模式是高效的,但只適用於列表渲染輸出的結果不依賴子元件狀態或者臨時 DOM 狀態 (例如表單輸入值) 的情況

為了給 Vue 一個提示,以便它可以跟蹤每個節點的標識,從而重用和重新排序現有的元素,你需要為每個元素對應的塊提供一個唯一的 key attribute

這裡,我們可以得到幾個有用的資訊:

  1. 沒有 key 的元素列表會通過就地更新,保證他們在原本指定的索引位置上渲染
  2. 添加了唯一的 key 屬性可以高效地重用和重新排序現有的元素
  3. 預設模式(不加 key)只適用於列表渲染輸出的結果不依賴子元件狀態或者臨時 DOM 狀態的情況

前置瞭解

磨刀不誤砍柴工,在這之前,我們需要了解 vue3 的編譯優化和渲染器模組中的 patch 流程

編譯優化

vue3 為了渲染函式的靈活性和對 vue2 的相容,還是選擇保留了虛擬 DOM 的設計。因此不可避免地也要承擔虛擬 DOM 帶來的額外效能開銷(相較於直接編譯成原生 DOM 程式碼)。為了優化這一方面的開銷,vue3 引入了 Block 和 PatchFlags 的概念。

首先我們需要了解一下什麼是動態節點,如下一段程式碼

```

我是靜態
 

{{ dynamic }}

```

上述模板中只有 dynamic 是個可以動態修改的變數,因此將<p>{{ dynamic }}</p>編譯成的 vnode 就是個動態節點。

所以優化的思路其實就是,在建立 vnode 階段,就將這些動態節點給標記和提取出來,如果要更新,就只更新這些動態節點,靜態節點保持不變

其中 PatchFlags 就是用來標記動態節點型別的,動態節點具有如下型別:

ts export const enum PatchFlags {  // 文字節點  TEXT = 1,  // 動態 class  CLASS = 1 << 1,  // 動態 style  STYLE = 1 << 2,  // 具有動態屬性的元素或元件  PROPS = 1 << 3,  // 具有動態 key 屬性的節點更新(不包括類名和樣式)  FULL_PROPS = 1 << 4,  // 帶有監聽事件的節點  HYDRATE_EVENTS = 1 << 5,  // 子節點順序不會變的 Fragment  STABLE_FRAGMENT = 1 << 6,  // 帶有 key 屬性的 Fragment  KEYED_FRAGMENT = 1 << 7,  // 不帶 key 的 Fragment  UNKEYED_FRAGMENT = 1 << 8,  // 僅對非 props 進行更新  NEED_PATCH = 1 << 9,  // 動態插槽  DYNAMIC_SLOTS = 1 << 10,  // 開發時放在根節點下的註釋 Fragment,因為生產環境註釋會被剝離  DEV_ROOT_FRAGMENT = 1 << 11,    // 以下是內建的特殊標記,不會在更新優化中用到  // 靜態節點標記(用於手動標記靜態節點跳過更新)  HOISTED = -1,  // 可以將 diff 演算法退出優化模式而走全量 diff  BAIL = -2 }

Block 其實就相當於普通的虛擬節點加了個dynamicChildren屬性,能夠收集節點本身和它所有子節點中的動態節點。當需要更新 Block 中的子節點時,只要對dynamicChildren存放的動態子節點進行更新就可以了。

同時,由於每個動態節點都有 patchFlag 標記了它們的動態屬性,所以更新也只需要更新動態節點標記的這些屬性就可以了。

舉個例子:

```

​ ```

這是一個簡單的文字變更的過程,三秒後”動態節點“會變成”變更文字“

2022-09-06 16.16.35.gif

按照傳統的 diff 流程,文字變更會生成一棵新的虛擬 DOM 樹,所以對比新舊 DOM 樹就需要按照虛擬 DOM 的層級結構一層一層地遍歷對比。上面這段模板從最外層的 div 往內一路對比過來,直到更新 p 中的文字內容。

而有了 Block 的收集動態節點和標記動態屬性的方式,在文字產生變更需要更新的時候,只需要更新 p 節點中的文字屬性。相較傳統 diff 模式,簡直是效能上的飛躍。大致對比如下:

image-20220906170236352.png

上述例子中模板的根節點就是一個 Block,因為根節點可以自上而下將它的動態子節點都收集到dynamicChildren裡去,子節點需要更新的時候再把dynamicChildren丟擲去做 diff 流程就行了。

那和 v-for 有啥關係?

v-for 指令渲染的是一個片段,會被標記為 Fragment 型別,同時 v-for 指令的節點會讓虛擬樹變得不穩定,所以需要將其編譯為 Block

所以 v-for 就是一個能夠收集動態子節點的 Block,它的子節點 patchFlag 一共有三種

  • STABLE_FRAGMENT 當使用 v-for 去遍歷常量時,會標記為STABLE_FRAGMENT
  • KEYED_FRAGMENT 當使用 v-for 去遍歷變數且綁定了 key,會標記為KEYED_FRAGMENT
  • UNKEYED_FRAGMENT 當使用 v-for 去遍歷變數且沒有繫結 key,會標記為KEYED_FRAGMENT

v-for 去遍歷常量時會被標記為STABLE_FRAGMENT。是因為遍歷常量渲染出的子節點是不會變更順序的,子節點中可能包含的動態子節點會走自身的更新邏輯。所以在下文中我們就可以不考慮這一類的情況。

知道以上這些知識,我們就可以繼續往下了

patch 流程

眾所周知,patch 函式是 vue3 中一手承包了元件掛載和更新的,大致的 patch 流程如下:

image.png

詳細的過程就不分析了,可能需要篇幾萬字的長文,沒關係,這裡我們只要關注流程的最末端

image.png

是不是很眼熟?

當使用 v-for 去遍歷變數時,變數如果產生響應式更新就會走到這一步,可以看到,v-for 帶 key 的話會執行patchKeyChildren方法更新子節點,而不帶 key 會執行patchUnkeyedChildren方法更新子節點

所以我們只要弄清楚這兩個方法的差異,就能知道 v-for 帶不帶 key 的根本原因了!

話不多說,回到原始碼

相同型別的新舊 vnode 的子節點都是一組節點的時候,會根據有無 key 值分開處理:

ts const patchChildren: PatchChildrenFn = (    ... ) => { ...    if (patchFlag > 0) {      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {        // 處理全部有 key 和部分有 key 的情況        patchKeyedChildren(          ...       )        return     } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {        // 處理完全沒 key 的情況        patchUnkeyedChildren(          ...       )        return     }   } }

接著我們來仔細看看這兩個函式

patchKeyedChildren

有 key 的子節點陣列更新會呼叫patchKeyedChildren這個方法,這就是流傳甚廣的”vue 核心 diff 演算法“,主要是根據節點繫結的 key 值進行了以下五步處理:

image-20220904180054473.png

  1. 同步頭節點

    ts // 1. sync from start // (a b) c // (a b) d e while (i <= e1 && i <= e2) {  const n1 = c1[i]  const n2 = (c2[i] = optimized    ? cloneIfMounted(c2[i] as VNode)   : normalizeVNode(c2[i]))  if (isSameVNodeType(n1, n2)) {    patch(      n1,      n2,      container,      null,      parentComponent,      parentSuspense,      isSVG,      slotScopeIds,      optimized   ) } else {    break }  i++ }

  1. 同步尾節點

    ts // 2. sync from end // a (b c) // d e (b c) while (i <= e1 && i <= e2) {  const n1 = c1[e1]  const n2 = (c2[e2] = optimized     ? cloneIfMounted(c2[e2] as VNode)     : normalizeVNode(c2[e2]))  if (isSameVNodeType(n1, n2)) {    patch(      n1,      n2,      container,      null,      parentComponent,      parentSuspense,      isSVG,      slotScopeIds,      optimized   ) } else {    break }  e1--  e2-- }

  1. 新增新的節點

    ts // 3. common sequence + mount // (a b) // (a b) c // i = 2, e1 = 1, e2 = 2 // (a b) // c (a b) // i = 0, e1 = -1, e2 = 0 if (i > e1) {  if (i <= e2) {    const nextPos = e2 + 1    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor    while (i <= e2) {      patch(        null,       (c2[i] = optimized         ? cloneIfMounted(c2[i] as VNode)         : normalizeVNode(c2[i])),        container,        anchor,        parentComponent,        parentSuspense,        isSVG,        slotScopeIds,        optimized     )      i++   } } }

  1. 解除安裝多餘的節點

    ts // 4. common sequence + unmount // (a b) c // (a b) // i = 2, e1 = 2, e2 = 1 // a (b c) // (b c) // i = 0, e1 = 0, e2 = -1 else if (i > e2) {  while (i <= e1) {    unmount(c1[i], parentComponent, parentSuspense, true)    i++ } }

  1. 處理未知子序列節點

    此處程式碼篇幅過長,且不是本文重點,就放一小部分了,感興趣的可以自行搜尋相關文章或者等我以後有空再補

    ts // 5. unknown sequence // [i ... e1 + 1]: a b [c d e] f g // [i ... e2 + 1]: a b [e d c h] f g // i = 2, e1 = 4, e2 = 5 else {  const s1 = i // prev starting index  const s2 = i // next starting index ​  // 建立索引圖  const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()  for (i = s2; i <= e2; i++) {    const nextChild = (c2[i] = optimized                       ? cloneIfMounted(c2[i] as VNode)                       : normalizeVNode(c2[i]))    if (nextChild.key != null) {      if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {        warn(          `Duplicate keys found during update:`,          JSON.stringify(nextChild.key),          `Make sure keys are unique.`       )     }      keyToNewIndexMap.set(nextChild.key, i)   } }    // 更新和移除舊節點  ...  // 移動和掛載新節點  ...

可以看到,vue 對有 key 的元素更新下了這麼大的功夫去處理,目的是為了對沒有發生變化的節點進行復用。DOM 的頻繁建立和銷燬對效能不友好,所以通過 key 值複用 DOM 可以儘可能地減小這方面的效能開銷。

那麼,那些沒有 key 的節點陣列怎麼更新呢?

patchUnkeyedChildren

ts const patchUnkeyedChildren = (    c1: VNode[],    c2: VNodeArrayChildren,    container: RendererElement,    anchor: RendererNode | null,    parentComponent: ComponentInternalInstance | null,    parentSuspense: SuspenseBoundary | null,    isSVG: boolean,    slotScopeIds: string[] | null,    optimized: boolean ) => {    ... }

沒有 key 的子節點陣列更新會呼叫 patchUnkeyedChildren 方法,它的實現就簡單很多了:

總共只有兩步:給公共長度部分節點打補丁(patch)、根據新舊子節點陣列長度移除或掛載節點

  1. 公共長度部分節點打補丁

    首先獲取新、舊子節點陣列的長度和公共長度部分

    ts c1 = c1 || EMPTY_ARR c2 = c2 || EMPTY_ARR const oldLength = c1.length const newLength = c2.length const commonLength = Math.min(oldLength, newLength)

    接著遍歷共長部分,對共長部分的新子節點直接呼叫 patch 方法更新

    ts let i for (i = 0; i < commonLength; i++) {  const nextChild = (c2[i] = optimized    ? cloneIfMounted(c2[i] as VNode)   : normalizeVNode(c2[i]))  patch(    c1[i],    nextChild,    container,    null,    parentComponent,    parentSuspense,    isSVG,    slotScopeIds,    optimized )

    這裡就是文章開頭提到的就地更新,沒有對 DOM 節點直接進行建立和刪除,而是通過 patch 打補丁的方式對對應索引位置的新節點的一些屬性直接進行更新。

  2. 根據長度移除多餘的節點或者掛載新節點

    if (oldLength > newLength) {  // 舊子節點陣列更長,將多餘的節點全部解除安裝  unmountChildren(    c1,    parentComponent,    parentSuspense,    true,    false,    commonLength  // 起始索引 ) } else {  // 新子節點陣列更長,將剩餘部分全部掛載  mountChildren(    c2,    container,    anchor,    parentComponent,    parentSuspense,    isSVG,    slotScopeIds,    optimized,    commonLength // 起始索引 ) }

    注意:unmountChildrenmountChildren會傳入commonLength作為解除安裝/掛載節點的起始索引遍歷到節點尾部。

整體流程如下:

image-20220904182409461.png

相比較直接用新節點覆蓋舊節點來說,這種處理方式也屬於一種效能上的優化,同樣是減少了 DOM 的建立和銷燬,對相同索引位置的新舊節點”就地更新“,然後再處理剩餘節點。

對比

程式碼描述可能不是很直觀,所以就用圖片來展示吧:

假設我們要將舊子節點更新為如下的新子節點

image-20220905162301320.png

那麼兩種方式的更新方式分別是這樣的

image-20220905171200545.png

道理我都懂,所以這倆種更新方式究竟會帶來什麼影響?

舉個例子就明白啦

```

​ ```

有一個v-for生成的輸入框列表,先不繫結 key,點選刪除按鈕後會將索引為2的輸入框刪除

image-20220905170425440.png

我們將每個輸入框中輸入它們各自位置的索引,然後點選刪除試一試

2022-09-05 17.05.17.gif

image-20220905163630653.png

神奇吧,不用懷疑 splice 的用法出錯,這就是更新過程就地更新會帶來的”後果“:DOM 的上一次的狀態也被留在了原地

我們加上 key 再試試

```

 

```

2022-09-05 17.24.17.gif

效果就正常了。

所以我們可以得出,沒有 key 的更新過程,為了減少 dom 重複建立和銷燬的開銷,採用了就地更新的策略,但是這種策略會讓 dom 的狀態得以留存,就會出現以上在這種”更新不正確的“渲染效果,所以 vue 官方很貼心的提示了我們:預設模式(不加 key)只適用於列表渲染輸出的結果不依賴子元件狀態或者臨時 DOM 狀態 (例如表單輸入值) 的情況

總結

問:

v-for 遍歷列表為什麼要加 key?

答:

Vue 在處理更新同類型 vnode 的一組子節點的過程中,為了減少 DOM 頻繁建立和銷燬的效能開銷:

對沒有 key 的子節點陣列更新呼叫的是patchUnkeyedChildren這個方法,核心是就地更新的策略。它會通過對比新舊子節點陣列的長度,先以比較短的那部分長度為基準,將新子節點的那一部分直接 patch 上去。然後再判斷,如果是新子節點陣列的長度更長,就直接將新子節點陣列剩餘部分掛載(mount);如果是新子節點陣列更短,就把舊子節點多出來的那部分給解除安裝掉(unmount)。所以如果子節點是元件或者有狀態的 DOM 元素,原有的狀態會保留,就會出現渲染不正確的問題

有 key 的子節點更新是呼叫的patchKeyedChildren,這個函式就是大家熟悉的實現核心 diff 演算法的地方,大概流程就是同步頭部節點、同步尾部節點、處理新增和刪除的節點,最後用求解最長遞增子序列的方法區處理未知子序列。是為了最大程度實現對已有節點的複用,減少 DOM 操作的效能開銷,同時避免了就地更新帶來的子節點狀態錯誤的問題。

綜上,如果是用 v-for 去遍歷常量或者子節點是諸如純文字這類沒有”狀態“的節點,是可以使用不加 key 的寫法的。但是實際開發過程中更推薦統一加上 key,能夠實現更廣泛場景的同時,避免了可能發生的狀態更新錯誤,我們一般可以使用 ESlint 配置 key 為 v-for 的必需元素。