v-for 为什么要加上 key?

看了一些讲解,v-for 中加key得文章,发现都描述得很笼统,甚至有很多不准确得,那不妨自力更生,这次直接从 vue3 得源码入手,带你了解真相,洞悉真理。

注:全文基于 vue v3.2.38版本源码

先看看官方文档对key的描述:

Vue 默认按照“就地更新”的策略来更新通过 v-for 渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。

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

为了给 Vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的 key attribute

 这里,我们可以得到几个有用的信息:

1.没有 key 的元素列表会通过就地更新,保证他们在原本指定的索引位置上渲染

2.添加了一堆的 key 属性可以高效地重用和重新排序现有的元素

3.默认模式(不加key)只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态的情况。

前置了解

磨刀不误砍柴工,在这之前,我们需要了解vue3 的编译优化和渲染器模块中的 patch流程

编译优化

vue3 为了渲染函数的灵活性和对 vue2 的兼容,还是选择保留了虚拟 DOM 的设计。因此不可避免地也要承担虚拟 DOM 带来的额外性能开销(相较于直接编译成原生 DOM 代码)。为了优化这一方面的开销, vue3 引入了 Block 和 PatchFlags 的概念。

首先我们需要了解一下什么是动态节点,如下一段代码

<div>
  <div>我是静态</div>
  <P>{{ dynamic }}</P>
</div>

上述模板中只有 dynamic 是个可以动态修改的变量,因此将 <p>{{ dynamic }}</p> 编译成的vnode 就是个动态节点。

所以优化的思路其实就是,在创建 vnode 阶段,就将这些动态节点给标记和提取出来,如果要更新,就只更新这些动态节点,静态节点保持不变。

其中 PatchFlags 就是用来标记动态节点类型的,动态节点具有如下类型:

export const enum PatchFlags {
  // 文本节点
  TEXT = 1,
  // 动态 class
  CLASS = 1 << 1,
  // 动态 style
  STYLE = 1 << 2,
  // 具有动态属性的元素或组件
  PROPS = 1 << 3,
  // 具有动态 key 属性的节点更新(不包括类名和样式)
  FULL_PROPS = 1 << 4,
  // 带有监听事件的节点
  HYDRATE_EVENTS = 1 << 5,
  // 子节点顺序不会变的 Fragment
  STABLE_FRAGMENT = 1 << 6,
  // 带有 key 属性的 Fragment
  KEYED_FRAGMENT = 1 << 7,
  // 不带 key 的 Fragment
  UNKEYED_FRAGMENT = 1 << 8,
  // 仅对非 props 进行更新
  NEED_PATCH = 1 << 9,
  // 动态插槽
  DYNAMIC_SLOTS = 1 << 10,
  // 开发时放在根节点下的注释 Fragment,因为生产环境注释会被剥离
  DEV_ROOT_FRAGMENT = 1 << 11,
  
  // 以下是内置的特殊标记,不会在更新优化中用到
  // 静态节点标记(用于手动标记静态节点跳过更新)
  HOISTED = -1,
  // 可以将 diff 算法退出优化模式而走全量 diff
  BAIL = -2
}

Block 其实就相当于普通的虚拟节点加了个 dynamicChildren 属性,能够收集节点本身和它所有子节点中的动态节点。当需要更新 Black 中的子节点时,只要对 dynamicChildren 存放的动态子节点进行更新就可以了。

同时,由于每个动态节点都有 patch Flag 标记了它们的动态属性,所有更新也只需要更新动态节点标记的这些属性iu可以了。

举个例子:

<script setup>
    import { ref } from 'vue'
    
    const dynamic = ref('动态节点')
    setTimeout(() => {
        dynamic.value = '变更文本'
    }, 3000)
</script>
​
<template>
    <div>
        <div>静态节点</div>
        <P>{{ dynamic }}</P>
    </div>
</template>

这是一个简单的文本变更过程,三秒后”动态节点“会变成”变更文本“

 按照传统的 diff 流程,文本变更会生成一棵新的虚拟 DOM 树,所以对比新旧 DOM 树就需要按照虚拟DOM 的层级结构一层一层地遍历对比。上面这段模板从最外层地 div 往内一路对比过来,直接到更新 P 中地文本内容。

而有了 Block 的收集动态节点和标记动态属性的方式,在文本产生中变更需要更新的时候,只需要更新 P 节点中的文本属性。相较传统 diff 模式,简直是性能上的飞跃。大致对比如下:

 上述例子中模板的根节点就是一个 Block,因为根节点可以自上而下将它的动态子节点都收集到dynamicChildren里去,子节点需要更新的时候再把dynamicChildren抛出去做 diff 流程就行了。

那和 v-for 有啥关系?

v-for 指令渲染的是一个片段,会被标记为 Fragment 类型,同时 v-for 指令的节点会让虚拟树变得不稳定,所以需要将其编译为 Block。

所以 v-for 就是一个能够手机动态子节点得Block,它的子节点 patchFlag 一共有三种

  • STABLE_FRAGMENT 当使用 v-for 去遍历常量时,会标记为STABLE_FRAGMENT
  • KEYED_FRAGMENT 当使用 v-for 去遍历变量且绑定了 key,会标记为KEYED_FRAGMENT
  • UNKEYED_FRAGMENT 当使用 v-for 去遍历变量且没有绑定 key,会标记为KEYED_FRAGMENT

v-for去遍历常量时会被标记为 STABLE_FRAGMENT 。是因为遍历常量渲染出的子节点是不会变更顺序的,子节点中可能包含的动态子节点会走自身的更新逻辑。所以在下文中我们就可以不考虑这一类的情况。

知道以上这些知识,我们就可以继续往下了

Patch 流程

众所周知,patch 函数是 vue3 中一手承包了组件挂载和更新的,大致的 patch 流程如下:

 详细的过程就不分析了,可能需要篇几万字的长文,没关系,这里我们只要关注流程的最末端

 是不是很眼熟?

当使用 v-for 去遍历变量时,变量如果产生响应式更新就会走到这一步,可以看到,v-for带 key 的话会执行 patchKeyChildren 方法更新子节点,而不带 key 会执行 patchUnkeyedChildren 方法更新子节点。

所以我们只要弄清楚这两个方法的差异,就能知道v-for 带不带 key 的根本原因了!

话不多说,回到源码

当相同类的新旧 vnode 的子节点都是一组节点的时候,会根据有无 key 值分开处理:

const patchChildren: PatchChildrenFn = (
    ...
  ) => {
    ...
    if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 处理全部有 key 和部分有 key 的情况
        patchKeyedChildren(
          ...
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 处理完全没 key 的情况
        patchUnkeyedChildren(
          ...
        )
        return
      }
    }
}

接着我们来仔细看看这两个函数

PatchKeydChildren

有 key 的子节点数组更新会调用 patchkeyedChildren 这个方法,这就是流传甚广的“vue”核心diff算法,主要是根据节点绑定的key 值进行了以下五步处理:

 1.同步头节点

// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  i++
}

2.同步尾节点

// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = (c2[e2] = optimized
     ? cloneIfMounted(c2[e2] as VNode)
     : normalizeVNode(c2[e2]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  e1--
  e2--
}

3.新增新的节点

// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
  if (i <= e2) {
    const nextPos = e2 + 1
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized
         ? cloneIfMounted(c2[i] as VNode)
         : normalizeVNode(c2[i])),
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      i++
    }
  }
}

4.卸载多余的节点

// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

5.处理未知子序列节点

此处代码篇幅过长,且不是本文重点,就放一小部分了,感兴趣的可以自行搜索相关文章或者等我以后补发

// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
  const s1 = i // prev starting index
  const s2 = i // next starting index
​
  // 建立索引图
  const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
  for (i = s2; i <= e2; i++) {
    const nextChild = (c2[i] = optimized
                       ? cloneIfMounted(c2[i] as VNode)
                       : normalizeVNode(c2[i]))
    if (nextChild.key != null) {
      if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
        warn(
          `Duplicate keys found during update:`,
          JSON.stringify(nextChild.key),
          `Make sure keys are unique.`
        )
      }
      keyToNewIndexMap.set(nextChild.key, i)
    }
  }
  
  // 更新和移除旧节点
  ...
  // 移动和挂载新节点
  ...

可以看到,vue 对有 key 的元素更新下了这么大的功夫去处理,目的式为了对没有发生改变的节点进行复用。DOM 的频繁创建和销毁对性能不友好,所以通过 key 值复用 DOM 可以尽可能地减小这方面的性能开销。

那么,那些没有 key 的节点数组怎么更新呢?

patchUnkeydChildren

const patchUnkeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    ...
  }

没有 key 的子节点数组更新会调用 patchUnkeydChildren 方法,它的实现就简单很多了:

总共只有两步:给公共长度部分节点打补丁(oatch)、根据新旧子节点数组长度移除或挂载节点

1.公共长度部分节点打补丁

首先获取新、旧节点数组的长度和公共长度部分

c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)

接着遍历共长部分,对共长部分的新子节点直接调用 patch 方法更新

let i
for (i = 0; i < commonLength; i++) {
  const nextChild = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  patch(
    c1[i],
    nextChild,
    container,
    null,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized
  )

这里就是文章开头提到的就地更新,没有对 DOM 节点直接进行创建和删除,而是通过 patch打补丁的方式对对应索引位置的新节点的一些属性直接进行更新。

2.根据长度移除多余的节点或者挂载新节点

if (oldLength > newLength) {
  // 旧子节点数组更长,将多余的节点全部卸载
  unmountChildren(
    c1,
    parentComponent,
    parentSuspense,
    true,
    false,
    commonLength  // 起始索引
  )
} else {
  // 新子节点数组更长,将剩余部分全部挂载
  mountChildren(
    c2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized,
    commonLength  // 起始索引
  )
}

注意:unmountChildrenmountChildren 会传入 commonLength 作为卸载/挂载节点的起始索引遍历到节点尾部

具体流程如下:

 相比较直接用新节点覆盖旧节点来说,这种处理方式也属于一种性能上的优化,同样式减少了DOM的创建和销毁,对相同索引位置的新旧节点“就地更新”, 然后再处理剩余节点。

对比

代码描述可能不是很直观,所以用图片展示:

假设我们要将旧子节点更新为如下的新子节点

 那么两种方式的更新方式分别是这样的

 道理我都懂,所以这两种更新方式究竟会带来什么影响?

举个例子就明白啦

<script lang="ts" setup>
    import { reactive } from 'vue'
​
    const list = reactive([1, 2, 3, 4, 5])
​
    // // 删除索引为 2 的输入框
    const deleteInput = () => {
        list.splice(2, 1)
    }
​
</script>
​
<template>
    <div v-for="item in list">
        <input type="text">
    </div>
    <button @click="deleteInput">删除</button>
</template>

有一个v-for生成的输入框列表,先不绑定 key ,点击删除按钮后会将索引为2的输入框删除

 我们将每个输入框中输入它们各自位置的索引,然后点击删除试一试

 

 神奇吧,不用怀疑 splice 的用法出错,这就是更新过程就地更新会带来的后果:DOM的上一次的状态也被留在了原地

我们加上 key 再试试

<div v-for="item in list" :key="item">
  <input type="text">
</div>

 效果就正常了。

所以我们可以得出,没有 key 的更新过程,为了减少 dom 重复创建和销毁的开销,采用了就地更新的策略,但是这种策略会让 dom 的状态得以留存,就会出现以上在这种“更新不正确的”渲染效果,所以 vue 官方很贴心的提示了我们:默认模式(不加key)只适用于列表渲染输出的结果不依赖子组件状态或者临时DOM状态(例如表单输入值)的情况。

总结

问:

v-for 遍历列表为什么要加 key?

答:

Vue 在处理更新同类型 vnode 的一组子节点的过程中,为了减少 DOM 频繁创建和销毁的性能开销:

对没有 key 的子节点数组更新调用的是patchUnkeyedChildren这个方法,核心是就地更新的策略。它会通过对比新旧子节点数组的长度,先以比较短的那部分长度为基准,将新子节点的那一部分直接 patch 上去。然后再判断,如果是新子节点数组的长度更长,就直接将新子节点数组剩余部分挂载(mount);如果是新子节点数组更短,就把旧子节点多出来的那部分给卸载掉(unmount)。所以如果子节点是组件或者有状态的 DOM 元素,原有的状态会保留,就会出现渲染不正确的问题

有 key 的子节点更新是调用的patchKeyedChildren,这个函数就是大家熟悉的实现核心 diff 算法的地方,大概流程就是同步头部节点、同步尾部节点、处理新增和删除的节点,最后用求解最长递增子序列的方法区处理未知子序列。是为了最大程度实现对已有节点的复用,减少 DOM 操作的性能开销,同时避免了就地更新带来的子节点状态错误的问题。

综上,如果是用 v-for 去遍历常量或者子节点是诸如纯文本这类没有”状态“的节点,是可以使用不加 key 的写法的。但是实际开发过程中更推荐统一加上 key,能够实现更广泛场景的同时,避免了可能发生的状态更新错误,我们一般可以使用 ESlint 配置 key 为 v-for 的必需元素。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值