爲何 Vue 中不要用 index 做爲 key?(diff 算法詳解)

前言

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

另外本文的結論對於性能的毀滅是針對列表子元素順序會交換的狀況,提早說明清楚,噴子繞道。vue

示例

以這樣一個列表爲例:java

<ul>
  <li>1</li>
  <li>2</li>
</ul>
複製代碼

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

{
  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())去驅動視圖更新,api

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

咱們直接進入 ul 這個 vnodepatch 過程。瀏覽器

對比新舊節點是不是相同類型的節點:bash

1. 不是相同節點:

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

2. 是相同節點,要儘量的作節點的複用(都是 ul,進入👈)。

會調用src/core/vdom/patch.js下的patchVNode方法。

若是新 vnode 是文字 vnode

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

若是新 vnode 不是文字 vnode

若是有新 children 而沒有舊 children

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

若是有舊 children 而沒有新 children

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

若是新舊 children 都存在(都存在 li 子節點列表,進入👈)

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

經過

// 舊首節點
  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,否則舊節點會被所有刪掉,新節點從新建立,你的老闆會被你氣死。

相關文章
相關標籤/搜索