為什麼 Vue 中不要用 index 作為 key?(diff 演算法詳解)

晨曦時夢見兮發表於2020-04-03

前言

Vue 中的 key 是用來做什麼的?為什麼不推薦使用 index 作為 key?常常聽說這樣的問題,本篇文章帶你從原理來一探究竟。

本文的結論對於效能的毀滅是針對列表子元素順序被改變、或者子元素被刪除的特殊情況,提前說明清楚。

本篇已經收錄在 Github 倉庫,歡迎 Star:

github.com/sl1673495/b…

示例

以這樣一個列表為例:

<ul>
  <li>1</li>
  <li>2</li>
</ul>
複製程式碼

那麼它的 vnode 也就是虛擬 dom 節點大概是這樣的。

{
  tag: 'ul',
  children: [
    { tag: 'li', children: [ { vnode: { text: '1' }}]  },
    { tag: 'li', children: [ { vnode: { text: '2' }}]  },
  ]
}
複製程式碼

假設更新以後,我們把子節點的順序調換了一下:

{
  tag: 'ul',
  children: [
+   { tag: 'li', children: [ { vnode: { text: '2' }}]  },
+   { tag: 'li', children: [ { vnode: { text: '1' }}]  },
  ]
}
複製程式碼

很顯然,這裡的 children 部分是我們本文 diff 演算法要講的重點(敲黑板)。

首先響應式資料更新後,觸發了 渲染 Watcher 的回撥函式 vm._update(vm._render())去驅動檢視更新,

vm._render() 其實生成的就是 vnode,而 vm._update 就會帶著新的 vnode 去走觸發 __patch__ 過程。

我們直接進入 ul 這個 vnodepatch 過程。

對比新舊節點是否是相同型別的節點:

1. 不是相同節點:

isSameNode為false的話,直接銷燬舊的 vnode,渲染新的 vnode。這也解釋了為什麼 diff 是同層對比。

2. 是相同節點,要儘可能的做節點的複用(都是 ul,進入?)。

會呼叫src/core/vdom/patch.js下的patchVNode方法。

如果新 vnode 是文字 vnode

就直接呼叫瀏覽器的 dom api 把節點的直接替換掉文字內容就好。

如果新 vnode 不是文字 vnode

那麼就要開始對子節點 children 進行對比了。(可以類比 ul 中的 li 子元素)。

如果有新 children 而沒有舊 children

說明是新增 children,直接 addVnodes 新增新子節點。

如果有舊 children 而沒有新 children

說明是刪除 children,直接 removeVnodes 刪除舊子節點

如果新舊 children 都存在(都存在 li 子節點列表,進入?)

那麼就是我們 diff演算法 想要考察的最核心的點了,也就是新舊節點的 diff 過程。

可以開啟原始碼倉庫裡大致看下這個函式,接下來我會逐步講解。

updateChildren

通過

  // 舊首節點
  let oldStartIdx = 0
  // 新首節點
  let newStartIdx = 0
  // 舊尾節點
  let oldEndIdx = oldCh.length - 1
  // 新尾節點
  let newEndIdx = newCh.length - 1
複製程式碼

這些變數分別指向舊節點的首尾新節點的首尾

根據這些指標,在一個 while 迴圈中不停的對新舊節點的兩端的進行對比,然後把兩端的指標向不斷內部收縮,直到沒有節點可以對比。

在講對比過程之前,要講一個比較重要的函式: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)
      )
    )
  )
}
複製程式碼

它是用來判斷節點是否可用的關鍵函式,可以看到,判斷是否是 sameVnode,傳遞給節點的 key 是關鍵。

然後我們接著進入 diff 過程,每一輪都是同樣的對比,其中某一項命中了,就遞迴的進入 patchVnode 針對單個 vnode 進行的過程(如果這個 vnode 又有 children,那麼還會來到這個 diff children 的過程 ):

  1. 舊首節點和新首節點用 sameNode 對比。

  2. 舊尾節點和新首節點用 sameNode 對比

  3. 舊首節點和新尾節點用 sameNode 對比

  4. 舊尾節點和新尾節點用 sameNode 對比

  5. 如果以上邏輯都匹配不到,再把所有舊子節點的 key 做一個對映表,然後用新 vnodekey 去找出在舊節點中可以複用的位置。

然後不停的把匹配到的指標向內部收縮,直到新舊節點有一端的指標相遇(說明這個端的節點都被patch過了)。

在指標相遇以後,還有兩種比較特殊的情況:

  1. 有新節點需要加入。 如果更新完以後,oldStartIdx > oldEndIdx,說明舊節點都被 patch 完了,但是有可能還有新的節點沒有被處理到。接著會去判斷是否要新增子節點。

  2. 有舊節點需要刪除。 如果新節點先patch完了,那麼此時會走 newStartIdx > newEndIdx 的邏輯,那麼就會去刪除多餘的舊子節點。

為什麼不要以index作為key?

節點reverse場景

假設我們有這樣的一段程式碼:

    <div id="app">
      <ul>
        <item
          :key="index"
          v-for="(num, index) in nums"
          :num="num"
          :class="`item${num}`"
        ></item>
      </ul>
      <button @click="change">改變</button>
    </div>
    <script src="./vue.js"></script>
    <script>
      var vm = new Vue({
        name: "parent",
        el: "#app",
        data: {
          nums: [1, 2, 3]
        },
        methods: {
          change() {
            this.nums.reverse();
          }
        },
        components: {
          item: {
            props: ["num"],
            template: `
                    <div>
                       {{num}}
                    </div>
                `,
            name: "child"
          }
        }
      });
    </script>
複製程式碼

其實是一個很簡單的列表元件,渲染出來 1 2 3 三個數字。我們先以 index 作為key,來跟蹤一下它的更新。

我們接下來只關注 item 列表節點的更新,在首次渲染的時候,我們的虛擬節點列表 oldChildren 粗略表示是這樣的:

[
  {
    tag: "item",
    key: 0,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
      num: 3
    }
  }
];
複製程式碼

在我們點選按鈕的時候,會對陣列做 reverse 的操作。那麼我們此時生成的 newChildren 列表是這樣的:

[
  {
    tag: "item",
    key: 0,
    props: {
+     num: 3
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
+     num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
+     num: 1
    }
  }
];
複製程式碼

發現什麼問題沒有?key的順序沒變,傳入的值完全變了。這會導致一個什麼問題?

本來按照最合理的邏輯來說,舊的第一個vnode 是應該直接完全複用 新的第三個vnode的,因為它們本來就應該是同一個vnode,自然所有的屬性都是相同的。

但是在進行子節點的 diff 過程中,會在 舊首節點和新首節點用sameNode對比。 這一步命中邏輯,因為現在新舊兩次首部節點key 都是 0了,

然後把舊的節點中的第一個 vnode 和 新的節點中的第一個 vnode 進行 patchVnode 操作。

這會發生什麼呢?我可以大致給你列一下: 首先,正如我之前的文章props的更新如何觸發重渲染?裡所說,在進行 patchVnode 的時候,會去檢查 props 有沒有變更,如果有的話,會通過 _props.num = 3 這樣的邏輯去更新這個響應式的值,觸發 dep.notify,觸發子元件檢視的重新渲染等一套很重的邏輯。

然後,還會額外的觸發以下幾個鉤子,假設我們的元件上定義了一些dom的屬性或者類名、樣式、指令,那麼都會被全量的更新。

  1. updateAttrs
  2. updateClass
  3. updateDOMListeners
  4. updateDOMProps
  5. updateStyle
  6. updateDirectives

而這些所有重量級的操作(虛擬dom發明的其中一個目的不就是為了減少真實dom的操作麼?),都可以通過直接複用 第三個vnode 來避免,是因為我們偷懶寫了 index 作為 key,而導致所有的優化失效了。

節點刪除場景

另外,除了會導致效能損耗以外,在刪除子節點的場景下還會造成更嚴重的錯誤,

可以看sea_ljf同學提供的這個demo

假設我們有這樣的一段程式碼:

<body>
  <div id="app">
    <ul>
      <li v-for="(value, index) in arr" :key="index">
        <test />
      </li>
    </ul>
    <button @click="handleDelete">delete</button>
  </div>
  </div>
</body>
<script>
  new Vue({
    name: "App",
    el: '#app',
    data() {
      return {
        arr: [1, 2, 3]
      };
    },
    methods: {
      handleDelete() {
        this.arr.splice(0, 1);
      }
    },
    components: {
      test: {
        template: "<li>{{Math.random()}}</li>"
      }
    }
  })
</script>
複製程式碼

那麼一開始的 vnode列表是:

[
  {
    tag: "li",
    key: 0,
    // 這裡其實子元件對應的是第一個 假設子元件的text是1
  },
  {
    tag: "li",
    key: 1,
    // 這裡其實子元件對應的是第二個 假設子元件的text是2
  },
  {
    tag: "li",
    key: 2,
    // 這裡其實子元件對應的是第三個 假設子元件的text是3
  }
];
複製程式碼

有一個細節需要注意,正如我上一篇文章中所提到的為什麼說 Vue 的響應式更新比 React 快?,Vue 對於元件的 diff 是不關心子元件內部實現的,它只會看你在模板上宣告的傳遞給子元件的一些屬性是否有更新。

也就是和v-for平級的那部分,回顧一下判斷 sameNode 的時候,只會判斷keytag是否有data的存在(不關心內部具體的值)是否是註釋節點是否是相同的input type,來判斷是否可以複用這個節點。

<li v-for="(value, index) in arr" :key="index"> // 這裡宣告的屬性
  <test />
</li>
複製程式碼

有了這些前置知識以後,我們來看看,點選刪除子元素後,vnode 列表 變成什麼樣了。

[
  // 第一個被刪了
  {
    tag: "li",
    key: 0,
    // 這裡其實上一輪子元件對應的是第二個 假設子元件的text是2
  },
  {
    tag: "li",
    key: 1,
    // 這裡其實子元件對應的是第三個 假設子元件的text是3
  },
];
複製程式碼

雖然在註釋裡我們自己清楚的知道,第一個 vnode 被刪除了,但是對於 Vue 來說,它是感知不到子元件裡面到底是什麼樣的實現(它不會深入子元件去對比文字內容),那麼這時候 Vue 會怎麼 patch 呢?

由於對應的 key使用了 index導致的錯亂,它會把

  1. 原來的第一個節點text: 1直接複用。
  2. 原來的第二個節點text: 2直接複用。
  3. 然後發現新節點裡少了一個,直接把多出來的第三個節點text: 3 丟掉。

至此為止,我們本應該把 text: 1節點刪掉,然後text: 2text: 3 節點複用,就變成了錯誤的把 text: 3 節點給刪掉了。

為什麼不要用隨機數作為key?

<item
  :key="Math.random()"
  v-for="(num, index) in nums"
  :num="num"
  :class="`item${num}`"
/>
複製程式碼

其實我聽過一種說法,既然官方要求一個 唯一的key,是不是可以用 Math.random() 作為 key 來偷懶?這是一個很雞賊的想法,看看會發生什麼吧。

首先 oldVnode 是這樣的:

[
  {
    tag: "item",
    key: 0.6330715699108844,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 0.25104533240710514,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 0.4114769152411637,
    props: {
      num: 3
    }
  }
];
複製程式碼

更新以後是:

[
  {
    tag: "item",
+   key: 0.11046018699748683,
    props: {
+     num: 3
    }
  },
  {
    tag: "item",
+   key: 0.8549799545696619,
    props: {
+     num: 2
    }
  },
  {
    tag: "item",
+   key: 0.18674467938937478,
    props: {
+     num: 1
    }
  }
];

複製程式碼

可以看到,key 變成了完全全新的 3 個隨機數。

上面說到,diff 子節點的首尾對比如果都沒有命中,就會進入 key 的詳細對比過程,簡單來說,就是利用舊節點的 key -> index 的關係建立一個 map 對映表,然後用新節點的 key 去匹配,如果沒找到的話,就會呼叫 createElm 方法 重新建立 一個新節點。

具體程式碼在這:

// 建立舊節點的 key -> index 對映表
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);

// 去對映表裡找可以複用的 index
idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 一定是找不到的,因為新節點的 key 是隨機生成的。
if (isUndef(idxInOld)) {
  // 完全通過 vnode 新建一個真實的子節點
  createElm();
}
複製程式碼

也就是說,我們們的這個更新過程可以這樣描述: 123 -> 前面重新建立三個子元件 -> 321123 -> 刪除、銷燬後面三個子元件 -> 321

發現問題了吧?這是毀滅性的災難,建立新的元件和銷燬元件的成本你們曉得的伐……本來僅僅是對元件移動位置就可以完成的更新,被我們毀成這樣了。

總結

經過這樣的一段旅行,diff 這個龐大的過程就結束了。

我們收穫了什麼?

  1. 用元件唯一的 id(一般由後端返回)作為它的 key,實在沒有的情況下,可以在獲取到列表的時候通過某種規則為它們建立一個 key,並保證這個 key 在元件整個生命週期中都保持穩定。

  2. 如果你的列表順序會改變,別用 index 作為 key,和沒寫基本上沒區別,因為不管你陣列的順序怎麼顛倒,index 都是 0, 1, 2 這樣排列,導致 Vue 會複用錯誤的舊子節點,做很多額外的工作。列表順序不變也儘量別用,可能會誤導新人。

  3. 千萬別用隨機數作為 key,不然舊節點會被全部刪掉,新節點重新建立,你的老闆會被你氣死。

反駁標題之前,請先看這段

這篇文章釋出以後,很多小夥伴提出了自己的建議和優化。但是也有很多人在評論區說,既然 index 只是在某些特定的場景下會出問題,那 列表順序保持不變 的情況下還是可以接著用。這樣做有什麼問題呢?

  1. 團隊程式碼規範,假設這樣一個場景吧,你這邊程式碼裡全部寫的 :key="index",有一個新人入職了跟著寫,結果他的場景是刪除和亂序的,這種情況你一個個講原理指正?這就是統一程式碼規範和最佳實踐的作用啊。eslint 甚至也專門有一個 rule 叫做 react/no-array-index-key,為什麼要有這些約束和規範?如果社群總結了最佳實踐,為什麼一定要去打破它?這都是值得思考的。 就像 == 操作符,為什麼要禁止?就是因為隱式轉換會出很多問題,你說你熟背隱式轉換所有原理,你能保證團隊所有小夥伴都熟背?何苦有更簡單的 === 操作符可以用。

  2. 說開發效率的問題,index 作為 key 我在上面已經提到了好幾種會出問題的情況了,還是堅持要用,就因為簡單。那麼 TypeScript 也沒有火起來的必要嗎?它需要多寫很多程式碼,“效率” 很低,為什麼它火了?不是因為用 JavaScript 就一定會出現型別錯誤,而是因為用了 TypeScript 可以更好的保證你程式碼的穩定性。正如用了 id 作為key,可以比 index 更好的保證穩定性,更何況用 id 也不費事啊。完全都不像 TypeScript 帶來的額外的語法成本。

  3. 所謂的列表順序穩定,這個穩定你真的能保證嗎?除了你前端寫死的永遠不變的一個列表,就假設你的列表沒有在頭部新增一項(導致節點全部依次錯誤複用),在任意位置 刪除一項(有時導致錯誤刪除)等這些會導致 patch 過程出現問題的操作。 就舉個很簡單的例子,你的“靜態”列表的順序是[1, 2, 3],資料庫裡突然加入了一條新資料0,那麼你認為的不會變的列表的就變成了[0, 1, 2, 3]。然後,1 節點就錯誤的和 0節點進行 patchVnode2 節點就錯誤的和 1 節點進行 patch、導致原本只需要把新增的0節點插入到頭部,然後分別對 1 -> 12 -> 23 -> 3 進行 patchVnode即可(基本沒有變化),變成了毀滅的全量更新。(如果子元件是個很重的元件呢?它的每一項都會經歷完整的 vm._update(vm._render()))過程,因為 props 變了。

❤️感謝大家

1.如果本文對你有幫助,就點個贊支援下吧,你的「贊」是我創作的動力。

2.關注公眾號「前端從進階到入院」即可加我好友,我拉你進「前端進階交流群」,大家一起共同交流和進步。

為什麼 Vue 中不要用 index 作為 key?(diff 演算法詳解)

相關文章