基础/列表渲染-通过 key 管理状态

119 阅读6分钟

本文代码均可直接复制到 Vue演练场 运行

什么是 key

key 是 Vue 提供的一种标签上的属性(attribute),使用方法和其他 attribute 一样。你可以使用 v-bind 指令给 key 绑定动态数据:

<template>
  <div class="box" key="1"></div>
  
  <div class="box" v-for="item in items" :key="item.id"></div>
</template>

key 的作用

  1. 复用元素:数据发生变化更新视图时,Vue会通过key来识别每个元素的身份,从而尽可能复用已有元素。

  2. 根据 key 的变化顺序来重新排列元素:数据发生变化更新视图时,Vue可以使用key来追踪每个节点的身份,确定哪些元素是相同的,可以移动而不是重新创建,这样可以提高列表渲染的性能。

  3. 销毁与创建:如果新的元素里缺少了某些key那么对应元素就会被销毁,新增的key就会创建对应元素。

如果没有key,Vue会采用“就地更新”的策略。

在下面 各种情况 部分,可以看到就地更新的机制,以及使用 key 之后带来的性能提升

什么时候需要使用 key

默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况

简单来说,如果你的子元素都是根据 v-for="item in items" 里的 item 获取的结果进行渲染,即使你不使用 key 也不会出现问题。

各种情况

不使用 key

子组件没有自己的状态

<template>
  <span>
    子组件 {{ id }}
  </span>
</template>

<script setup>
  import { watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成`)
  })
  
  onUnmounted(() => {
    console.log(props.id + '')
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

  1. 点击 尾部插入一条数据 ,观察控制台输出结果:["id为:3的子组件挂载完成"]。发现创建了一个新组件。我们新增的数据id是3,没问题。

  2. 点击 头部插入一条数据,观察控制台输出结果:["id为:0的子组件更新完成", "id为:1的子组件更新完成", "id为:2的子组件更新完成", "id为:3的子组件挂载完成"]。出现了问题,我们新增id为0的元素是被更新的,原来已经存在id为3的元素却被重新创建。 这就是 Vue 的 “就地更新”策略。更新逻辑为:截屏2024-07-24 12.59.54.png

子组件有自己的状态

<template>
  <span>
    子组件 {{ id }}
    <input v-model="name" />
  </span>
</template>

<script setup>
  import { ref, watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })
  
  const name = ref('')

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成,此时组件自己的输入框值为:${name.value}`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成,此时组件自己的输入框值为:${name.value}`)
  })
  
  onUnmounted(() => {
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

列表渲染输出结果现在依赖子组件自己的状态了,就地更新策略出现了问题。

  1. 首先就地更新,原来元素顺序没有改变,值为1的输入框现在仍是处于首位;
  2. 头部添加数据响应式数据发生变化,需要更新内容,第一项id变为0、msg变为第零项,但是input是子组件自己维护状态的,不受父组件数据变化的影响;
  3. 因此,页面就出现了值为1的输入框,出现在第零项的后面。 截屏2024-07-24 12.56.16.png

使用 index 索引作为 key

子组件没有自己的状态

<template>
  <span>
    子组件 {{ id }}
  </span>
</template>

<script setup>
  import { watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成`)
  })
  
  onUnmounted(() => {
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items" :key="index">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

  1. 点击 尾部插入一条数据 ,观察控制台输出结果:["id为:3的子组件挂载完成"]。发现创建了一个新组件。我们新增的数据id是3,没问题。

  2. 点击 头部插入一条数据,观察控制台输出结果:["id为:0的子组件更新完成", "id为:1的子组件更新完成", "id为:2的子组件更新完成", "id为:3的子组件挂载完成"]

看起来好像效果和不添加 key 一样,但其实还是比较了 key 的。 截屏2024-07-24 14.40.36.png

子组件有自己的状态

<template>
  <span>
    子组件 {{ id }}
    <input v-model="name" />
  </span>
</template>

<script setup>
  import { ref, watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })
  
  const name = ref('')

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成,此时组件自己的输入框值为:${name.value}`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成,此时组件自己的输入框值为:${name.value}`)
  })
  
  onUnmounted(() => {
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items" :key="index">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

出现了和不添加 key 并且子组件有自己的状态同样的问题,属于第一项的输入框,更新之后出现在第零项。

截屏2024-07-24 14.47.17.png

涉及到子组件有自己状态或者表单输入时,不仅仅是头部插入,只要元素顺序变化(删除其中某个元素,随机插入,交换顺序等),不管是不添加 key 还是使用 index 作为 key 渲染都会出现问题。

使用唯一 key

子组件没有自己的状态

<template>
  <span>
    子组件 {{ id }}
  </span>
</template>

<script setup>
  import { watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成`)
  })
  
  onUnmounted(() => {
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

  1. 点击尾部插入元素控制台打印:["id为:3的组件挂载完成"]
  2. 再点击头部插入元素控制台打印:["id为:0的组件挂载完成"],比之前少了几个,key为1,id为1的元素更新之后仍是key为1,id为1,数据也没发生变化因此无需更新此元素。截屏2024-07-24 15.06.39.png

子组件有自己的状态

<template>
  <span>
    子组件 {{ id }}
    <input v-model="name" />
  </span>
</template>

<script setup>
  import { ref, watch, onMounted, onUpdated, onUnmounted } from 'vue'
  
  const props = defineProps({
    id: Number
  })
  
  const name = ref('')

  onMounted(() => {
    console.log(`id为:${props.id}的子组件挂载完成,此时组件自己的输入框值为:${name.value}`)
  })

  onUpdated(() => {
    console.log(`id为:${props.id}的子组件更新完成,此时组件自己的输入框值为:${name.value}`)
  })
  
  onUnmounted(() => {
    console.log(`id为:${props.id}的子组件已被销毁`)
  })
</script>
<template>
  <button @click="insertLast"> 尾部插入一条数据 </button>
  <button @click="insertCenter"> 中间位置插入一条数据 </button>
  <button @click="insertFirst"> 头部插入一条数据 </button>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ item.msg }}
      <Comp :id="item.id" />
      <button @click="del(index)">删除</button>
    </li>
  </ul>
</template>

<script setup>
  import { ref } from 'vue'
  import Comp from './Comp.vue';
	
  const items = ref([
    {
      id: 1,
      msg: '第一项',
    },
    {
      id: 2,
      msg: '第二项',
    },
  ])
  
  const insertLast = () => {
    items.value.push({
      id: 3,
      msg:'第三项'
    })
  }
  const insertCenter = () => {
    items.value.splice(1,0,{
      id: 4,
      msg:'第四项'
    })
  }
  const insertFirst = () => {
    items.value.unshift({
      id: 0,
      msg:'第零项'
    })
  }
  const del = (index) => {
    items.value.splice(index, 1)
  }
</script>

视频地址

同样可以正确渲染: 截屏2024-07-24 15.10.01.png

总结

在子组件没有自己的状态或者没有表单输入内容时,不使用 key、使用 index 作为 key、使用唯一 key,最后渲染的效果都是一样的。

不过观察控制台打印,只有在使用唯一 key 时打印内容最少,证明进行的更新最少,因此性能也就最好。

并且在子组件维护自己的状态或者子元素存在表单项时,也只有使用唯一 key 可以做到正确渲染。

其他用途

配合 Transition 实现动画

<!-- 实现一些动画效果 -->
<template>
  <p style="overflow: hidden">
    js框架:
    <Transition name="text" mode="out-in">
      <span :key="computedText" :style="computedStyle"> {{ computedText }} </span>
    </Transition>
  </p>
</template>

<script setup>
  import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
 
  const textList = [
    'Vue',
    'React',
    'Angular'
  ]
  const colorList = [
    'rgb(66, 184, 131)',
    'rgb(8, 126, 164)',
    'rgb(239, 8, 19)'
  ]
  
  const curIndex = ref(0)
  
  const computedText = computed(() => textList[curIndex.value])
  const computedStyle = computed(() => ({
    color: colorList[curIndex.value],
    display: 'inline-block'
  }))
  
  let timer = null
  const autoChange = () => {
  	 if (curIndex.value === textList.length - 1) {
       curIndex.value = 0
     } else {
       curIndex.value += 1
     }
  }
  
  onMounted(() => {
    timer = setInterval(autoChange, 2000)
  })
  
  onBeforeUnmount(() => {
    clearInterval(timer)
  })
</script>

<style scoped>
  .text-enter-active,
  .text-leave-active {
    transition: all .2s ease;
  }
  
  .text-enter-from {
    transform: translateY(100%);
  }

  .text-leave-to {
    transform: translateY(-100%);
  }
</style>

主动重新渲染子组件

<!-- Comp.vue -->
<template>
  <p>
    子组件
  </p>
</template>

<script setup>
	import { onMounted, onUnmounted } from 'vue'
  
  onMounted(() => {
    console.log('子组件挂载完成')
  })
  
  onUnmounted(() => {
    console.log('子组件已被销毁')
  })
</script>
<!-- App.vue -->
<template>
  <Comp :key="count" />
  
  <button @click="reRender">
    重新渲染子组件
  </button>
</template>

<script setup>
  import { ref, onMounted, onUnmounted } from 'vue'
  import Comp from './Comp.vue'
  
  const count = ref(0)
  
  const reRender = () => {
    count.value += 1
    console.log(`子组件已被重新渲染 ${count.value} 次`)
    
    // 点击按钮后控制台打印:
    // 子组件已被重新渲染 1 次
    // 子组件已被销毁
    // 子组件挂载完成
  }
</script>

使用 key 的注意事项

  • 同一父元素下面子元素的 key 必须唯一
  • key 最好是和数据绑定的值,比如该条数据在数据库中的 id
  • key 的数据类型只支持:string | number | symbol
  • 哪个标签使用了 v-for 哪个标签添加 key
<!-- 即使 template 不会被渲染成实际的DOM,也要在 template 上添加key,因为 v-for 在 template 上使用 -->
<template v-for="todo in todos" :key="todo.name">
  <li>{{ todo.name }}</li>
</template>