Vue / React 列表渲染中的 key:为什么没有 key,Checkbox 可能“对号跑错”?

在 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 到底有什么用?

尤其是当列表中的元素包含 checkboxinput 等自身带有状态的组件时,不加 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。"

相关推荐
YWL1 小时前
vue-time-axis-plus进阶:双进度条+跨空白续播+播放指针,打造专业级监控回放体验
前端·javascript·vue·时间轴
mmsx1 小时前
MapLibre 实战 09|Bug 单写着"地图被风刮走了":一个瓦片源工厂,是三个线上事故换来的
android·前端·开源
不羁的木木1 小时前
给鸿蒙 App 增加打开外部网页能力 —— flutter_web_browser 的鸿蒙使用指南
前端·flutter·harmonyos
广州山泉婚姻1 小时前
微服务时代,前后端分离架构该怎样高效协同?
前端·后端
EatFan1 小时前
多租户系统到底怎么设计?从 tenant_id 到 JWT 租户隔离的真实实践
前端·后端·全栈·多租户
Bs_MoneyMagnet1 小时前
基于springboot+vue的旅游行程分享与推荐小程序的设计与实现 源码+文档
java·vue.js·spring boot·后端·微信小程序·毕业设计·计算机毕业设计
计算机魔术师1 小时前
硅谷四大AI巨头联手踩刹车,是真心怕失控还是怕被超越?
前端
岁岁种桃花儿1 小时前
Vue组件第六篇:Vue单文件组件
开发语言·前端·javascript·vue.js
yivifu1 小时前
创建支持交叉链接且鼠标悬停显示注释框的HTML文件
前端·javascript·html