从 vue3 源码中探究中 key 的作用

本文正在参加「金石计划 . 瓜分6万现金大奖」

在 vue2 或 vue3 的文档中,都可以看到这么一句话:

建议尽可能在使用 v-for 时提供 key attribute。

记得几年前的一次面试我就被问到过这么个问题 —— 为什么在使用 v-for 进行列表渲染时要提供 key 属性?我当时的回答是因为文档建议了,于是面试官建议我回去等通知。今天,我们就去看看 vue3 的源码,找寻文档为什么会这么建议的原因,下载或调试 vue3 源码的方法参见《将 vue3 源码下载到本地调试》

当我们往一个数组中,插入一个新元素时,vue 会调用定义于 packages\runtime-core\src\renderer.ts 的 patchChildren() 方法,该方法会首先判断下我们是否有提供 key 属性,然后再调用不同的方法处理:

const patchChildren: PatchChildrenFn = (...) => {
  ...
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // 提供了 key 属性
      patchKeyedChildren(...)
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // 没有提供 key 属性
      patchUnkeyedChildren(...)
      return
    }
  }

有 key

如果提供了 key,也就是 PatchFlags.KEYED_FRAGMENT 为 true,则调用 patchKeyedChildren() 方法,方法里主要做了 5 件事,其源码也做了注释:

  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, ...)
  } else {
    break
  }
  i++
}

在一个 while 循环中,从旧 VNode 列表中取出 n1 节点,从新 VNode 列表中取出 n2 节点,然后传给 isSameVNodeType() 判断 n1 和 n2 的类型是否相同,该函数的返回值为:

n1.type === n2.type && n1.key === n2.key

由此可以理解官方文档中的:

key 特殊 attribute 主要用做 Vue 的虚拟 DOM 算法的提示,以在比对新旧节点组时辨识 VNodes。

如果发现新旧节点的类型与 key 值都相同,则将它们传给 patch() 进行 diff 处理;
如果发现它们的类型或 key 值不同则执行 break 终止循环。图示如下:
image.png

  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, ...)
  } else {
    break
  }
  e1--
  e2--
}

与第一步做的事情类似,只不过是从尾部开始遍历,直至遇到类型或 key 值不同的节点终止循环。

  1. 判断是否需要挂载新节点
// 3. common sequence + mount
if (i > e1) {
  if (i <= e2) {
    // ... 省略部分代码
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized
         ? cloneIfMounted(c2[i] as VNode)
         : normalizeVNode(c2[i])),
       ...
      )
      i++
    }
  }
}

当前两步执行完毕,如果发现新节点数量多于旧节点,则将 null 作为第一个参数传给 patch 函数,进行挂载(mount)操作。图示如下:
image.png

  1. 判断是否需要卸载旧节点
// 4. common sequence + unmount
else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

当前两步执行完毕,如果发现新节点数量少于旧节点,则进行卸载(unmount)操作。

  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 {...}

这种情况处理起来比较复杂,总结起来就是 vue 会借助 key 值,跟踪每个节点的身份,对现有元素进行重用和重新排序。

没有 key

如果没有提供 key,则调用 patchUnkeyedChildren() 方法,其部分源码如下所示:

const patchUnkeyedChildren = (c1: VNode[], c2: VNodeArrayChildren,  ...) => {
  // ... 省略部分代码
  const oldLength = c1.length // 获取旧节点数组的长度
  const newLength = c2.length // 获取新节点数组的长度
  const commonLength = Math.min(oldLength, newLength)
  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, ...)
  }
  if (oldLength > newLength) {
    // remove old
    unmountChildren(c1, ...)
  } else {
    // mount new
    mountChildren(c2, ...)
  }
}

大致过程是先获取新旧节点数组的长度,然后用 Math.min() 取它们中较小的那个,作为 for 循环的判断依据;再从下标值为 0 开始,一个个依次从新旧节点数组取出 VNode 传给 patch() 进行 diff;
最后判断下新旧节点的数量,如果旧节点多就卸载,新节点多则创建并挂载。

可以看出,在不提供 key 值时,在一些情况下会使得 diff 效率比提供了 key 值的时候低很多 —— 比如下图这样往数组中间位置新增节点的情况:
image.png
明明 c 节点并没有改变,但是因为是按顺序依次 patch,所以 oldVnode 中的 c 节点会和 newVnode 中的 d 节点进行 patch,然后再新增一个节点 c。

感谢.gif
点赞.png
本文正在参加「金石计划 . 瓜分6万现金大奖」

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容