Vue中v-for不加key引发的问题及原因【深度解析】

1,158 阅读6分钟

背景:

日常开发中,v-for不加key或者直接用数组索引做key有时候会出现元素错乱问题。
有时候不加key,也不会有问题。有时候却会有问题。
但是如果任何渲染列表的情况都加唯一key。那么有时候一些简单的结构是没有唯一key的。需要自己生成。这就增加了心智负担和不必要的代码。

本文深入解析不加key会产生问题的情况原因及解决方法(反之能知道什么时候可以不加key)。理解原理后写出更健壮的代码

注:不加key或者用数组索引做key。虽然走的diff算法不同(有key走patchKeyedChildren(快速diff算法),没key走patchUnkeyedChildren)(简单diff算法),但是都会产生一样的问题,因为都是通过数组索引找到新旧元素,找到的元素可能不是同一个元素。

本文解析用patchUnkeyedChildren算法,也就是不加key的情况作为测试用例。

react也有一样的问题。react文档对于受控/非受控组件组件复用的解释更详细。

patchUnkeyedChildren 算法步骤

  1. 找到新旧节点子节点的最短长度。

  2. 遍历最短长度的节点。新节点替换旧节点。

  3. 处理剩余的节点:

  • 如果新节点长度大于旧节点长度,挂载剩余的新节点。

  • 如果新节点长度小于旧节点长度,卸载剩余的旧节点。

这个过程实际上就是两个数组之间的对比和操作,通过索引来进行逐个元素的比较和处理

不加 Key 导致的问题解析

文本节点的情况【无问题】

例如:

  • 旧节点:[1,2,3]
  • 新节点:[1,3]

按照上述算法:

  • 旧节点 1 和新节点 1 对比,相同,不更新。
  • 旧节点 2 和新节点 3 对比,相同(根据索引key,误认为是同一个节点),新节点 3 的值更新到旧节点 2。(这里的更新单纯的 el.textContent='新的值')
  • 旧节点 3 被卸载。

结果:

  • 旧节点变为 [1,3]

有自己状态的组件或临时 DOM(非受控输入框)的情况【有问题】

组件的对比主要是 props 是否有变化。例如:

  • 旧组件:[1,2,3]
  • 新组件:[1,3]

按照上述算法:

  • 旧组件 1 和新组件 1 对比,相同,不更新。
  • 旧组件 2 和新组件 3 对比,props 不同,新组件 3 的 props 传给旧组件 2旧组件2并没有被删除,相同类型的dom,会被复用),又因为props是响应式对象,它的修改会触发使用了它值的页面(执行渲染函数)。
  • 针对有自己状态的组件:props的更新虽然重新触发了渲染函数。但是setup不会再次执行。所以组件的状态保留了下来。
  • 针对临时dom(非受控输入框):props的更新虽然重新触发了渲染函数。但是input标签在非受控模式下,其内部会维护值的状态。所以它的状态保留了下来。 旧组件 3 被卸载。

结果变为:

  • 新组件 [1,2],旧组件 3 被卸载。

具体原因

原因是复用 DOM 结构(dom类型一致就复用)只更新属性。例如:

  • input 内部维护的值无法更新。
  • 组件内部维护的值依赖组件的 props,但组件内部状态的初始化仅在组件初始化时执行。

解决方法

使用唯一 Key

  • 加了唯一 key,新旧节点可以找到对应的节点(相同节点)。这样,即使组件有自己的状态,也只需更新 props,不会出现问题。
  • 不加 key 或使用索引作为 key 时,找到的新旧节点可能不是相同节点

测试用例

基于上述的结论。进行代码验证。使问题出现。

临时dom问题复现【会出现问题】

依次在输入框输入1 2 3 image.png 删除中间元素。结果符合上面的分析。

image.png

列表内子组件有自己的状态 【会出现问题】

依次在输入框输入1 2 3 image.png 删除中间元素。结果。主要看 组件自己的状态,不会更新:2。 可以证明这是旧节点。
被复用了。并且只更新了propsimage.png

临时dom改成受控模式【不会出现问题】

依次在输入框输入1 2 3 image.png 删除中间元素。不会出问题。 2被正确的删除掉了。 image.png

解析代码:

<template>
  <div v-for="(item,index) in list">
    <!-- 形如:
    <input :value="text" @input="event => text = event.target.value">
    可以看到text绑定了:value。那么text的更新会触发input的重新渲染。
     -->
    <input v-model="item.text" />
    <button @click="deleteItem(index)">删除当前元素</button>
  </div>
</template>
<script>

import { defineComponent, ref } from 'vue';
export default defineComponent({
  setup() {
    const list = ref([{text:''},{text:''},{text:''}]);
    const deleteItem = (index)=>{
      list.value.splice(index,1)
    }
    return { list , deleteItem };
  },
});
</script>

日常开发中也要注意
组件的props是响应式的。如果用在模板(编译后会变成渲染函数)上。 props修改会触发副作用函数(渲染函数)重新渲染页面。 但是setup里面的代码只会在组件初始化也就是created前执行一次。
如果基于props的值初始化定义了响应式数据并且使用到了模板上。props的值改变并不会重新触发渲染函数。
这个情况可以通过watch监听props的值进行处理。

总结

不加 key 或使用索引作为 key,(列表内的组件有自己状态 或者 临时dom(就是非受控组件/元素))会导致更新错误:

临时dom:
<input/>标签 内部会维护自己的输入值状态,input 内部维护的值无法得到更新。

有自己的状态的子组件
组件更新的时候主要替换props。

  • 替换 props,更换属性。
  • 如果组件内部状态依赖 props 初始化,随后渲染到页面,更新 props 只会触发页面的渲染,而 setup 函数仅在组件初始化时执行。

小结(精简版)

v-for不加key为什么会出问题?
其实就是dom元素被复用了。
旧组件和新组件都是相同类型节点的时候旧组件被复用。
复用:新组件的props更新到旧组件的props。 这样来完成组件更新。然后props的变化,导致旧组件会重新触发渲染函数。
这时候:
例如上面说的组件有自己的状态(并且被显示在界面(模板)上,这种情况下才会有显示问题)。或者单独的<input />,都是没办法更新到的。界面效果就是删除了但是又没删除掉。
组件自己的状态 setup在组件初始化时执行,props的变化影响不到它。
临时dom input标签的值是标签内部维护的值(非受控模式下)。