vue中關(guān)于v-for循環(huán)key值問(wèn)題的研究
介紹
關(guān)于key的作用,官方是這樣描述的
? key
的特殊屬性主要用在 Vue 的虛擬 DOM 算法,在新舊 nodes 對(duì)比時(shí)辨識(shí) VNodes。
如果不使用 key,Vue 會(huì)使用一種最大限度減少動(dòng)態(tài)元素并且盡可能的嘗試修復(fù)/再利用相同類型元素的算法。
使用 key,它會(huì)基于 key 的變化重新排列元素順序,并且會(huì)移除 key 不存在的元素。
有相同父元素的子元素必須有獨(dú)特的 key。重復(fù)的 key 會(huì)造成渲染錯(cuò)誤。
之前不知道在哪里看的文章,一直以為使用key是為了就地復(fù)用,但是從上面的描述中可以看到,實(shí)際上不用key,vue也會(huì)盡可能的嘗試使用復(fù)用修復(fù)策略,沒(méi)有設(shè)置key的時(shí)候,vnode的key值就是undefined,而在源碼patchVnode函數(shù)中有一個(gè)判斷函數(shù)sameVnode用于判斷是否是相同節(jié)點(diǎn)。
A.key(undefined )=== B.key(undefined)
也是判斷為相同節(jié)點(diǎn)的。
/* 判斷兩個(gè)VNode節(jié)點(diǎn)是否是同一個(gè)節(jié)點(diǎn),需要滿足以下條件 key相同 tag(當(dāng)前節(jié)點(diǎn)的標(biāo)簽名)相同 isComment(是否為注釋節(jié)點(diǎn))相同 是否data(當(dāng)前節(jié)點(diǎn)對(duì)應(yīng)的對(duì)象,包含了具體的一些數(shù)據(jù)信息,是一個(gè)VNodeData類型,可以參考VNodeData類型中的數(shù)據(jù)信息)都有定義 當(dāng)標(biāo)簽是<input>的時(shí)候,type必須相同 */ 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) ) }
由此也可以看出,如果要想不復(fù)用,可以通過(guò)給組件以不同的key值來(lái)實(shí)現(xiàn),用于強(qiáng)制替換元素/組件而不是重復(fù)使用它。
作用
既然key值不是用于就地復(fù)用的目的,那為什么要設(shè)置這個(gè)key值呢?
上面說(shuō)使用key值時(shí),提到了重新排列順序及刪除的問(wèn)題。
查看源碼中patch函數(shù)中對(duì)于key值得應(yīng)用,主要看updateChildren
中節(jié)點(diǎn)比較部分,在前面的新舊首尾互比中,設(shè)置key和不設(shè)置key的比對(duì)是一樣的,在下面這塊,設(shè)置key的作用就凸顯出來(lái)了
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { ... //新舊首尾互比之后 /* 生成一個(gè)key與舊VNode的key對(duì)應(yīng)的哈希表(只有第一次進(jìn)來(lái)undefined的時(shí)候會(huì)生成,也為后面檢測(cè)重復(fù)的key值做鋪墊) 比如childre是這樣的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 結(jié)果生成{key0: 0, key1: 1, key2: 2} */ if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) /*如果newStartVnode新的VNode節(jié)點(diǎn)存在key并且這個(gè)key在oldVnode中能找到則返回這個(gè)節(jié)點(diǎn)的idxInOld(即第幾個(gè)節(jié)點(diǎn),下標(biāo))*/ idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null if (isUndef(idxInOld)) { // New element /*newStartVnode沒(méi)有key或者是該key沒(méi)有在老節(jié)點(diǎn)中找到則創(chuàng)建一個(gè)新的節(jié)點(diǎn)*/ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { /*獲取同key的老節(jié)點(diǎn)*/ elmToMove = oldCh[idxInOld] /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !elmToMove) { /*如果elmToMove不存在說(shuō)明之前已經(jīng)有新節(jié)點(diǎn)放入過(guò)這個(gè)key的Dom中,提示可能存在重復(fù)的key,確保v-for的時(shí)候item有唯一的key值*/ warn( 'It seems there are duplicate keys that is causing an update error. ' + 'Make sure each v-for item has a unique key.' ) } if (sameVnode(elmToMove, newStartVnode)) { /*如果新VNode與得到的有相同key的節(jié)點(diǎn)是同一個(gè)VNode則進(jìn)行patchVnode*/ patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) /*因?yàn)橐呀?jīng)patchVnode進(jìn)去了,所以將這個(gè)老節(jié)點(diǎn)賦值undefined,之后如果還有新節(jié)點(diǎn)與該節(jié)點(diǎn)key相同可以檢測(cè)出來(lái)提示已有重復(fù)的key*/ oldCh[idxInOld] = undefined /*當(dāng)有標(biāo)識(shí)位canMove實(shí)可以直接插入oldStartVnode對(duì)應(yīng)的真實(shí)Dom節(jié)點(diǎn)前面*/ canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { // same key but different element. treat as new element /*當(dāng)新的VNode與找到的同樣key的VNode不是sameVNode的時(shí)候(比如說(shuō)tag不一樣或者是有不一樣type的input標(biāo)簽),創(chuàng)建一個(gè)新的節(jié)點(diǎn)*/ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } } ... }
從上面的源碼中可以看到,key值作為快速索引,用于查找當(dāng)前vnode在舊的vnode序列中的位置。
假設(shè):oldVnodes里有
[{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}];
新的vnodes有
[{xx: xx, key: 'key1'}, {xx: xx, key: 'key3'}, {xx: xx, key: ‘key2'}]
在循環(huán)遍歷新的vnodes時(shí),例如key1,在oldVnodes中找到了,但是數(shù)組索引位置(1)和當(dāng)前新的vnodes中的位置(0)不一樣,表示需要進(jìn)行節(jié)點(diǎn)移動(dòng),此時(shí)做的操作
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
是用舊的vnode隊(duì)列中key值對(duì)應(yīng)的節(jié)點(diǎn)來(lái)復(fù)用patch生成新的vnode,然后進(jìn)行節(jié)點(diǎn)移動(dòng),節(jié)點(diǎn)移動(dòng)后,會(huì)將oldVnodes中(1)位置清空,置為undefined。
再例如key3節(jié)點(diǎn),在oldVnodes中沒(méi)有,就意味著這是一個(gè)新的節(jié)點(diǎn),需要進(jìn)行新建和插入指定位置的操作。
這里需要注意,即便是sameVnode,也是要進(jìn)行patch操作的,而不是直接拿舊的節(jié)點(diǎn)來(lái)用。
key值在新舊節(jié)點(diǎn)在變化前后順序不一致的情況下,能夠快速的定位,從舊的節(jié)點(diǎn)隊(duì)列中找到具有相同key值得節(jié)點(diǎn)來(lái)復(fù)用渲染。
應(yīng)用場(chǎng)景
日常開(kāi)發(fā)中最常見(jiàn)的用例是結(jié)合 v-for
:,開(kāi)發(fā)過(guò)程中vue要求為循環(huán)生成的節(jié)點(diǎn)必須綁定key值
<ul> <li v-for="item in items" :key="item.id">...</li> </ul>
為何不建議用index做key
使用index作為key值,有可能造成錯(cuò)誤的渲染。簡(jiǎn)單的來(lái)說(shuō),假如有oldVnode集合[A, B, C],刪除第一個(gè)元素,剩下的vnode集合就是[B,C],當(dāng)循環(huán)patchVnode的時(shí)候,oldVnode中A的key是0,vnode中B的key也是0,vue在實(shí)際渲染的過(guò)程中就會(huì)復(fù)用A來(lái)渲染B
例如下面根據(jù)list循環(huán)生成三個(gè)子組件,以數(shù)組index作為key值,當(dāng)在刪除的時(shí)候可能會(huì)出現(xiàn)異常。
<div class="list"> <child v-for="(item, i) in list" :data="item" :key="i" @del="onDelChild" ></child> </div> <script> export default { data() { return { list: [ { text: "circle", id: 1 }, { text: "trangle", id: 2 }, { text: "square", id: 3 } ] }; }, methods: { onDelChild(item) { const idx = this.list.findIndex(it => it.id == item.id); this.list.splice(idx, 1); } } }; </script>
<template> <div> {{ data.id }} <input v-model="text" /> <button @click="onDel">delete</button> <span @click="onClick">{{ staticText }}</span> </div> </template> <script> export default { props: ["data"], data() { return { text: "hello", staticText: "click Me" }; }, methods: { onDel() { this.$emit("del", this.data); }, onClick() { this.staticText = "clicked"; } } }; </script>
先點(diǎn)擊第一條的click Me,改變文本內(nèi)容。
再點(diǎn)擊第一條的刪除按鈕,結(jié)果如下圖所示,并沒(méi)有如我們期待的那樣刪除第一條數(shù)據(jù),你會(huì)發(fā)現(xiàn)視圖的一部分確實(shí)正確更新了,如前面的id,但是后面的input內(nèi)容和文本內(nèi)容都沒(méi)有按預(yù)期的刪除第一條。
上面的異常情況中,很明顯的用oldVnode集合中的A復(fù)用渲染了vnode中的B,oldVnode中的B復(fù)用渲染了vnode中的C,然后刪除了oldVnode中的C。
注意:上面未正確更新的內(nèi)容,實(shí)際綁定的值是Child子組件內(nèi)部的自有屬性,如果將Child內(nèi)容改為一下,則可以得到預(yù)期效果
<div> {{ data.id }} <input v-model="data.text" /> <button @click="onDel">delete</button> </div>
初步的結(jié)論是,循環(huán)渲染子組件,并以index作為key值綁定時(shí),當(dāng)動(dòng)態(tài)改變父組件中的list集合,vue會(huì)按index索引來(lái)查找oldVnode中的子節(jié)點(diǎn)用以復(fù)用,且在進(jìn)行patch時(shí),只更新了那些由父組件傳遞過(guò)來(lái)的prop綁定視圖,子組件自身的實(shí)例屬性不會(huì)更新
那么,為什么會(huì)出現(xiàn)這種渲染錯(cuò)誤的情況?
首先,問(wèn)題的源頭肯定是節(jié)點(diǎn)復(fù)用,也就是我們上面key值復(fù)用其中的一步
if (sameVnode(elmToMove, newStartVnode)) { /*如果新VNode與得到的有相同key的節(jié)點(diǎn)是同一個(gè)VNode則進(jìn)行patchVnode*/ patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) /*因?yàn)橐呀?jīng)patchVnode進(jìn)去了,所以將這個(gè)老節(jié)點(diǎn)賦值undefined,之后如果還有新節(jié)點(diǎn)與該節(jié)點(diǎn)key相同可以檢測(cè)出來(lái)提示已有重復(fù)的key*/ oldCh[idxInOld] = undefined /*當(dāng)有標(biāo)識(shí)位canMove實(shí)可以直接插入oldStartVnode對(duì)應(yīng)的真實(shí)Dom節(jié)點(diǎn)前面*/ canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] }
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
在這里面用eleToMove復(fù)用渲染生成newStartVnode時(shí),更新渲染的dom出現(xiàn)了問(wèn)題。
下面我們來(lái)看看這個(gè)錯(cuò)誤是如何出現(xiàn)的
在進(jìn)入patchVnode之前,我們首先需要明確當(dāng)前elmToMove是什么,newStartVnode又是怎樣的。
elmToMove是從舊的渲染隊(duì)列中拿出來(lái)的,由此可以看出它是一個(gè)完成的已經(jīng)渲染過(guò)的數(shù)據(jù),其中包含著數(shù)據(jù)變更前,child子組件對(duì)應(yīng)的實(shí)例及生成的dom等數(shù)據(jù)。
而newStartVnode則不盡然,它只是一個(gè)占位vnode,具體何為占位vnode,可以詳細(xì)了解下之前學(xué)習(xí)過(guò)的vue源碼中create-component這一節(jié)。
對(duì)于<child></child>
這種自定義組件,在調(diào)用h函數(shù)生成vnode時(shí),會(huì)生成一個(gè)vue-component-${Ctor.cid}${name}
的占位節(jié)點(diǎn),具體可以看源碼中create-component.js中的createComponent
方法,這里只截取一部分,來(lái)簡(jiǎn)單說(shuō)明下占位節(jié)點(diǎn)也就是當(dāng)前newStartVnode有哪些內(nèi)容。
/*創(chuàng)建一個(gè)組件節(jié)點(diǎn),返回Vnode節(jié)點(diǎn)*/ export function createComponent ( Ctor: Class<Component> | Function | Object | void, data?: VNodeData, context: Component, children: ?Array<VNode>, tag?: string ): VNode | void { //..... // return a placeholder vnode /** 組件節(jié)點(diǎn),本質(zhì)上是一個(gè)占位節(jié)點(diǎn),在實(shí)際dom中并沒(méi)有 * 當(dāng)出現(xiàn) <HelloWorld></HelloWorld> 這種組件節(jié)點(diǎn)時(shí), * 會(huì)相應(yīng)生成一個(gè)tag為vue-component-xx-hellowrold的占位vnode * 占位vnode沒(méi)有children */ const name = Ctor.options.name || tag const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined/** 組件是沒(méi)有children的 */, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }/** componentOptions */ ) return vnode }
這里重點(diǎn)關(guān)注一下,占位vnode中componentOptions
,后面在盡心patch時(shí)會(huì)用到,data里面是一些鉤子函數(shù),用于組件節(jié)點(diǎn)實(shí)例的創(chuàng)建或者patch更新,詳細(xì)的這里不過(guò)多解釋。
所以,此時(shí)新的vnode其實(shí)還沒(méi)有創(chuàng)建實(shí)例,生成dom節(jié)點(diǎn),接下來(lái)看看patchVnode中如何為新的vnode生成實(shí)例和dom節(jié)點(diǎn)的
/*patch VNode節(jié)點(diǎn)*/ function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { ... const data = vnode.data if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { /*i = data.hook.prepatch,如果存在的話,見(jiàn)"./create-component componentVNodeHooks"。*/ // 根據(jù)vnode,更新oldVnode子組件實(shí)例對(duì)應(yīng)的相關(guān)屬性 i(oldVnode, vnode) } //這里復(fù)用,直接將oldVnode的dom賦給了vnode const elm = vnode.elm = oldVnode.elm const oldCh = oldVnode.children const ch = vnode.children if (isDef(data) && isPatchable(vnode)) { /*調(diào)用update回調(diào)以及update鉤子*/ for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } ... }
在上面的代碼中,導(dǎo)致了后續(xù)的渲染錯(cuò)誤。
首先,作為組件占位vnode,根據(jù)我們上面看到的create-component中Vnode的構(gòu)造函數(shù)中可以看出,vnode.data中是一堆hook函數(shù),用于組件vnode的實(shí)例創(chuàng)建和patch更新,這里調(diào)用了hook.prepatch方法,用oldVnode來(lái)patch生成vnode。然后直接將oldVnode的dom賦給了vnode
我們來(lái)看下這個(gè)prepatch都做了些啥
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) { //props相關(guān)的數(shù)據(jù)在componentOptions里 const options = vnode.componentOptions //實(shí)例覆蓋:將oldVnode的實(shí)例直接賦值給了vnode const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ) }
這里可以看到,在prepatch中,直接將oldVnode的實(shí)例直接賦值給了vnode,到這里,問(wèn)題就出現(xiàn)了。
vnode中那些實(shí)例相關(guān)的屬性(例如data中的數(shù)據(jù))就會(huì)丟失,轉(zhuǎn)而成為oldVnode中的實(shí)例屬性。
這里可能會(huì)有點(diǎn)繞,簡(jiǎn)單舉個(gè)例子
上面例子中用A節(jié)點(diǎn)復(fù)用生成B節(jié)點(diǎn)的時(shí)候,在prepatch的時(shí)候?qū)的實(shí)例直接賦值給了B,此時(shí)新節(jié)點(diǎn)隊(duì)列中的B對(duì)應(yīng)的實(shí)例屬性text就是A,渲染后會(huì)生成對(duì)應(yīng)的text:A。而oldVnode中B對(duì)應(yīng)的text: B,這就出現(xiàn)了之前我們所說(shuō)的渲染錯(cuò)誤。
那為什么最上面例子中組件節(jié)點(diǎn)中的{{ data.id }}
能正確渲染呢?
這里就要看下面這段代碼
updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children )
這里的options.propsData
用的是vnode.componentOptions中的propsData,而不是oldVnode,所以當(dāng)對(duì)應(yīng)的propsData有變化的時(shí)候,能夠正確的渲染。而覆蓋后的新舊實(shí)例屬性為同一實(shí)例,無(wú)法對(duì)比差異,所以會(huì)直接復(fù)用oldVnode的實(shí)例屬性dom。
所以,日常開(kāi)發(fā)中,如果需要用index作為key值時(shí),需要明確會(huì)不會(huì)造成這種渲染錯(cuò)誤,否則可能會(huì)出現(xiàn)意想不到的難題。
總結(jié)
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
在使用vue-cli創(chuàng)建vue項(xiàng)目,如何添加和刪除預(yù)置配置
這篇文章主要介紹了在使用vue-cli創(chuàng)建vue項(xiàng)目,如何添加和刪除預(yù)置配置問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10vue項(xiàng)目實(shí)例中用query傳參如何實(shí)現(xiàn)跳轉(zhuǎn)效果
這篇文章主要介紹了vue項(xiàng)目實(shí)例中用query傳參如何實(shí)現(xiàn)跳轉(zhuǎn)效果,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-10-10使用van-picker?動(dòng)態(tài)設(shè)置當(dāng)前選中項(xiàng)
這篇文章主要介紹了使用van-picker?動(dòng)態(tài)設(shè)置當(dāng)前選中項(xiàng)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-10-10Vue中禁止編輯的常見(jiàn)方法(以禁止編輯輸入框?yàn)槔?
在我們開(kāi)發(fā)項(xiàng)目的時(shí)候,有時(shí)候我們不希望用戶對(duì)我們的頁(yè)面進(jìn)行操作,尤其是輸入框之類的,這篇文章主要給大家介紹了Vue中禁止編輯的常見(jiàn)方法,文中介紹的方法主要以禁止編輯輸入框?yàn)槔?需要的朋友可以參考下2024-02-02Vue3學(xué)習(xí)之語(yǔ)法糖、箭頭函數(shù)、函數(shù)聲明詳解
在Vue3中箭頭函數(shù)被廣泛支持,尤其是在組合式API的上下文中,這篇文章主要給大家介紹了關(guān)于Vue3學(xué)習(xí)之語(yǔ)法糖、箭頭函數(shù)、函數(shù)聲明的相關(guān)資料,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2024-08-08Vue中ref、computed與reactive使用頻率現(xiàn)象分析(示例詳解)
這篇文章主要分析了Vue中的ref、computed和reactive三個(gè)響應(yīng)式API的使用頻率和優(yōu)勢(shì),ref適合處理簡(jiǎn)單數(shù)據(jù)類型的響應(yīng)式需求,感興趣的朋友跟隨小編一起看看吧2024-11-11Vue中常用的rules校驗(yàn)規(guī)則的實(shí)現(xiàn)
在vue開(kāi)發(fā)中,難免遇到各種表單校驗(yàn),本文主要介紹了Vue中常用的rules校驗(yàn)規(guī)則的實(shí)現(xiàn),具有一定的參考價(jià)值,感興趣的可以了解一下2023-10-10Vue對(duì)Element中的el-tag添加@click事件無(wú)效的解決
本文主要介紹了Vue對(duì)Element中的el-tag添加@click事件無(wú)效的解決,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-05-05