在列表渲染 v-for 时,Vue 要求为每个被循环的元素绑定一个唯一的 key 属性。这个看似简单的规则,背后是虚拟 DOM Diff 算法得以高效运转的核心机制。
key 在底层做了什么
Diff 算法在对比新旧两组虚拟 DOM 节点时,需要知道“哪个旧节点对应哪个新节点”。没有 key 时,Vue 只能按顺序就地复用:
<!-- 旧列表 -->
<li>A</li>
<li>B</li>
<li>C</li>
<!-- 新列表(头部插入一个 D) -->
<li>D</li>
<li>A</li>
<li>B</li>
<li>C</li>
在没有 key 的情况下,Diff 会逐个对比同位置的节点:
- 第1个:旧 A → 新 D,文本不同,更新文本内容
- 第2个:旧 B → 新 A,文本不同,更新文本内容
- 第3个:旧 C → 新 B,文本不同,更新文本内容
- 第4个:旧无 → 新 C,新建节点
结果:四个节点全部被更新或重建,哪怕实际只是插入了一个新节点。
有了 key,Diff 算法就能跨位置匹配。如果为每个 <li> 绑定了稳定的 key(比如数据 id),Vue 会建立一张 key 到旧节点的映射表。对于新列表,它会先尝试从旧节点中找到相同 key 的节点,然后仅移动其 DOM 位置或更新内容,而不是摧毁重建。
同样的场景,如果 A、B、C 分别有 key="1"、"2"、"3",D 有 key="4":
- Diff 发现新节点 D 的 key="4" 在旧节点中不存在,直接创建
- 新节点 A 匹配到旧节点 A,只做位置移动
- B 同理,C 同理
最终只有一次创建和三次移动,大幅减少了不必要的 DOM 操作。
更关键的是对组件状态的影响。如果一个列表项是一个带有自身状态的组件(例如一个展开/收起的卡片,或一个正在播放的视频),没有 key 时 Vue 会复用同一个组件实例,仅更新传入的 Props,导致原组件的内部状态被错误地保留。有了稳定的 key,Vue 会知道“这是另一个实例”,从而正确销毁旧组件、创建新组件。
使用 index 作为 key 的陷阱
许多开发者知道必须写 key,但图省事直接使用循环的 index:
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>
这种写法在列表顺序不会改变时看起来一切正常,但在插入、删除或排序时就会出现经典问题。
假设列表为 [{name:'张三'}, {name:'李四'}],渲染后两个输入框分别填入了“张三的输入”和“李四的输入”。现在在头部插入一个新项,列表变成 [{name:'王五'}, {name:'张三'}, {name:'李四'}]。用 index 作为 key:
- 原来的第0项 (key=0) 对应张三,新的第0项 (key=0) 对应王五
- Vue 会认为 key=0 的节点还是同一个,只是数据变了,于是复用旧节点的 DOM 和组件实例,仅更新文本从张三变成王五
- 但输入框的内容是组件内部状态,不会被自动更新,导致王五这一行依然显示着“张三的输入”
核心原因:index 作为 key 不具备稳定性。当列表顺序变化时,同一个 index 指向的数据项已经变了,但 Vue 误以为还是同一个节点。这会导致:
- 状态串位:输入框、展开状态、选中状态等保留在错误的位置
- 动画错乱:过渡动画应用于错误的节点
- 不必要的 DOM 更新:原本只需移动的节点被重建,损失性能
最佳实践
使用每条数据中唯一且不变的字段作为 key,例如后端返回的 item.id。如果数据没有自然唯一标识,可以在获取数据时为每条数据生成一个唯一 id(如使用 crypto.randomUUID() 或自增计数器),并保证在列表的整个生命周期中该 id 不变。
<!-- 正确:使用稳定的业务 id -->
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
总结:key 不只是消除控制台警告的“格式要求”,它直接决定了 Diff 算法能否正确高效地复用节点。用 index 当 key 等于把 key 的作用拱手让出,在列表顺序变化时会引发一系列难以排查的 UI 状态 bug。遵循“唯一且稳定”的原则,是列表渲染中性价比最高的性能与正确性保障。