vue3 key对diff的影响

前言

这是vue3系列源码的第十一章,使用的vue3版本是3.4.15

背景

这一节中,我们看一下vue3diff 的实现,以及keydiff过程的影响

前置

js 复制代码
// app.vue
<template>
  <div>
    <button @click="check">点击</button>
  </div>
  <div>
    <textCom :data="item" v-for="(item, index) in aa"/>
  </div>
 </template>
 <script setup>
 import { ref} from 'vue' 
 import textCom from './text.vue'

 const aa = ref([1,2,3])
 const check = () => {
  aa.value = [2,1,3]
 }
 </script>

app组件中对一个数组进行循环,渲染子组件。

这里的text 组件只需要接收一个传进来的props,并渲染一下

js 复制代码
// text.vue
<template>
<div>{{ data }}</div>
</template>
<script setup>
  defineProps({
    data: Number
  })
</script>

没有key

首先我们按照上面的写法,就是循环的时候没有设置key, 看一下,这个时候的更新过程。

patchChildren

js 复制代码
  const patchChildren: PatchChildrenFn = (
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    namespace: ElementNamespace,
    slotScopeIds,
    optimized = false,
  ) => {
          const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fast path
    if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // this could be either fully-keyed or mixed (some keyed some not)
        // presence of patchFlag means children are guaranteed to be arrays
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized,
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // unkeyed
        patchUnkeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized,
        )
        return
      }
    }
    ...
  }

patchChildren 函数中,主要是针对了不同的情况,进行了不同的patch处理。

在我们的这个例子中,它会先根据有没有绑定key 进行判断,我们这里没有绑定key ,进入patchUnkeyedChildren函数。

patchUnkeyedChildren

js 复制代码
const patchUnkeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    namespace: ElementNamespace,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ) => {
    c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    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,
        container,
        null,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        optimized,
      )
    }
    if (oldLength > newLength) {
      // remove old
      unmountChildren(
        c1,
        parentComponent,
        parentSuspense,
        true,
        false,
        commonLength,
      )
    } else {
      // mount new
      mountChildren(
        c2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        optimized,
        commonLength,
      )
    }
  }

这个函数其实就干了两件事情:

  • 依次按顺序遍历新老节点,进行patch
  • 对多出来的老节点进行unmount ,对多出来的新节点进行mount

所以我们可以看见,当我们不绑定key 的时候,其实更新过程没有涉及到diff算法,就是一种暴力的更新过程。

那么下面,我们看看绑定了key的过程。

key 为 index

我们首先看一下我们在开发中可能的一种写法,就是把index 绑定为key

我们先改造一下app.vue

js 复制代码
 <textCom :data="item" v-for="(item, index) in aa" :key="index"/>

这次,我们绑定了key ,在patchChildren 函数中,走到了patchKeyedChildren函数中。

patchKeyedChildren

这个函数就是diff 过程的核心函数,vue3的diff算法就是这个函数。

js 复制代码
  // can be all-keyed or mixed
  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    parentAnchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    namespace: ElementNamespace,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ) => {
         let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index

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

diff过程针对不同的情况,做了不同的处理,我们一点一点看。

isSameVNodeType

这里我们要先看一下工具函数,判断新旧节点是否相同类型。

js 复制代码
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
  ) {
    // #7042, ensure the vnode being unmounted during HMR
    // bitwise operations to remove keep alive flags
    n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
    n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
    // HMR only: if the component has been hot-updated, force a reload.
    return false
  }
  return n1.type === n2.type && n1.key === n2.key
}

这里主要的判断依据就是typekey

那么type是什么:

type其实是一个包含了几个属性的对象,基本上描述了一个组件的全部内容。

那么key 就是我们绑定的key.

回到我们这个例子中,当我们用index 来绑定key 的时候,我们会发现,虽然数组变了,但是新旧节点的key是没有变化的。

所以,在isSameVNodeType 函数的判断中,新旧节点是符合条件的,那么就会直接进入patch

一直到遍历完新旧节点数量少的那个为止。

js 复制代码
    // 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,
            namespace,
            slotScopeIds,
            optimized,
          )
          i++
        }
      }
    }

    // 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++
      }
    }

最后,就和没有绑定key的过程一样,新节点少的,进行unmount ,新节点多出来的,进行patch

那么,我们可以得出一个结论:

当我们用数组的index 作为key 的时候,在遇到像排序这种变化的时候,和没有绑定key是一样的效果。

id 为 key

下面我们再看看,我们不用indexkey ,用唯一标志符作为key ,比如iddiff过程是什么样的。

这里,我们再次改造一下app.vue文件

js 复制代码
<textCom :data="item" v-for="(item, index) in aa" :key="item"/>
...
const check = () => {
  aa.value = [3,1,2,4]
 }

这里,我们用item 本身代替id

此时,由于第一项的key 不一样,不满足isSameVNodeType 的条件,所以直接break出来,进入下一个判断。

js 复制代码
// 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,
          namespace,
          slotScopeIds,
          optimized,
        )
      } else {
        break
      }
      e1--
      e2--
    }

上面是第二个判断,从两边的尾部开始对比,如果是是sameVnode ,进行patch操作, 这里仍然不符合

js 复制代码
    // 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,
            namespace,
            slotScopeIds,
            optimized,
          )
          i++
        }
      }
    }

第三个和第四个判断,就是上面keyindex的时候,提到的,当新旧节点数量不相同的时候,就会进行挂载或者卸载操作

如果上面的四个判断都经过了,还是有没有处理掉的节点,那么此时就到了diff算法的核心部分,对更加一般情况的处理。

  • 将存在于旧节点,但是不存在于新节点的节点卸载,新旧都有的节点进行更新
  • 得到一个数组,数组是按照新节点的顺序,保存了老节点的索引+1
  • 找到最长公共子序列
  • 从剩余节点的最后一个往最前一个遍历,遇到新的节点就进行增加
  • 以最长子序列的位置为基准,移动其他节点,做到最大程度的复用

最长公共子序列

这里最难的部分是最长公共子序列的寻找。

这里会首先得到一个数组。

newIndexToOldIndexMap:

newIndexToOldIndexMap[newIndex - s2] = i + 1

其中i 代表老节点中的索引。也就是说,这个数组存下了老节点新节点中的顺序。

那么要找到最小的变化,尽可能多的复用节点,就是要找到新老节点之间的最多的连续节点,节点的连续反映到索引上就是索引的递增,所以其实就是在寻找这个数组的最长递增子序列。

这里的核心算法采用了动态规划 + 二分法

可以参考300.最长递增子序列,但是这里得到的数组只有长度是准确的,实际的元素并不准确

所以源码做了一些修改,保存了真实的元素。

总结

以上就是diff 过程的全部内容。我们看了一下diff的各个过程

回到标题,keydiff过程的影响是很大的。

我们讨论了在例子情况下,用index 当做key 的弊端,有时候和没有加key 的效果是一样,这样会加大diff 过程的开销,不符合复用原则,而已在有的情况下还会发生错误。

所以一个唯一且固定的key对于vue3来说是很重要的,能很大提升源码运行的效率。

相关推荐
GISer_Jing1 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲7 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5817 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter8 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry9 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js
柳杉9 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化