在 Vue 和 React 开发中,列表渲染几乎无处不在。
Vue 中我们经常写:
javascript
<view
v-for="item in goods"
:key="item.id"
>
{{ item.name }}
</view>
React 中则经常写:
javascript
{items.map(item => (
<div key={item.id}>
{item.name}
</div>
))}
很多初学者会产生一个疑问:
key到底有什么用?
尤其是当列表中的元素包含 checkbox、input 等自身带有状态的组件时,不加 key 甚至可能出现一种非常诡异的现象:
明明数据删除的是"小米",结果勾选状态却跑到别的商品身上了。
这篇文章就用一个具体的 Vue 案例,把 key 到底解决了什么问题彻底讲清楚。
一、先看案例
我们有一个商品列表
javascript
<template>
<view class="out">
<view
class="item"
v-for="item in goods"
>
<checkbox></checkbox>
<text class="title">{{ item.name }}</text>
<text class="del">删除</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const goods = ref([
{ id: 11, name: '小米' },
{ id: 22, name: '华为' },
{ id: 33, name: 'oppo' },
{ id: 44, name: '苹果' }
])
</script>
页面大概是:
☐ 小米 删除
☐ 华为 删除
☐ oppo 删除
☐ 苹果 删除
现在假设用户点击了 oppo:
☐ 小米
☐ 华为
☑ oppo
☐ 苹果
然后我们删除"小米"。
按照我们的直觉,剩下的数据应该是:
☐ 华为
☑ oppo
☐ 苹果
但问题来了:
如果列表项没有稳定的
key,当数组发生变化时,框架可能会复用原有的 DOM/组件实例,而不是简单地"一个数据对应一个全新的 DOM"。
这时候,像 checkbox 这样的内部状态就可能出现错位。
二、问题的根源:数据变了,但 DOM 不一定全部重建
很多初学者会想:
"数组变了,页面不是应该重新生成吗?"
Vue 的响应式系统确实会触发更新。
但"更新"不等于:
把整个页面删除,然后从头创建一遍。
这样做效率太低。
框架会尽量:
复用已经存在的 DOM 和组件,只更新真正发生变化的部分。
例如最开始:
位置 0 → 小米
位置 1 → 华为
位置 2 → oppo
位置 3 → 苹果
删除小米之后:
位置 0 → 华为
位置 1 → oppo
位置 2 → 苹果
如果没有 key,框架在 diff / 更新过程中可以更倾向于依据"位置"复用节点:
原来的 DOM0 → 现在继续作为位置0
原来的 DOM1 → 现在继续作为位置1
原来的 DOM2 → 现在继续作为位置2
于是:
原来的:
DOM0 → 小米
DOM1 → 华为
DOM2 → oppo
DOM3 → 苹果
删除小米:
DOM0 → 华为
DOM1 → oppo
DOM2 → 苹果
注意:
DOM 位置没有完全跟着数据身份移动,而是被尽可能复用了。
三、为什么普通文本看不出问题?
如果列表只有:
<div v-for="item in goods">
{{ item.name }}
</div>
你可能根本发现不了问题。
因为文本很好更新。
例如:
DOM0
原来:小米
现在:华为
只需要把文本:
小米
改成:
华为
视觉上完全正常。
所以很多人第一次学 key 的时候会觉得:
"不加也没啥问题啊?"
确实,很多简单场景下你看不出来。
真正容易暴露问题的是:
input
checkbox
select
组件内部状态
动画状态
DOM 状态
四、Checkbox 为什么特别容易暴露问题?
因为 checkbox 不只是文本。
它有自己的 UI 状态:
checked
unchecked
比如原来:
☐ 小米
☐ 华为
☑ oppo
☐ 苹果
假设 oppo 对应的 checkbox 是"勾选状态"。
这个"勾选状态"实际上属于:
当前那个 checkbox DOM / 组件实例。
现在我们把"小米"删除了。
如果框架按照位置复用节点,就可能出现:
原来的 DOM1
→ 华为
现在 DOM1
→ oppo
但是原来的 DOM1 是什么状态?
如果之前它是:
☐
那现在它被复用给 oppo 时,状态就可能和我们期望的不一致。
反过来,如果某个有状态的节点被复用给了另一个数据,就会出现:
"数据属于 A,但 UI 状态看起来像 B。"
这就是我们常说的列表状态错位。
五、这就是 key 的意义
于是 Vue 提供了:
:key="item.id"
修改:
<view
class="item"
v-for="item in goods"
:key="item.id"
>
<checkbox></checkbox>
<text class="title">{{ item.name }}</text>
<text class="del">删除</text>
</view>
现在每一项都有了一个稳定身份:
11 → 小米
22 → 华为
33 → oppo
44 → 苹果
删除"小米"以后:
11 → 删除
22 → 华为
33 → oppo
44 → 苹果
Vue 可以更加明确地知道:
22 还是原来的华为
33 还是原来的 oppo
44 还是原来的苹果
即使它们的位置变了:
原来:
0 → 11
1 → 22
2 → 33
3 → 44
现在:
0 → 22
1 → 33
2 → 44
Vue 仍然知道:
22 ≠ 33 ≠ 44
也就是说:
位置变了,但身份没变。
六、把 key 理解成"身份证"
这是理解 key 最简单的方法。
没有 key 时,你可以粗略理解成:
位置 0
位置 1
位置 2
位置 3
框架更容易从"位置"来判断节点如何复用。
有 key:
身份证 11 → 小米
身份证 22 → 华为
身份证 33 → oppo
身份证 44 → 苹果
此时即使顺序发生变化:
苹果
小米
oppo
华为
框架仍然能认出:
44 → 苹果
11 → 小米
33 → oppo
22 → 华为
所以:
key描述的不是"位置",而是"身份"。
七、为什么不能简单使用数组下标 index?
很多时候我们会看到:
javascript
<div
v-for="(item, index) in goods"
:key="index"
>
看上去好像也有 key。
但这里的问题是:
index是位置,不是数据身份。
例如最开始:
index 0 → 小米
index 1 → 华为
index 2 → oppo
删除小米以后:
index 0 → 华为
index 1 → oppo
于是:
index 0
原来代表"小米"。
现在却变成了"华为"。
身份证竟然变了主人。
这就是为什么:
index = 位置
id = 身份
两者有本质区别。
八、最推荐使用什么作为 key?
最好使用数据本身稳定且唯一的 ID:
:key="item.id"
例如:
const goods = ref([
{
id: 11,
name: '小米'
},
{
id: 22,
name: '华为'
},
{
id: 33,
name: 'oppo'
}
])
这样:
11 → 永远代表小米
22 → 永远代表华为
33 → 永远代表 oppo
即使顺序改变,身份依旧稳定。
九、为什么不能使用 Math.random()?
有的人会想:
:key="Math.random()"
这样不是每次都有唯一值了吗?
确实"看起来唯一"。
但这是错误的做法。
因为:
第一次渲染:
Math.random() → 0.123
第二次渲染:
Math.random() → 0.981
同一个元素每次渲染的 key 都变了。
对于框架来说,相当于:
旧元素:
"身份证 0.123"
新元素:
"身份证 0.981"
框架会认为:
这不是同一个元素。
结果就失去了复用的意义。
所以真正重要的不是:
唯一
而是:
稳定且唯一。
十、key 不是用来显示的
还有一个常见误解:
<div :key="item.id">
是不是最终 HTML 里会出现:
<div key="11">
并不是。
key 是 Vue / React 用于内部列表更新、节点身份识别的特殊信息。
你不应该把:
:key="item.id"
理解成普通 HTML 属性。
它真正表达的是:
"这个列表项,在框架眼里是谁。"
十一、这件事和 React 的 key 完全对应:
javascript
items.map(item => (
<div key={item.id}>
{item.name}
</div>
))
Vue:
javascript
<div
v-for="item in items"
:key="item.id"
>
{{ item.name }}
</div>
虽然 Vue 和 React 的实现细节不同,但核心思想一致:
列表渲染
↓
每一个元素
↓
需要稳定身份
↓
key
所以你可以直接建立一个知识映射:
Vue React
v-for map()
:key="item.id" key={item.id}
{{ item.name }} {item.name}
@click="handleClick" onClick={handleClick}
十二、为什么"列表有状态"时 key 特别重要?
可以总结为:
没有状态的元素
例如:
<div>张三</div>
<div>李四</div>
<div>王五</div>
即使没有 key,很多时候也看不出问题。
因为框架只需要修改文本。
有内部状态的元素
例如:
checkbox
input
select
video
组件内部 state
这些元素除了"显示什么内容"之外,还有:
当前勾选状态
当前输入内容
当前光标位置
当前组件状态
如果节点被错误复用,就可能产生:
内容换了,但内部状态没跟着正确的数据走。
这就是为什么实际项目中,列表项的 key 不能随便省。
十三、再看一次完整流程
假设:
const goods = ref([
{ id: 11, name: '小米' },
{ id: 22, name: '华为' },
{ id: 33, name: 'oppo' },
{ id: 44, name: '苹果' }
])
页面:
☐ 小米
☐ 华为
☑ oppo
☐ 苹果
删除:
小米
新的数组:
[
{ id: 22, name: '华为' },
{ id: 33, name: 'oppo' },
{ id: 44, name: '苹果' }
]
有 key:
22 → 还是华为
33 → 还是 oppo
44 → 还是苹果
没有 key:
第 0 个位置 → 从小米变成华为
第 1 个位置 → 从华为变成 oppo
第 2 个位置 → 从 oppo 变成苹果
这就是:
按位置复用
和:
按身份识别
的区别。
十四、真正应该记住的不是"有 key 才能循环"
这是一个很重要的纠正。
key 不是让 v-for 工作的必要条件。
下面代码依然可以循环:
<div v-for="item in goods">
{{ item.name }}
</div>
key 解决的是另外一个问题:
当列表发生变化时,帮助框架准确判断"谁还是谁"。
所以:
v-for
→ 负责遍历
key
→ 负责身份识别
这是两个完全不同的职责。
十五、一个非常实用的口诀
以后看到列表:
v-for
马上想到:
"每一项都有自己的身份证吗?"
如果有数据库 ID:
:key="item.id"
优先使用。
如果使用:
:key="index"
就问自己:
"这个列表会不会增删、排序、移动?"
如果会,就要谨慎。
如果使用:
:key="Math.random()"
基本可以直接判断:
不合适。
十六、最后总结
key 的本质不是:
"为了让 Vue 不报 warning。"
也不是:
"为了让
v-for能运行。"
真正的作用是:
给列表中的每一项建立稳定、唯一的身份,让 Vue / React 在列表发生变化时,可以正确地识别、复用、移动和销毁对应的 DOM / 组件。
这次的 checkbox 案例非常典型:
没有 key
↓
框架可能按位置复用节点
↓
列表发生删除/插入/排序
↓
DOM / 组件的位置发生变化
↓
checkbox 等内部状态可能跟错数据
↓
出现"对号跑了"的现象
而:
:key="item.id"
相当于给每个列表项贴上:
身份证
于是:
11 → 小米
22 → 华为
33 → oppo
44 → 苹果
哪怕位置改变:
33 从第 3 个变成第 2 个
框架依然知道:
"它还是那个 33,它还是 oppo。"