vue中關(guān)于v-for循環(huán)key值問題的研究
介紹
關(guān)于key的作用,官方是這樣描述的
? key 的特殊屬性主要用在 Vue 的虛擬 DOM 算法,在新舊 nodes 對比時辨識 VNodes。
如果不使用 key,Vue 會使用一種最大限度減少動態(tài)元素并且盡可能的嘗試修復(fù)/再利用相同類型元素的算法。
使用 key,它會基于 key 的變化重新排列元素順序,并且會移除 key 不存在的元素。
有相同父元素的子元素必須有獨特的 key。重復(fù)的 key 會造成渲染錯誤。
之前不知道在哪里看的文章,一直以為使用key是為了就地復(fù)用,但是從上面的描述中可以看到,實際上不用key,vue也會盡可能的嘗試使用復(fù)用修復(fù)策略,沒有設(shè)置key的時候,vnode的key值就是undefined,而在源碼patchVnode函數(shù)中有一個判斷函數(shù)sameVnode用于判斷是否是相同節(jié)點。
A.key(undefined )=== B.key(undefined)
也是判斷為相同節(jié)點的。
/*
判斷兩個VNode節(jié)點是否是同一個節(jié)點,需要滿足以下條件
key相同
tag(當(dāng)前節(jié)點的標(biāo)簽名)相同
isComment(是否為注釋節(jié)點)相同
是否data(當(dāng)前節(jié)點對應(yīng)的對象,包含了具體的一些數(shù)據(jù)信息,是一個VNodeData類型,可以參考VNodeData類型中的數(shù)據(jù)信息)都有定義
當(dāng)標(biāo)簽是<input>的時候,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ù)用,可以通過給組件以不同的key值來實現(xiàn),用于強制替換元素/組件而不是重復(fù)使用它。
作用
既然key值不是用于就地復(fù)用的目的,那為什么要設(shè)置這個key值呢?
上面說使用key值時,提到了重新排列順序及刪除的問題。
查看源碼中patch函數(shù)中對于key值得應(yīng)用,主要看updateChildren中節(jié)點比較部分,在前面的新舊首尾互比中,設(shè)置key和不設(shè)置key的比對是一樣的,在下面這塊,設(shè)置key的作用就凸顯出來了
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
...
//新舊首尾互比之后
/*
生成一個key與舊VNode的key對應(yīng)的哈希表(只有第一次進來undefined的時候會生成,也為后面檢測重復(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é)點存在key并且這個key在oldVnode中能找到則返回這個節(jié)點的idxInOld(即第幾個節(jié)點,下標(biāo))*/
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
if (isUndef(idxInOld)) { // New element
/*newStartVnode沒有key或者是該key沒有在老節(jié)點中找到則創(chuàng)建一個新的節(jié)點*/
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
/*獲取同key的老節(jié)點*/
elmToMove = oldCh[idxInOld]
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !elmToMove) {
/*如果elmToMove不存在說明之前已經(jīng)有新節(jié)點放入過這個key的Dom中,提示可能存在重復(fù)的key,確保v-for的時候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é)點是同一個VNode則進行patchVnode*/
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
/*因為已經(jīng)patchVnode進去了,所以將這個老節(jié)點賦值undefined,之后如果還有新節(jié)點與該節(jié)點key相同可以檢測出來提示已有重復(fù)的key*/
oldCh[idxInOld] = undefined
/*當(dāng)有標(biāo)識位canMove實可以直接插入oldStartVnode對應(yīng)的真實Dom節(jié)點前面*/
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的時候(比如說tag不一樣或者是有不一樣type的input標(biāo)簽),創(chuàng)建一個新的節(jié)點*/
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時,例如key1,在oldVnodes中找到了,但是數(shù)組索引位置(1)和當(dāng)前新的vnodes中的位置(0)不一樣,表示需要進行節(jié)點移動,此時做的操作
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
是用舊的vnode隊列中key值對應(yīng)的節(jié)點來復(fù)用patch生成新的vnode,然后進行節(jié)點移動,節(jié)點移動后,會將oldVnodes中(1)位置清空,置為undefined。
再例如key3節(jié)點,在oldVnodes中沒有,就意味著這是一個新的節(jié)點,需要進行新建和插入指定位置的操作。
這里需要注意,即便是sameVnode,也是要進行patch操作的,而不是直接拿舊的節(jié)點來用。
key值在新舊節(jié)點在變化前后順序不一致的情況下,能夠快速的定位,從舊的節(jié)點隊列中找到具有相同key值得節(jié)點來復(fù)用渲染。
應(yīng)用場景
日常開發(fā)中最常見的用例是結(jié)合 v-for:,開發(fā)過程中vue要求為循環(huán)生成的節(jié)點必須綁定key值
<ul> <li v-for="item in items" :key="item.id">...</li> </ul>
為何不建議用index做key
使用index作為key值,有可能造成錯誤的渲染。簡單的來說,假如有oldVnode集合[A, B, C],刪除第一個元素,剩下的vnode集合就是[B,C],當(dāng)循環(huán)patchVnode的時候,oldVnode中A的key是0,vnode中B的key也是0,vue在實際渲染的過程中就會復(fù)用A來渲染B
例如下面根據(jù)list循環(huán)生成三個子組件,以數(shù)組index作為key值,當(dāng)在刪除的時候可能會出現(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>先點擊第一條的click Me,改變文本內(nèi)容。
再點擊第一條的刪除按鈕,結(jié)果如下圖所示,并沒有如我們期待的那樣刪除第一條數(shù)據(jù),你會發(fā)現(xiàn)視圖的一部分確實正確更新了,如前面的id,但是后面的input內(nèi)容和文本內(nèi)容都沒有按預(yù)期的刪除第一條。

上面的異常情況中,很明顯的用oldVnode集合中的A復(fù)用渲染了vnode中的B,oldVnode中的B復(fù)用渲染了vnode中的C,然后刪除了oldVnode中的C。
注意:上面未正確更新的內(nèi)容,實際綁定的值是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值綁定時,當(dāng)動態(tài)改變父組件中的list集合,vue會按index索引來查找oldVnode中的子節(jié)點用以復(fù)用,且在進行patch時,只更新了那些由父組件傳遞過來的prop綁定視圖,子組件自身的實例屬性不會更新
那么,為什么會出現(xiàn)這種渲染錯誤的情況?
首先,問題的源頭肯定是節(jié)點復(fù)用,也就是我們上面key值復(fù)用其中的一步
if (sameVnode(elmToMove, newStartVnode)) {
/*如果新VNode與得到的有相同key的節(jié)點是同一個VNode則進行patchVnode*/
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
/*因為已經(jīng)patchVnode進去了,所以將這個老節(jié)點賦值undefined,之后如果還有新節(jié)點與該節(jié)點key相同可以檢測出來提示已有重復(fù)的key*/
oldCh[idxInOld] = undefined
/*當(dāng)有標(biāo)識位canMove實可以直接插入oldStartVnode對應(yīng)的真實Dom節(jié)點前面*/
canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
在這里面用eleToMove復(fù)用渲染生成newStartVnode時,更新渲染的dom出現(xiàn)了問題。
下面我們來看看這個錯誤是如何出現(xiàn)的
在進入patchVnode之前,我們首先需要明確當(dāng)前elmToMove是什么,newStartVnode又是怎樣的。
elmToMove是從舊的渲染隊列中拿出來的,由此可以看出它是一個完成的已經(jīng)渲染過的數(shù)據(jù),其中包含著數(shù)據(jù)變更前,child子組件對應(yīng)的實例及生成的dom等數(shù)據(jù)。
而newStartVnode則不盡然,它只是一個占位vnode,具體何為占位vnode,可以詳細(xì)了解下之前學(xué)習(xí)過的vue源碼中create-component這一節(jié)。
對于<child></child>這種自定義組件,在調(diào)用h函數(shù)生成vnode時,會生成一個vue-component-${Ctor.cid}${name}的占位節(jié)點,具體可以看源碼中create-component.js中的createComponent方法,這里只截取一部分,來簡單說明下占位節(jié)點也就是當(dāng)前newStartVnode有哪些內(nèi)容。
/*創(chuàng)建一個組件節(jié)點,返回Vnode節(jié)點*/
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é)點,本質(zhì)上是一個占位節(jié)點,在實際dom中并沒有
* 當(dāng)出現(xiàn) <HelloWorld></HelloWorld> 這種組件節(jié)點時,
* 會相應(yīng)生成一個tag為vue-component-xx-hellowrold的占位vnode
* 占位vnode沒有children
*/
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined/** 組件是沒有children的 */, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children }/** componentOptions */
)
return vnode
}這里重點關(guān)注一下,占位vnode中componentOptions,后面在盡心patch時會用到,data里面是一些鉤子函數(shù),用于組件節(jié)點實例的創(chuàng)建或者patch更新,詳細(xì)的這里不過多解釋。
所以,此時新的vnode其實還沒有創(chuàng)建實例,生成dom節(jié)點,接下來看看patchVnode中如何為新的vnode生成實例和dom節(jié)點的
/*patch VNode節(jié)點*/
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,如果存在的話,見"./create-component componentVNodeHooks"。*/
// 根據(jù)vnode,更新oldVnode子組件實例對應(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ù)的渲染錯誤。
首先,作為組件占位vnode,根據(jù)我們上面看到的create-component中Vnode的構(gòu)造函數(shù)中可以看出,vnode.data中是一堆hook函數(shù),用于組件vnode的實例創(chuàng)建和patch更新,這里調(diào)用了hook.prepatch方法,用oldVnode來patch生成vnode。然后直接將oldVnode的dom賦給了vnode
我們來看下這個prepatch都做了些啥
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
//props相關(guān)的數(shù)據(jù)在componentOptions里
const options = vnode.componentOptions
//實例覆蓋:將oldVnode的實例直接賦值給了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的實例直接賦值給了vnode,到這里,問題就出現(xiàn)了。
vnode中那些實例相關(guān)的屬性(例如data中的數(shù)據(jù))就會丟失,轉(zhuǎn)而成為oldVnode中的實例屬性。
這里可能會有點繞,簡單舉個例子

上面例子中用A節(jié)點復(fù)用生成B節(jié)點的時候,在prepatch的時候?qū)的實例直接賦值給了B,此時新節(jié)點隊列中的B對應(yīng)的實例屬性text就是A,渲染后會生成對應(yīng)的text:A。而oldVnode中B對應(yīng)的text: B,這就出現(xiàn)了之前我們所說的渲染錯誤。
那為什么最上面例子中組件節(jié)點中的{{ 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)對應(yīng)的propsData有變化的時候,能夠正確的渲染。而覆蓋后的新舊實例屬性為同一實例,無法對比差異,所以會直接復(fù)用oldVnode的實例屬性dom。
所以,日常開發(fā)中,如果需要用index作為key值時,需要明確會不會造成這種渲染錯誤,否則可能會出現(xiàn)意想不到的難題。
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
vue3整合SpringSecurity加JWT實現(xiàn)權(quán)限校驗
本文主要介紹了vue3整合SpringSecurity加JWT實現(xiàn)權(quán)限校驗,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2025-04-04
vue中v-for循環(huán)數(shù)組,在方法中splice刪除數(shù)組元素踩坑記錄
這篇文章主要介紹了vue中v-for循環(huán)數(shù)組,在方法中splice刪除數(shù)組元素踩坑記錄,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-06-06
vue 添加和編輯用同一個表單,el-form表單提交后清空表單數(shù)據(jù)操作
這篇文章主要介紹了vue 添加和編輯用同一個表單,el-form表單提交后清空表單數(shù)據(jù)操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-08-08
Vue 實現(xiàn)分頁與輸入框關(guān)鍵字篩選功能
這篇文章主要介紹了Vue 實現(xiàn)分頁+輸入框關(guān)鍵字篩選功能,本文通過實例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價值,需要的朋友可以參考下2020-01-01
詳解在vue-cli項目下簡單使用mockjs模擬數(shù)據(jù)
這篇文章主要介紹了詳解在vue-cli項目下簡單使用mockjs模擬數(shù)據(jù),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-10-10

