v-for 到底为啥要加上 key?

·  阅读 4591
v-for 到底为啥要加上 key?

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

看了一些讲解 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属性,能够收集节点本身和它所有子节点中的动态节点。当需要更新 Block 中的子节点时,只要对dynamicChildren存放的动态子节点进行更新就可以了。

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

举个例子:

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

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

2022-09-06 16.16.35.gif

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

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

image-20220906170236352.png

上述例子中模板的根节点就是一个 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 流程如下:

image.png

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

image.png

是不是很眼熟?

当使用 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
      }
    }
}
复制代码

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

patchKeyedChildren

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

image-20220904180054473.png

  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++
    }
    复制代码
  1. 同步尾节点

    // 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--
    }
    复制代码
  1. 新增新的节点

    // 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++
        }
      }
    }
    复制代码
  1. 卸载多余的节点

    // 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++
      }
    }
    复制代码
  1. 处理未知子序列节点

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

    // 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 的节点数组怎么更新呢?

patchUnkeyedChildren

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 的子节点数组更新会调用 patchUnkeyedChildren 方法,它的实现就简单很多了:

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

  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作为卸载/挂载节点的起始索引遍历到节点尾部。

整体流程如下:

image-20220904182409461.png

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

对比

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

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

image-20220905162301320.png

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

image-20220905171200545.png

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

举个例子就明白啦

<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的输入框删除

image-20220905170425440.png

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

2022-09-05 17.05.17.gif

image-20220905163630653.png

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

我们加上 key 再试试

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

2022-09-05 17.24.17.gif

效果就正常了。

所以我们可以得出,没有 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 的必需元素。

分类:
前端
收藏成功!
已添加到「」, 点击更改