一场由Vue3 Key值引发的血案

215 阅读13分钟

一、“血案” 背景

背景是这样的,我做了一个分页列表,这个列表里面每一项都是由图片,checkbox和一些文字描述组成,一开始我用循环的时候的index作为key,然后出现一个问题,就是当我翻到下一页的的时候,前面选中的哪一项会在后面的那一页被选中,后面将key值换成一个唯一的id值就没有这个问题了

image.png

分页列表的构成

我所做的这个分页列表,旨在展示一系列包含多种元素的信息条目。每一个条目当中,都有图片元素来直观呈现相关内容,比如若是展示商品信息,那图片可能就是商品的外观图;若是展示文章列表,图片也许就是文章相关的配图等。

同时,还有 checkbox(复选框)存在,它的作用通常是用于用户进行选择操作,比如选择感兴趣的商品、文章或者其他需要进一步处理的条目等。除此之外,每个条目里还配备了一些文字描述,这些文字会详细说明对应条目的关键信息,像商品的价格、规格、文章的摘要内容等。

整个分页列表在页面上以一定的布局呈现,可能是从上到下依次排列各个条目,并且根据设定好的每页显示数量进行分页展示,当用户浏览完当前页内容后,可以通过相应的分页按钮或者滚动加载等交互方式,切换到下一页继续查看其他条目内容。

初始 key 值选择及问题浮现

一开始,在使用 v-for 指令对列表数据进行循环渲染的时候,我图方便就直接采用了循环时的 index(也就是数组的下标索引)作为 key 值。例如下面这样的代码形式:

<template>
  <div>
    <ul>
      <li v-for="item in currentPageItems" :key="item.id">
        <img :src="item.imageUrl" alt="" />
        <input type="checkbox" v-model="item.checked" />
        <span>{{ item.description }}</span>
      </li>
    </ul>
    <button @click="prevPage" :disabled="currentPage === 1">Previous Page</button>
    <button @click="nextPage" :disabled="currentPage === totalPages">Next Page</button>
  </div>
</template>

<script>
import { ref, computed } from 'vue';

export default {
  setup() {
    // 模拟数据列表,实际应用中可从后端获取
    const allItems = ref([
      { id: 1, imageUrl: 'image1.jpg', description: 'Item 1', checked: false },
      { id: 2, imageUrl: 'image2.jpg', description: 'Item 2', checked: false },
      { id: 3, imageUrl: 'image3.jpg', description: 'Item 3', checked: false },
      { id: 4, imageUrl: 'image4.jpg', description: 'Item 4', checked: false },
      { id: 5, imageUrl: 'image5.jpg', description: 'Item 5', checked: false },
      { id: 6, imageUrl: 'image6.jpg', description: 'Item 6', checked: false },
      { id: 7, imageUrl: 'image7.jpg', description: 'Item 7', checked: false },
      { id: 8, imageUrl: 'image8.jpg', description: 'Item 8', checked: false },
      { id: 9, imageUrl: 'image9.jpg', description: 'Item 9', checked: false },
      { id: 10, imageUrl: 'image10.jpg', description: 'Item 10', checked: false },
      { id: 11, imageUrl: 'image11.jpg', description: 'Item 11', checked: false },
      { id: 12, imageUrl: 'image12.jpg', description: 'Item 12', checked: false },
      { id: 13, imageUrl: 'image13.jpg', description: 'Item 13', checked: false },
      { id: 14, imageUrl: 'image14.jpg', description: 'Item 14', checked: false },
      { id: 15, imageUrl: 'image15.jpg', description: 'Item 15', checked: false },
      { id: 16, imageUrl: 'image16.jpg', description: 'Item 16', checked: false },
      { id: 17, imageUrl: 'image17.jpg', description: 'Item 17', checked: false },
      { id: 18, imageUrl: 'image18.jpg', description: 'Item 18', checked: false },
      { id: 19, imageUrl: 'image19.jpg', description: 'Item 19', checked: false },
      { id: 20, imageUrl: 'image20.jpg', description: 'Item 20', checked: false },
    ]);

    const itemsPerPage = 5;
    const currentPage = ref(1);

    // 计算总页数
    const totalPages = computed(() => Math.ceil(allItems.value.length / itemsPerPage));

    // 根据当前页获取当前页的列表项
    const currentPageItems = computed(() => {
      const startIndex = (currentPage.value - 1) * itemsPerPage;
      const endIndex = startIndex + itemsPerPage;
      return allItems.value.slice(startIndex, endIndex);
    });

    const prevPage = () => {
      if (currentPage.value > 1) {
        currentPage.value--;
      }
    };

    const nextPage = () => {
      if (currentPage.value < totalPages.value) {
        currentPage.value++;
      }
    };

    return {
      currentPageItems,
      currentPage,
      totalPages,
      prevPage,
      nextPage,
    };
  },
};
</script>

<style scoped>
ul {
  list-style-type: none;
  padding: 0;
}

li {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
}

img {
  width: 50px;
  height: 50px;
  margin-right: 10px;
}

input[type="checkbox"] {
  margin-right: 10px;
}
</style>

在初期进行简单的页面浏览和操作时,似乎并没有发现什么明显的问题。但是,当我进行翻页操作时,奇怪的现象就出现了。比如我在第一页中选中了某几个条目的 checkbox,然后翻到下一页去浏览其他内容,等我再翻回到前面那一页的时候,竟然发现之前选中的那些项依然处于被选中的状态,可我并没有再次去手动选择它们呀,这显然不符合正常的预期操作逻辑,给用户体验带来了很大的干扰,也使得这个分页列表功能出现了严重的瑕疵,亟待去找到问题根源并解决它。

二、Key 值原理探究

Key 值在 Vue3 中的基础作用

在 Vue3 中,key 值起着至关重要的作用,其主要用途在于实现高效更新 DOM。我们都知道 Vue 有一个特性,那就是使用虚拟 DOM 和 Diff 算法,以此尽量复用 DOM 节点,从而提升渲染性能。

在整个流程里,初次添加数据时,Vue 会将这些数据放入虚拟 DOM 当中。而当有新的数据添加进来时,又会产生新的虚拟 DOM,此时新的虚拟 DOM 就会和旧的虚拟 DOM 进行比较,也就是执行 Diff 算法,去查看二者之间有没有什么不同之处。一旦发现新的虚拟 DOM 中有跟旧虚拟 DOM 相同的内容,那么就会直接调用旧的虚拟 DOM 中的对应内容,也就是进行复用操作。

而 key 值在这个新旧虚拟 DOM 对比的过程中,担当着关键角色。具体来说,在对比的时候,是根据 key 值的序号,逐个进行对比的。简单来讲,就是 key 为 1 的新虚拟 DOM 会去找 key 为 1 的旧的虚拟 DOM,看看内容是否一样,如果一样,就直接复用;要是不一样,就会相应地修改为新的内容,或者添加新的内容。

可以说,key 值就像是每个虚拟 DOM 节点的 “身份证”,帮助 Vue 更高效地找到变化的元素,快速定位节点,减少查找时间,同时还能减少不必要的重绘操作,只更新那些有需要变化的部分。特别是在大规模数据更新时,使用 key 属性能显著提升整体的性能表现,让虚拟 DOM 操作更加精准、高效,避免对整个列表进行不必要的重新渲染,在整个虚拟 DOM 的更新维护过程中意义重大。

使用 index 作为 key 引发问题的底层逻辑

当我们使用 index(也就是数组的下标索引)作为 key 时,看似方便,但其实在很多情况下容易埋下隐患,导致真实 DOM 更新出现异常以及界面显示错乱等问题。

比如在进行一些破坏顺序的操作时,像逆序添加或者逆序删除元素这种情况(即在集合头部添加或者删除元素),会使得所有新的 key 和旧的 key 不一致。因为 index 是基于元素在数组中的位置来确定的,顺序一旦改变,index 对应的元素也就变了。按照 Diff 算法基于 key 来判断节点是否复用等操作逻辑,此时就可能出现所有的 DOM 都重新渲染的情况,这和没有设置 key 时的效率几乎没什么区别了,完全失去了利用 key 来优化更新的意义。

另外,如果结构中包含输入类的 DOM(比如 input 输入框等)时,问题也会凸显出来。以一个常见的列表场景举例,列表中每个条目有输入框用于用户输入内容,当使用 index 作为 key,然后进行如在头部添加新条目等操作后,由于元素顺序改变导致 index 变化,Vue 在进行虚拟 DOM 对比和更新时,可能会错误地复用节点或者进行不正确的更新,使得输入框里原本输入的值出现错乱。原本输入框中的内容可能会因为这种错误的 DOM 更新而丢失或者显示异常,即使有些有双向绑定的输入组件,虽然会重新赋值,但整个界面更新的效率也会受到极大影响,无法达到预期的正确显示和交互效果。

总的来说,使用 index 作为 key 在涉及到元素顺序变动、包含特定交互 DOM 的场景下,很难准确地让 Vue 通过 Diff 算法去精准判断节点的真实变化情况,进而容易引发一系列显示和交互上的问题。

三、“破案” 关键 ——id 值替换

唯一 id 值作为 key 的优势

在 Vue3 中,使用每条数据的唯一标识(如 id)作为 key 有着诸多优势。首先,它能够精准地区分不同的节点。就好比每个节点都有了独一无二的 “身份号码”,在虚拟 DOM 更新过程中,Vue 可以凭借这个唯一的 id 值迅速且准确地找到对应的节点,而不会出现混淆的情况。

例如在一个复杂的列表结构里,若存在多条看似相似的数据,但各自有其独立的业务逻辑和状态,使用唯一 id 作为 key,就能让 Vue 清晰地知晓哪些是需要更新、复用或者删除的节点。

其次,通过唯一 id 作为 key,Vue 在进行虚拟 DOM 的新旧对比时,可以正确地追踪每个列表项的变化情况。当数据发生改变,需要更新 DOM 时,它能依据 key 值准确判断哪些节点可以复用,哪些需要重新渲染,避免像使用 index 作为 key 时,因顺序变化等因素导致的节点复用错误以及不必要的大量重绘操作。

而且在涉及到包含交互元素(如 checkbox、input 输入框等)的列表组件中,唯一 id 作为 key 能够保障这些元素的状态正确地被维持。不会出现像使用 index 作为 key 那样,因为 DOM 节点复用错误而使得用户操作的状态(比如 checkbox 的选中状态、输入框里输入的内容等)出现错乱或者丢失的现象,极大地提升了用户体验,让整个列表组件在各种操作下都能按照预期进行更新和交互,保障功能的正确性和稳定性。

解决分页列表选中项异常的原理

当我们把 key 值从原本的 index 换成唯一的 id 值后,在新旧虚拟 DOM 对比环节就发生了本质的改变。之前使用 index 作为 key 时,是基于元素在数组中的位置序号来进行新旧虚拟 DOM 的匹配复用,一旦数组顺序发生变化(比如翻页操作其实就是数据顺序的一种改变形式),就容易导致匹配混乱,进而出现选中项异常等问题。

而采用唯一 id 值作为 key 后,在新旧虚拟 DOM 对比时,Vue 会根据 id 这个稳定且唯一的标识去寻找对应的节点。例如,在分页列表中,第一页的数据和第二页的数据虽然在不同的页码展示,但每条数据都有其独立且固定的 id。当从第一页翻到第二页再翻回来时,Vue 通过 id 能准确识别出每条数据对应的节点,不管页面如何切换,它都能正确地复用之前的节点状态,不会出现错误地将第一页的选中状态 “传递” 到第二页对应位置上的情况。

在节点复用及更新方面,使用唯一 id 作为 key,Vue 能精准地判断哪些节点在翻页等操作后是保持不变的,哪些是新出现或者消失的。对于不变的节点,直接复用其之前的状态(像 checkbox 的选中与否等),对于新的节点则进行相应的创建和初始化,对于消失的节点进行正确的销毁。这样一来,整个分页列表在翻页以及各种交互操作下,都能按照预期准确地更新和展示,解决了之前因 key 值使用不当而引发的选中项异常问题,保证了列表功能的正常运行以及用户操作状态的一致性和准确性。

四、总结与建议

image.png

回顾与思考

在这次由 Vue3 中 key 值引发的 “血案” 里,我们先是构建了一个分页列表,它包含图片、checkbox 以及文字描述等元素。最初在使用v-for循环渲染列表数据时,为图省事,选择用循环的index作为key值,在平常浏览操作时好像没什么问题,可一旦进行翻页操作,就出现了异常情况,也就是前一页选中的 checkbox,翻到下一页再返回时,居然还保持着选中状态,这显然不符合预期,严重影响了用户体验和功能的正常使用。

随后,我们深入探究了key值在 Vue3 中的原理,了解到它对于虚拟 DOM 的高效更新起着关键作用,是新旧虚拟 DOM 对比时判断节点是否复用等操作的重要依据。而使用index作为key之所以出现问题,是因为index基于元素在数组中的位置确定,当出现如翻页这种顺序改变的情况,或者列表结构中包含输入类 DOM 时,容易导致 DOM 更新错乱,节点复用错误等情况发生。

最后,我们把key值换成了每条数据的唯一标识(如id值),这一改变让 Vue 在进行虚拟 DOM 对比和更新时,能精准地根据id去识别每个节点,不管页面如何切换、数据顺序如何变化,都可以正确复用节点状态,保障了诸如 checkbox 选中状态等交互元素状态的正确性,成功解决了之前的选中项异常问题。

通过整个过程,我们可以清晰地看到,key值虽小,但在涉及列表渲染以及有交互元素的场景中,其选择至关重要,使用不当很容易出现各种隐藏的显示和交互问题,而选择合适的唯一标识作为key,能让项目的功能更加稳定、可靠。

开发中的 key 值使用建议

在 Vue3 开发中,使用v-for循环渲染列表时,对于key值的选择一定要谨慎。通常情况下,建议优先使用每条数据的唯一标识作为key值,例如常见的数据库中的id字段,或者其他能确保唯一性的属性,像商品的编号、用户的唯一账号等。

如果只是简单地渲染静态列表,并且确定不会有顺序变动、添加删除元素等操作,也不会包含像checkbox、input输入框这类有状态的交互元素,那么使用index作为key在一定程度上是可行的,但这只是少数特定简单场景。在大多数实际项目开发中,列表往往是动态变化的,会有各种交互操作,所以为了避免出现类似本文中提到的选中项异常、DOM 更新错乱等问题,还是要坚持使用唯一标识作为key。

另外,在开发过程中,如果遇到类似列表更新后界面显示不符合预期、交互元素状态异常等问题时,不妨先检查一下key值的设置是否合理,很有可能问题就出在这里。合理选择和使用key值,能够有效提升开发效率,减少调试时间,保障项目质量,让 Vue 应用的列表渲染功能更加稳定、高效地运行。