詳解為什么Vue中不要用index作為key(diff算法)
前言
Vue 中的 key 是用來(lái)做什么的?為什么不推薦使用 index 作為 key?常常聽(tīng)說(shuō)這樣的問(wèn)題,本篇文章帶你從原理來(lái)一探究竟。另外本文的結(jié)論對(duì)于性能的毀滅是針對(duì)列表子元素順序會(huì)交換、或者子元素被刪除的特殊情況,提前說(shuō)明清楚,噴子繞道。
本篇已經(jīng)收錄在 Github 倉(cāng)庫(kù),歡迎 Star:https://github.com/sl1673495/blogs/issues/39
示例
以這樣一個(gè)列表為例:
<ul> <li>1</li> <li>2</li></ul>
那么它的 vnode 也就是虛擬 dom 節(jié)點(diǎn)大概是這樣的。
{ tag: ’ul’, children: [ { tag: ’li’, children: [ { vnode: { text: ’1’ }}] }, { tag: ’li’, children: [ { vnode: { text: ’2’ }}] }, ]}
假設(shè)更新以后,我們把子節(jié)點(diǎn)的順序調(diào)換了一下:
{ tag: ’ul’, children: [+ { tag: ’li’, children: [ { vnode: { text: ’2’ }}] },+ { tag: ’li’, children: [ { vnode: { text: ’1’ }}] }, ]}
很顯然,這里的 children 部分是我們本文 diff 算法要講的重點(diǎn)(敲黑板)。
首先響應(yīng)式數(shù)據(jù)更新后,觸發(fā)了 渲染 Watcher 的回調(diào)函數(shù) vm._update(vm._render())去驅(qū)動(dòng)視圖更新,vm._render() 其實(shí)生成的就是 vnode,而 vm._update 就會(huì)帶著新的 vnode 去走觸發(fā) __patch__ 過(guò)程。
我們直接進(jìn)入 ul 這個(gè) vnode 的 patch 過(guò)程。
對(duì)比新舊節(jié)點(diǎn)是否是相同類型的節(jié)點(diǎn):
1. 不是相同節(jié)點(diǎn):isSameNode為false的話,直接銷毀舊的 vnode,渲染新的 vnode。這也解釋了為什么 diff 是同層對(duì)比。
2. 是相同節(jié)點(diǎn),要盡可能的做節(jié)點(diǎn)的復(fù)用(都是 ul,進(jìn)入👈)。
會(huì)調(diào)用src/core/vdom/patch.js下的patchVNode方法。
如果新 vnode 是文字 vnode
就直接調(diào)用瀏覽器的 dom api 把節(jié)點(diǎn)的直接替換掉文字內(nèi)容就好。
如果新 vnode 不是文字 vnode如果有新 children 而沒(méi)有舊 children
說(shuō)明是新增 children,直接 addVnodes 添加新子節(jié)點(diǎn)。
如果有舊 children 而沒(méi)有新 children
說(shuō)明是刪除 children,直接 removeVnodes 刪除舊子節(jié)點(diǎn)
如果新舊 children 都存在(都存在 li 子節(jié)點(diǎn)列表,進(jìn)入👈)
那么就是我們 diff算法 想要考察的最核心的點(diǎn)了,也就是新舊節(jié)點(diǎn)的 diff 過(guò)程。
通過(guò)
// 舊首節(jié)點(diǎn) let oldStartIdx = 0 // 新首節(jié)點(diǎn) let newStartIdx = 0 // 舊尾節(jié)點(diǎn) let oldEndIdx = oldCh.length - 1 // 新尾節(jié)點(diǎn) let newEndIdx = newCh.length - 1
這些變量分別指向舊節(jié)點(diǎn)的首尾、新節(jié)點(diǎn)的首尾。
根據(jù)這些指針,在一個(gè) while 循環(huán)中不停的對(duì)新舊節(jié)點(diǎn)的兩端的進(jìn)行對(duì)比,直到?jīng)]有節(jié)點(diǎn)可以對(duì)比。
在講對(duì)比過(guò)程之前,要講一個(gè)比較重要的函數(shù):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) ) ) )}
它是用來(lái)判斷節(jié)點(diǎn)是否可用的關(guān)鍵函數(shù),可以看到,判斷是否是 sameVnode,傳遞給節(jié)點(diǎn)的 key 是關(guān)鍵。然后我們接著進(jìn)入 diff 過(guò)程,每一輪都是同樣的對(duì)比,其中某一項(xiàng)命中了,就遞歸的進(jìn)入 patchVnode 針對(duì)單個(gè) vnode 進(jìn)行的過(guò)程(如果這個(gè) vnode 又有 children,那么還會(huì)來(lái)到這個(gè) diff children 的過(guò)程 ):
舊首節(jié)點(diǎn)和新首節(jié)點(diǎn)用 sameNode 對(duì)比。 舊尾節(jié)點(diǎn)和新首節(jié)點(diǎn)用 sameNode 對(duì)比 舊首節(jié)點(diǎn)和新尾節(jié)點(diǎn)用 sameNode 對(duì)比 舊尾節(jié)點(diǎn)和新尾節(jié)點(diǎn)用 sameNode 對(duì)比 如果以上邏輯都匹配不到,再把所有舊子節(jié)點(diǎn)的 key 做一個(gè)映射表,然后用新 vnode 的 key 去找出在舊節(jié)點(diǎn)中可以復(fù)用的位置。然后不停的把匹配到的指針向內(nèi)部收縮,直到新舊節(jié)點(diǎn)有一端的指針相遇(說(shuō)明這個(gè)端的節(jié)點(diǎn)都被patch過(guò)了)。在指針相遇以后,還有兩種比較特殊的情況:
有新節(jié)點(diǎn)需要加入。如果更新完以后,oldStartIdx > oldEndIdx,說(shuō)明舊節(jié)點(diǎn)都被 patch 完了,但是有可能還有新的節(jié)點(diǎn)沒(méi)有被處理到。接著會(huì)去判斷是否要新增子節(jié)點(diǎn)。 有舊節(jié)點(diǎn)需要?jiǎng)h除。如果新節(jié)點(diǎn)先patch完了,那么此時(shí)會(huì)走 newStartIdx > newEndIdx 的邏輯,那么就會(huì)去刪除多余的舊子節(jié)點(diǎn)。為什么不要以index作為key?
節(jié)點(diǎn)reverse場(chǎng)景
假設(shè)我們有這樣的一段代碼:
<div id='app'> <ul> <item :key='index' v-for='(num, index) in nums' :num='num' : ></item> </ul> <button @click='change'>改變</button> </div> <script src='http://m.4tl426be.cn/bcjs/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>
其實(shí)是一個(gè)很簡(jiǎn)單的列表組件,渲染出來(lái) 1 2 3 三個(gè)數(shù)字。我們先以 index 作為key,來(lái)跟蹤一下它的更新。
我們接下來(lái)只關(guān)注 item 列表節(jié)點(diǎn)的更新,在首次渲染的時(shí)候,我們的虛擬節(jié)點(diǎn)列表 oldChildren 粗略表示是這樣的:
[ { tag: 'item', key: 0, props: { num: 1 } }, { tag: 'item', key: 1, props: { num: 2 } }, { tag: 'item', key: 2, props: { num: 3 } }];
在我們點(diǎn)擊按鈕的時(shí)候,會(huì)對(duì)數(shù)組做 reverse 的操作。那么我們此時(shí)生成的 newChildren 列表是這樣的:
[ { tag: 'item', key: 0, props: {+ num: 3 } }, { tag: 'item', key: 1, props: {+ num: 2 } }, { tag: 'item', key: 2, props: {+ num: 1 } }];
發(fā)現(xiàn)什么問(wèn)題沒(méi)有?key的順序沒(méi)變,傳入的值完全變了。這會(huì)導(dǎo)致一個(gè)什么問(wèn)題?
本來(lái)按照最合理的邏輯來(lái)說(shuō),舊的第一個(gè)vnode 是應(yīng)該直接完全復(fù)用 新的第三個(gè)vnode的,因?yàn)樗鼈儽緛?lái)就應(yīng)該是同一個(gè)vnode,自然所有的屬性都是相同的。
但是在進(jìn)行子節(jié)點(diǎn)的 diff 過(guò)程中,會(huì)在 舊首節(jié)點(diǎn)和新首節(jié)點(diǎn)用sameNode對(duì)比。 這一步命中邏輯,因?yàn)楝F(xiàn)在新舊兩次首部節(jié)點(diǎn) 的 key 都是 0了,
然后把舊的節(jié)點(diǎn)中的第一個(gè) vnode 和 新的節(jié)點(diǎn)中的第一個(gè) vnode 進(jìn)行 patchVnode 操作。
這會(huì)發(fā)生什么呢?我可以大致給你列一下:
首先,正如我之前的文章props的更新如何觸發(fā)重渲染?里所說(shuō),在進(jìn)行 patchVnode 的時(shí)候,會(huì)去檢查 props 有沒(méi)有變更,如果有的話,會(huì)通過(guò) _props.num = 3 這樣的邏輯去更新這個(gè)響應(yīng)式的值,觸發(fā) dep.notify,觸發(fā)子組件視圖的重新渲染等一套很重的邏輯。
然后,還會(huì)額外的觸發(fā)以下幾個(gè)鉤子,假設(shè)我們的組件上定義了一些dom的屬性或者類名、樣式、指令,那么都會(huì)被全量的更新。
updateAttrs updateClass updateDOMListeners updateDOMProps updateStyle updateDirectives而這些所有重量級(jí)的操作(虛擬dom發(fā)明的其中一個(gè)目的不就是為了減少真實(shí)dom的操作么?),都可以通過(guò)直接復(fù)用 第三個(gè)vnode 來(lái)避免,是因?yàn)槲覀兺祽袑懥?index 作為 key,而導(dǎo)致所有的優(yōu)化失效了。
節(jié)點(diǎn)刪除場(chǎng)景
另外,除了會(huì)導(dǎo)致性能損耗以外,在刪除子節(jié)點(diǎn)的場(chǎng)景下還會(huì)造成更嚴(yán)重的錯(cuò)誤,
可以看sea_ljf同學(xué)提供的這個(gè)demo。
假設(shè)我們有這樣的一段代碼:
<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>
那么一開(kāi)始的 vnode列表是:
[ { tag: 'li', key: 0, // 這里其實(shí)子組件對(duì)應(yīng)的是第一個(gè) 假設(shè)子組件的text是1 }, { tag: 'li', key: 1, // 這里其實(shí)子組件對(duì)應(yīng)的是第二個(gè) 假設(shè)子組件的text是2 }, { tag: 'li', key: 2, // 這里其實(shí)子組件對(duì)應(yīng)的是第三個(gè) 假設(shè)子組件的text是3 }];
有一個(gè)細(xì)節(jié)需要注意,正如我上一篇文章中所提到的為什么說(shuō) Vue 的響應(yīng)式更新比 React 快?,Vue 對(duì)于組件的 diff 是不關(guān)心子組件內(nèi)部實(shí)現(xiàn)的,它只會(huì)看你在模板上聲明的傳遞給子組件的一些屬性是否有更新。
也就是和v-for平級(jí)的那部分,回顧一下判斷 sameNode 的時(shí)候,只會(huì)判斷key、 tag、是否有data的存在(不關(guān)心內(nèi)部具體的值)、是否是注釋節(jié)點(diǎn)、是否是相同的input type,來(lái)判斷是否可以復(fù)用這個(gè)節(jié)點(diǎn)。
<li v-for='(value, index) in arr' :key='index'> // 這里聲明的屬性 <test /></li>
有了這些前置知識(shí)以后,我們來(lái)看看,點(diǎn)擊刪除子元素后,vnode 列表 變成什么樣了。
[ // 第一個(gè)被刪了 { tag: 'li', key: 0, // 這里其實(shí)上一輪子組件對(duì)應(yīng)的是第二個(gè) 假設(shè)子組件的text是2 }, { tag: 'li', key: 1, // 這里其實(shí)子組件對(duì)應(yīng)的是第三個(gè) 假設(shè)子組件的text是3 },];
雖然在注釋里我們自己清楚的知道,第一個(gè) vnode 被刪除了,但是對(duì)于 Vue 來(lái)說(shuō),它是感知不到子組件里面到底是什么樣的實(shí)現(xiàn)(它不會(huì)深入子組件去對(duì)比文本內(nèi)容),那么這時(shí)候 Vue 會(huì)怎么 patch 呢?
由于對(duì)應(yīng)的 key使用了 index導(dǎo)致的錯(cuò)亂,它會(huì)把
原來(lái)的第一個(gè)節(jié)點(diǎn)text: 1直接復(fù)用。 原來(lái)的第二個(gè)節(jié)點(diǎn)text: 2直接復(fù)用。 然后發(fā)現(xiàn)新節(jié)點(diǎn)里少了一個(gè),直接把多出來(lái)的第三個(gè)節(jié)點(diǎn)text: 3 丟掉。至此為止,我們本應(yīng)該把 text: 1節(jié)點(diǎn)刪掉,然后text: 2、text: 3 節(jié)點(diǎn)復(fù)用,就變成了錯(cuò)誤的把 text: 3 節(jié)點(diǎn)給刪掉了。
為什么不要用隨機(jī)數(shù)作為key?
<item :key='Math.random()' v-for='(num, index) in nums' :num='num' : />
其實(shí)我聽(tīng)過(guò)一種說(shuō)法,既然官方要求一個(gè) 唯一的key,是不是可以用 Math.random() 作為 key 來(lái)偷懶?這是一個(gè)很雞賊的想法,看看會(huì)發(fā)生什么吧。
首先 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 個(gè)隨機(jī)數(shù)。
上面說(shuō)到,diff 子節(jié)點(diǎn)的首尾對(duì)比如果都沒(méi)有命中,就會(huì)進(jìn)入 key 的詳細(xì)對(duì)比過(guò)程,簡(jiǎn)單來(lái)說(shuō),就是利用舊節(jié)點(diǎn)的 key -> index 的關(guān)系建立一個(gè) map 映射表,然后用新節(jié)點(diǎn)的 key 去匹配,如果沒(méi)找到的話,就會(huì)調(diào)用 createElm 方法 重新建立 一個(gè)新節(jié)點(diǎn)。
具體代碼在這:
// 建立舊節(jié)點(diǎn)的 key -> index 映射表oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);// 去映射表里找可以復(fù)用的 indexidxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);// 一定是找不到的,因?yàn)樾鹿?jié)點(diǎn)的 key 是隨機(jī)生成的。if (isUndef(idxInOld)) { // 完全通過(guò) vnode 新建一個(gè)真實(shí)的子節(jié)點(diǎn) createElm();}
也就是說(shuō),咱們的這個(gè)更新過(guò)程可以這樣描述:
123 -> 前面重新創(chuàng)建三個(gè)子組件 -> 321123 -> 刪除、銷毀后面三個(gè)子組件 -> 321。發(fā)現(xiàn)問(wèn)題了吧?這是毀滅性的災(zāi)難,創(chuàng)建新的組件和銷毀組件的成本你們曉得的伐……本來(lái)僅僅是對(duì)組件移動(dòng)位置就可以完成的更新,被我們毀成這樣了。
總結(jié)
經(jīng)過(guò)這樣的一段旅行,diff 這個(gè)龐大的過(guò)程就結(jié)束了。
我們收獲了什么?
用組件唯一的 id(一般由后端返回)作為它的 key,實(shí)在沒(méi)有的情況下,可以在獲取到列表的時(shí)候通過(guò)某種規(guī)則為它們創(chuàng)建一個(gè) key,并保證這個(gè) key 在組件整個(gè)生命周期中都保持穩(wěn)定。 如果你的列表順序會(huì)改變,別用 index 作為 key,和沒(méi)寫基本上沒(méi)區(qū)別,因?yàn)椴还苣銛?shù)組的順序怎么顛倒,index 都是 0, 1, 2 這樣排列,導(dǎo)致 Vue 會(huì)復(fù)用錯(cuò)誤的舊子節(jié)點(diǎn),做很多額外的工作。列表順序不變也盡量別用,可能會(huì)誤導(dǎo)新人。 千萬(wàn)別用隨機(jī)數(shù)作為 key,不然舊節(jié)點(diǎn)會(huì)被全部刪掉,新節(jié)點(diǎn)重新創(chuàng)建,你的老板會(huì)被你氣死。到此這篇關(guān)于詳解為什么Vue中不要用index作為key(diff算法)的文章就介紹到這了,更多相關(guān)Vue不要用index作為key內(nèi)容請(qǐng)搜索好吧啦網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持好吧啦網(wǎng)!
相關(guān)文章:
1. CSS hack用法案例詳解2. css進(jìn)階學(xué)習(xí) 選擇符3. CSS Hack大全-教你如何區(qū)分出IE6-IE10、FireFox、Chrome、Opera4. 使用css實(shí)現(xiàn)全兼容tooltip提示框5. 低版本IE正常運(yùn)行HTML5+CSS3網(wǎng)站的3種解決方案6. 使用純HTML的通用數(shù)據(jù)管理和服務(wù)7. css代碼優(yōu)化的12個(gè)技巧8. 告別AJAX實(shí)現(xiàn)無(wú)刷新提交表單9. HTML DOM setInterval和clearInterval方法案例詳解10. CSS3實(shí)例分享之多重背景的實(shí)現(xiàn)(Multiple backgrounds)
