Vue 中的 key 是用來作什麼的?爲何不推薦使用 index 做爲 key?經常據說這樣的問題,本篇文章帶你從原理來一探究竟。前端
本文的結論對於性能的毀滅是針對列表子元素順序被改變、或者子元素被刪除的特殊狀況,提早說明清楚。vue
本篇已經收錄在 Github 倉庫,歡迎 Star:node
以這樣一個列表爲例:git
<ul>
<li>1</li>
<li>2</li>
</ul>
複製代碼
那麼它的 vnode
也就是虛擬 dom 節點大概是這樣的。github
{ 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__
過程。api
咱們直接進入 ul
這個 vnode
的 patch
過程。
對比新舊節點是不是相同類型的節點:
isSameNode
爲false的話,直接銷燬舊的 vnode
,渲染新的 vnode
。這也解釋了爲何 diff
是同層對比。
ul
,進入👈)。會調用src/core/vdom/patch.js
下的patchVNode
方法。
就直接調用瀏覽器的 dom api
把節點的直接替換掉文字內容就好。
那麼就要開始對子節點 children
進行對比了。(能夠類比 ul
中的 li
子元素)。
說明是新增 children,直接 addVnodes
添加新子節點。
說明是刪除 children,直接 removeVnodes
刪除舊子節點
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
的過程 ):
舊首節點和新首節點用 sameNode
對比。
舊尾節點和新尾節點用 sameNode
對比
舊首節點和新尾節點用 sameNode
對比
舊尾節點和新首節點用 sameNode
對比
若是以上邏輯都匹配不到,再把全部舊子節點的 key
作一個映射到舊節點下標的 key -> index
表,而後用新 vnode
的 key
去找出在舊節點中能夠複用的位置。
而後不停的把匹配到的指針向內部收縮,直到新舊節點有一端的指針相遇(說明這個端的節點都被patch過了)。
在指針相遇之後,還有兩種比較特殊的狀況:
有新節點須要加入。 若是更新完之後,oldStartIdx > oldEndIdx
,說明舊節點都被 patch
完了,可是有可能還有新的節點沒有被處理到。接着會去判斷是否要新增子節點。
有舊節點須要刪除。 若是新節點先patch完了,那麼此時會走 newStartIdx > newEndIdx
的邏輯,那麼就會去刪除多餘的舊子節點。
假設咱們有這樣的一段代碼:
<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的屬性或者類名、樣式、指令,那麼都會被全量的更新。
而這些全部重量級的操做(虛擬dom發明的其中一個目的不就是爲了減小真實dom的操做麼?),均可以經過直接複用 第三個vnode
來避免,是由於咱們偷懶寫了 index
做爲 key
,而致使全部的優化失效了。
另外,除了會致使性能損耗之外,在刪除子節點
的場景下還會形成更嚴重的錯誤,
假設咱們有這樣的一段代碼:
<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
的時候,只會判斷key
、 tag
、是否有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
致使的錯亂,它會把
原來的第一個節點text: 1
直接複用。原來的第二個節點text: 2
直接複用。text: 3
丟掉。至此爲止,咱們本應該把 text: 1
節點刪掉,而後text: 2
、text: 3
節點複用,就變成了錯誤的把 text: 3
節點給刪掉了。
<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
這個龐大的過程就結束了。
咱們收穫了什麼?
用組件惟一的 id
(通常由後端返回)做爲它的 key
,實在沒有的狀況下,能夠在獲取到列表的時候經過某種規則爲它們建立一個 key
,並保證這個 key
在組件整個生命週期中都保持穩定。
若是你的列表順序會改變,別用 index
做爲 key
,和沒寫基本上沒區別,由於無論你數組的順序怎麼顛倒,index 都是 0, 1, 2
這樣排列,致使 Vue 會複用錯誤的舊子節點,作不少額外的工做。列表順序不變也儘可能別用,可能會誤導新人。
千萬別用隨機數做爲 key
,否則舊節點會被所有刪掉,新節點從新建立,你的老闆會被你氣死。
這篇文章發佈之後,不少小夥伴提出了本身的建議和優化。可是也有不少人在評論區說,既然 index
只是在某些特定的場景下會出問題,那 列表順序保持不變
的狀況下仍是能夠接着用。這樣作有什麼問題呢?
團隊代碼規範,假設這樣一個場景吧,你這邊代碼裏所有寫的 :key="index"
,有一個新人入職了跟着寫,結果他的場景是刪除和亂序的,這種狀況你一個個講原理指正?這就是統一代碼規範和最佳實踐的做用啊。eslint
甚至也專門有一個 rule
叫作 react/no-array-index-key
,爲何要有這些約束和規範?若是社區總結了最佳實踐,爲何必定要去打破它?這都是值得思考的。 就像 ==
操做符,爲何要禁止?就是由於隱式轉換會出不少問題,你說你熟背隱式轉換全部原理,你能保證團隊全部小夥伴都熟背?何苦有更簡單的 ===
操做符能夠用。
說開發效率的問題,index
做爲 key
我在上面已經提到了好幾種會出問題的狀況了,仍是堅持要用,就由於簡單。那麼 TypeScript
也沒有火起來的必要嗎?它須要多寫不少代碼,「效率」 很低,爲何它火了?不是由於用 JavaScript
就必定會出現類型錯誤,而是由於用了 TypeScript
能夠更好的保證你代碼的穩定性。正如用了 id
做爲key,能夠比 index
更好的保證穩定性,更況且用 id
也不費事啊。徹底都不像 TypeScript
帶來的額外的語法成本。
所謂的列表順序穩定,這個穩定你真的能保證嗎?除了你前端寫死的永遠不變的一個列表,就假設你的列表沒有在頭部新增一項
(致使節點所有依次錯誤複用),在任意位置 刪除一項
(有時致使錯誤刪除)等這些會致使 patch
過程出現問題的操做。 就舉個很簡單的例子,你的「靜態」列表的順序是[1, 2, 3]
,數據庫裏忽然加入了一條新數據0
,那麼你認爲的不會變的列表的就變成了[0, 1, 2, 3]
。而後,1
節點就錯誤的和 0
節點進行 patchVnode
, 2
節點就錯誤的和 1
節點進行 patch
、致使本來只須要把新增的0
節點插入到頭部,而後分別對 1 -> 1
、2 -> 2
、3 -> 3
進行 patchVnode
便可(基本沒有變化),變成了毀滅的全量更新
。(若是子組件是個很重的組件呢?它的每一項都會經歷完整的 vm._update(vm._render())
)過程,由於 props
變了。
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。