一、为什么不建议用 index 做 key
重新排序时的性能问题
当对一个列表进行重新排序操作时,若采用 index 作为 key,Vue 的虚拟 DOM Diff 算法将陷入困境,导致节点更新的处理出现偏差。
不妨考虑如下示例代码:
<template>
<div>
<button @click="sortList">排序</button>
<ul>
<li v-for="(item, index) in list" :key="index">{{ item }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from "vue";
// 堆代码 duidaima.com
const list = ref(["张三", " 李四", "王五"]);
const sortList = () => {
list.value.sort();
}; </script>
在上述代码所构建的简单列表场景中,点击 “排序” 按钮可对列表进行排序操作。由于使用 index 作为 key,当列表从原始的 ["张三", "李四", "王五"] 排序为 ["李四", "张三", "王五"] 时,原本 index 为 0 的 “张三”,排序后其 index 依然是 0,尽管它在 DOM 中的实际位置已然发生了改变。Vue 的 Diff 算法在执行过程中( patchKeyedChildren 函数的逻辑运作),会依据 key 来判断节点是否发生变化。在此情形下,由于 “张三” 的 key(即 index)未变,算法会错误地认为该节点无需移动位置,进而导致 Vue 对其他节点进行不必要的移动和更新操作,而非高效地复用已有节点并妥善调整它们的顺序,最终致使性能大打折扣。
从 Vue 3 的源码角度深入探究,在 runtime-core/src/renderer.ts 文件中的 patchKeyedChildren 函数里,存在如下类似的伪代码逻辑:
// 模拟旧虚拟节点(n1),对应初始列表状态的虚拟 DOM 表示
const n1 = {
tag: "div",
key: "parent1",
children: [{
tag: "ul",
key: "ul1",
children: [{
tag: "li",
key: "0",
// 这里以index作为key,初始第一个元素index为0
props: {},
children: [{
tag: "#text",
key: "text0",
props: {},
textContent: "张三",
},
],
},
{
tag: "li",
key: "1",
props: {},
children: [{
tag: "#text",
key: "text1",
props: {},
textContent: "李四",
},
],
},
{
tag: "li",
key: "2",
props: {},
children: [{
tag: "#text",
key: "text2",
props: {},
textContent: "王五",
},
],
},
],
props: {},
},
],
props: {},
};
// 模拟新虚拟节点(n2),对应点击排序按钮后列表状态改变的虚拟 DOM 表示
const n2 = {
tag: "div",
key: "parent1",
children: [{
tag: "ul",
key: "ul1",
children: [{
tag: "li",
key: "0",
// 这里由于还是以index作为key,排序后第一个元素变为李四,其index还是0
props: {},
children: [{
tag: "#text",
key: "text0",
props: {},
textContent: "李四",
},
],
},
{
tag: "li",
key: "1",
props: {},
children: [{
tag: "#text",
key: "text1",
props: {},
textContent: "张三",
},
],
},
{
tag: "li",
key: "2",
props: {},
children: [{
tag: "#text",
key: "text2",
props: {},
textContent: "王五",
},
],
},
],
props: {},
},
],
props: {},
};
// 模拟 patch 更新函数
function patch(oldVNode, newVNode, container) { // 首先判断节点类型是否一致,如果类型都不一致,直接替换整个节点(这里简单示意,真实情况更复杂)
if (oldVNode.tag !== newVNode.tag) {
// 卸载旧节点
unmount(oldVNode);
// 创建并挂载新节点
mount(newVNode, container, null);
return;
}
// 如果是文本节点,更新文本内容
if (oldVNode.tag === "#text" && newVNode.tag === "#text") {
if (oldVNode.textContent !== newVNode.textContent) {
container.textContent = newVNode.textContent;
}
return;
}
// 在上述基于index作为key的复用错误情况下,进入到文本节点更新判断时,虽然在真实的vue中的patch会去比较文本内容是否变化并进行更新(如这里会发现“张三”变为“李四”等文本变化情况),
// 但由于前面节点复用的错误判断,会出现不必要的判断,导致不必要的性能开支
// 遍历新属性,添加或更新属性
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
//...省略属性的更新
}
} // 遍历旧属性,删除不存在于新属性中的旧属性
for (const key in oldProps) {
//...省略属性的移除
}
// 更新子节点(这里递归调用patchKeyedChildren类似逻辑,简化版只做简单示意)
const oldChildren = oldVNode.children || [];
const newChildren = newVNode.children || [];
if (oldChildren.length > 0 && newChildren.length > 0) {
// 如果有子节点,就递归调用
patchKeyedChildren(oldVNode, newVNode, container);
}
elseif(oldChildren.length === 0 && newChildren.length > 0) {
// 如果旧节点无子节点,新节点有,挂载新子节点(类似mount逻辑)
for (const child of newChildren) {
mount(child, container, null);
}
}
elseif(oldChildren.length > 0 && newChildren.length === 0) {
// 如果新节点无子节点,旧节点有,卸载旧子节点(类似unmount逻辑)
for (const child of oldChildren) {
unmount(child);
}
}
}
// 模拟 mount 函数,简单打印表示插入新节点操作,实际会复杂得多
function mount(newVNode, container, anchor) {
console.log(`正在插入新节点: $ {
newVNode.key
}`);
}
// 模拟 unmount 函数,简单打印表示卸载节点操作,实际会复杂得多
function unmount(oldVNode) {
console.log(`正在卸载节点: $ {
oldVNode.key
}`);
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children;
const newChildren = n2.children;
let j = 0;
let oldVNodeToNewIndexMap = newMap();
// 先建立旧节点到新索引的映射表
for (let i = 0; i < oldChildren.length; i++) {
const oldChild = oldChildren[i];
oldVNodeToNewIndexMap.set(oldChild.key, i);
}
// 遍历新节点
for (let i = 0; i < newChildren.length; i++) {
const newChild = newChildren[i];
const oldIndex = oldVNodeToNewIndexMap.get(newChild.key);
if (oldIndex !== undefined) {
// 如果在旧节点中找到对应key的节点,说明可以复用,进行节点更新操作
patch(oldChildren[oldIndex], newChild, container);
} else {
// 如果没找到,说明是新增节点,创建并插入新节点
mount(newChild, container, null); // 这里简单传入null模拟anchor,实际场景需按情况传入合适的参考节点
}
}
// 处理旧节点中多余的节点,即需要删除的节点
for (let i = newChildren.length; i < oldChildren.length; i++) {
unmount(oldChildren[i]);
}
}
// 模拟容器节点,在真实Vue里对应真实DOM中的挂载点等,这里简单用一个对象表示
const container = {};
// 调用函数进行模拟更新
patchKeyedChildren(n1, n2, container);
这段代码主要模拟 Vue 虚拟 DOM 更新机制。n1 和 n2 是模拟的新旧虚拟节点,patch、mount、unmount 函数分别模拟节点更新、插入、卸载操作,当前只是简单打印信息。实际会很复杂,所以在这里先不多赘述。
重点是 patchKeyedChildren 里通过比较 key 会判断是否进去 patch 函数更新,如果相同的 key 最后会走到对比 text 值的对比更新, 因为相同的 key 文本值并不相同,会对 dom 进行一个更新。但如果 key 是唯一的,会省去这一步 dom 更新,性能自然也会提升,毕竟操作 dom的成本是昂贵的。
二、为什么不建议用随机数做 key
随机数导致的不稳定更新
使用随机数作为 key,会使得每个渲染周期中 key 的值都处于变化的状态,即便数据本身并未发生实质性的改变。
以下是一个简单的示例代码:
<template>
<div>
<ul>
<li v-for="(item, index) in list" :key="Math.random()">
{{ item }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const list = ref(['张三', '李四', '王五']);
</script>
在上述代码中,每次组件重新渲染时,由于 key 是借助 Math.random() 函数随机生成的,这就导致 Vue 在执行更新操作(类似于 patchKeyedChildren 函数的执行流程)时,会认为所有的节点都是全新的。因为在 patchKeyedChildren 函数的逻辑里,如上面的伪代码所示
由于 key 不断随机变化
在构建 oldVNodeToNewIndexMap 时,无法正确地建立起新旧节点的对应关系。Vue 无法进行有效的节点复用和更新操作,只能选择全部重新创建节点,这无疑会引发严重的性能问题,毕竟频繁地创建和销毁 DOM 元素是极为消耗资源的操作。
三、正确的 key 选择与节点更新机制
正确的 key 选择
在 Vue 开发实践中,明智的做法是优先选用具有唯一性且稳定不变的数据属性作为 key,比如数据库中的唯一标识符(如常见的 id 字段)。这样无论什么情况(诸如排序、插入、删除等各种操作),每个元素的 key 都始终保持一致。Vue 便能依据稳定的 key,精确地进行节点的复用、更新或创建操作,从而全方位提升性能。
以下是一个采用正确 key 选择的示例代码:
<template>
<div>
<ul>
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const list = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
]); </script>
在这个示例代码中,使用 item.id 作为 key,当列表遭遇任何形式的变化时,Vue 都能够凭借准确无误的 id 精准识别节点身份,正确的进行节点复用、更新或创建操作。以上就是为什么不建议用 index 和随机数做 key 的原因。正确的使用 key是提高性能的关键。