问题:为什么不建议在 v-for 指令中使用 index 作为 key?
<div v-for="(item, index) in items" :key="index">
<!-- 内容 -->
</div>
Vue 默认按照“就地更新”的策略来更新通过 v-for
渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。
为了避免上述情况,可以为每个元素对应的块提供一个唯一的 key
attribute。
这个特殊的 key
attribute 主要作为 Vue 的虚拟 DOM 算法提示,在比较新旧节点列表时用于识别 vnode。
这里提到了两个内容:vnode(虚拟DOM)和 比较新旧节点。
先写下总结:
1. vnode(虚拟 DOM )是为了避免频繁操作真实 DOM 带来的性能损耗;
2. 比较新旧节点(diff 算法)是在 patch 子 vnode 过程中,找到与新 vnode 对应的老 vnode,复用真实的dom节点,避免不必要的性能开销。
总之,目的就是减少真实DOM的操作,提升性能。
与其说虚拟 DOM 是一种具体的技术,不如说是一种模式,所以并没有一个标准的实现。这个概念是由 React 率先开拓,随后被许多不同的框架采用,当然也包括 Vue。 – 源自 vue 官网
vnode简单示例:
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
}
vnode
是一个纯 JavaScript 的对象 (一个“虚拟节点”),它代表着一个 <div>
元素。它包含我们创建实际元素所需的所有信息。它还包含更多的子节点,这使它成为虚拟 DOM 树的根节点。
这里,有必要先提下整个构建流程(以vue举例)
过程 | 说明 |
---|---|
Template => render function code | 编译 |
render function code => Virtual DOM tree => Actual DOM | 挂载 |
这里我们清楚了,vnode 是作为渲染函数与真实DOM的桥梁! 虚拟 DOM 带来的主要收益是它让开发者能够灵活、声明式地创建、检查和组合所需 UI 的结构,同时只需把具体的 DOM 操作留给渲染器去处理。
而上面提到的比较新旧节点(diff 算法),就是在发生更新过程中,如何对新旧两份虚拟DOM进行比较的过程,遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。
篇幅有限,无法详尽的说明 diff 的具体机制,只针对自己的理解,做简单梳理,目的是为了说明开头抛出的「为什么不建议在 v-for 指令中使用 index 作为 key」。如果需要了解 diff 算法细则,大家可自行查阅。
示例: old vnode:[A B C D E F G H] new vnode:[A B D E C I G H]
判断是否为相同节点,这里使用到了 key。
sameVnode:https://github.com/vuejs/vue/blob/HEAD/src/core/vdom/patch.ts#L36-L37
function sameVnode(a, b) {
return (
a.key === b.key && // 如果key不相同,会被认定为不是相同的节点
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
)
}
patch:https://github.com/vuejs/vue/blob/HEAD/src/core/vdom/patch.ts#L801-L802
function patch(oldVnode, vnode, hydrating, removeOnly) {
if (sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// replacing existing element
const oldElm = oldVnode.elm // 当前oldVnode对应的真实元素节点
const parentElm = nodeOps.parentNode(oldElm) // / 父元素
createElm(vnode) // 创建新元素
insert(parentElm, vnode.elm, refElm) // 在createElm中实现
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0) // 移除以前的旧元素节点
}
}
return vnode.elm
}
patchVnode:https://github.com/vuejs/vue/blob/HEAD/src/core/vdom/patch.ts#L584-L585
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) {
return // 同一个对象,直接return
}
const elm = (vnode.elm = oldVnode.elm)
let i
const data = vnode.data
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) // oldVnode 和 vnode children 都有子节点
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
}
updateChildren:https://github.com/vuejs/vue/blob/HEAD/src/core/vdom/patch.ts#L413-L414
/* newStartIdx、newEndIdx:new vnode 第一个和最后一个节点index值
* oldStartIdx、oldEndIdx:old vnode 第一个和最后一个节点index值 */
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVnode(oldStartVnode, newStartVnode)) { // 下述「第1步」从头开始patch
patchVnode(...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) { // 下述「第2步」从尾开始patch
patchVnode(...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // 下述「第3步」
// Vnode moved right
patchVnode(...)
canMove && nodeOps.insertBefore(...)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // 下述「第4步」
// Vnode moved left
patchVnode(...)
canMove && nodeOps.insertBefore(...)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 这一部分比较重要,截图源码说明
}
}
createKeyToOldIdx<key, index>
=> {C:2, D:3, E:4, F:5}
② 针对 剩余的 new vnode 节点:
key
,则通过 createKeyToOldIdx
索引是否存在;key
,则遍历剩余 oldCh,获取index;=> findIdxInOld ------- 至此,直到上述循环结束,oldStartIdx > oldEndIdx || newStartIdx > newEndIdx
-------
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(...)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
oldStartIdx > oldEndIdx
,剩余新节点无法找到可复用内容,直接新建;newStartIdx > newEndIdx
,新节点已执行完成,剩余的老节点无意义,直接删除。在没有 key 的情况下,Vue 将使用一种最小化元素移动的算法,并尽可能地就地更新/复用相同类型的元素。如果传了 key,则将根据 key 的变化顺序来重新排列元素,并且将始终移除/销毁 key 已经不存在的元素。
<script setup>
import { ref } from 'vue'
const list = ref([
{ name: '项目1' },
{ name: '项目2'},
{ name: '项目3'}
])
function del(index) {
list = list.value.splice(index, 1)
}
</script>
<template>
<div>
<div v-for="(item,index) in list" :key="index">
<span>{{item.name}}</span>
<input />
<button @click="del(index)">删除</button>
</div>
</div>
</template>
使用 index 作为key, 当点击删除第二条数据时,可以看到文本框的内容还是原本的第二条数据的内容。原因是虚拟DOM在比较元素的时候,因为DOM上的key等属性均未发生变化,所以其自身和内部的input均被复用了。
所以,在实际开发过程中不要把 index 作为 key 值。