浅析为什么推荐使用唯一不变的 id 作为 key、使用index作为key会导致的问题(复用错误、组件类型propsData或文本node触发重新渲染等问题)

  所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 index 或 random 作为 key

  也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用index 作为 key 会有什么问题?假如使用 random 作为 key 会有什么问题?假如使用一个唯一不变的 id 作为 key 有什么好处呢?

  这道题目,表面上看起来是考察我们对同级比较过程中 diff 算法的理解,唯一不变的 key 可以帮助我们更快的找到可复用的 VNode,节省性能开销,使用 index 作为 key 有可能造成 VNode 错误的复用,从而产生 bug ,而使用 random 作为 key 会导致VNode 始终无法复用,极大的影响性能。

  这么回答有问题么?没有问题。但是假如这道题目满分100,我只能给你99分。还有 1分,涉及到 Vue 更新流程中的一点点细节,若不理清,可能在实际的业务场景中给我们造成困扰。啥困扰呢?

一、index 作为 key ,假如我们删除某一条,结果会是啥呢?

<template>
  <div id="app">
    <div v-for="(item, index) in data" :key="index">
      <Child />
      <button @click="handleDelete(index)">删除这一行</button>
    </div>
  </div>
</template>

<script>

export default {
  name: "App",
  components: {
    Child: {
      template: '<span>{{name}}{{Math.floor(Math.random() * 1000)}}</span>',
      props: ['name']
    }
  },
  data() {
    return {
      data: [
        { name: "小明" },
        { name: "小红" },
        { name: "小蓝" },
        { name: "小紫" },
      ]
    };
  },
  methods: {
    handleDelete(index) {
      this.data.splice(index, 1);
    },
  }
};
</script>

  看结果可以观察到:虽然我们删除的不是最后一条,但最终却是最后一条被删除了。看起来很奇怪,但是假如你了解过 Vue 的 diff 流程,这个结果应该是可以符合你的预期的。

二、diff

  大段的列源码,会增加我们的理解负担,所以我把 Vue更新流程简化成一张图:

  通常来讲,我们说 Vuediff 流程,指的就是 patchVnode ,其中 updateChildren 就是我们说的同层比较,其实就是比较新旧两个 Vnode 数组。

  Vue 会声明四个指针变量,分别记录新旧 Vnode 数组的首尾索引,通过首尾索引指针的移动,根据新头旧头、新尾旧尾、旧头新尾、旧尾新头的顺序,依次比较新旧 Vnode ,若不能命中 sameVnode,则将oldVnode.key 维护成一个 map, 继续查询是否包含newVnode.key ,若命中 sameVnode ,则递归执行 patchVnode。若最终无法命中,说明无可复用的 Vnode ,创建新的 dom 节点。

  若 newVnode 的首尾指针先相遇,说明 newVnode 已经遍历完成,直接移除 oldVnode 多余部分,若 oldVnode 的首尾指针先相遇,说明 oldVnode 已经遍历完成,直接新增 newVnode 的多余部分。

三、使用 index 作为 key 会有什么问题

  上面我们讲,判断新旧 Vnode 是否可以复用,取决于 sameNode 方法,这个方法非常简单,就是比对 Vnode 的部分属性,其中 key 是最关键的因素

function sameVnode (a, b) {
    return (
      a.key === b.key &&
      a.asyncFactory === b.asyncFactory && (
        (
          a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)
        ) || (
          isTrue(a.isAsyncPlaceholder) &&
          isUndef(b.asyncFactory.error)
        )
      )
    )
  }

  我们再回到上面的栗子,看看是哪里出了问题。上面代码生成的 VNode 大约是这样的:

[
  {
    tag: 'div',
    key: 0,
    children: [
      {
        tag: VueComponent, 
        elm: 408, // 这个Vnode对应的真实dom是408
      },
      {
        tag: 'button'
      }
    ]
  },
  {
    tag: 'div',
    key: 1,
    children: [
      {
        tag: VueComponent,
        elm: 227, // 这个Vnode对应的真实dom是227
      },
      {
        tag: 'button'
      }
    ]
  }
  ...
]

// 我们删除第一条数据,新的 VNode 大约是这样的:
[
  {
    tag: 'div',
    key: 0,
    children: [
      {
        tag: VueComponent,
        elm: 227, // 这个Vnode对应的真实dom是227
      },
      {
        tag: 'button'
      }
    ]
  },
  {
    tag: 'div',
    key: 1,
    children: [
      {
        tag: VueComponent,
        elm: 324, // 这个Vnode对应的真实dom是324
      },
      {
        tag: 'button'
      }
    ]
  }
  ...
]

  我们人肉逻辑 一下这两个 Vnode 数组,由于 key 都是0,所以比较第一条的时候,就会命中 sameNode ,导致错误复用,然后updateChildren ,子节点的 Vnode 依然会命中 sameVnode ,同理,第二、三条均会命中 sameVnode ,而直接错误复用其关联的真实 dom节点,所以我们明明删除的是第一条,UI表现却是最后一条被删除了。

  那么到这里就结束了么?当然没有,因为很多小伙伴在刚接触 Vue 的时候,也用过 index 作为 key,部分牛逼的项目甚至已经上线了,似乎也没人来找麻烦。why?

三、为什么我用 index 作为 key 没出现问题

  如果我把代码改成这样,再删除某一条,会是什么结果呢?

<template>
  <div id="app">
    <div v-for="(item, index) in data" :key="index">
      <Child :name="`${item.name}`" />
      <button @click="handleDelete(index)">删除这一行</button>
    </div>
  </div>
</template>

  看结果观察却是正常的。为什么?我们明明把 Vue 的更新流程捋清楚了,用 index 作为 key 会导致 Vnode 错误复用啊,怎么这里表现却正常了呢?

  具体原因:组件类型的 Vnode ,在 patchVnode 的过程中会执行 prePatch 钩子函数,给组件的 propsData 重新赋值,从而触发 setter ,假如propsData 的值有变化,则会触发 update ,重新渲染组件。

  我们可以再人肉逻辑 一下,这次我们删除的是第二条,因为key 一致,新的 Vnode 数组依然会复用旧的 Vnode 数组的前三条,第一条Vnode 是正确复用,组件的 propsData 未发生变化,不会触发 update,直接复用其关联的真实 dom 节点,但是第二条 Vnode 是错误复用,但是组件的 propsData 发生变化,由小红变成了小蓝,触发了 update,组件重新渲染,因此我们看到其实连 random 都发生了变化,第三条同理。

  那么到这里就结束了么?其实还没有,比如我们再改一下代码:

<template>
  <div id="app">
    <div v-for="(item, index) in data" :key="index">
      <span>{{item.name}}</span>
      <button @click="handleDelete(index)">删除这一行</button>
    </div>
  </div>
</template>

  看结果观察也是正常的。为什么?这次我们没有组件类型 Vnode ,不会执行 prePatch,为啥表现还是正常的呢?

  再观察一下上面的更新流程图,文本类型的 Vnode ,新旧文本不同的时候是会直接覆盖的。

  到这里,我们已经完全明白,列表渲染的场景下,为什么推荐使用唯一不变的 id 作为 key了。

  抛开代码规范不谈,即使某些场景下,问题并未以 bug 的形式暴露出来,但是不能复用、或者错误复用 Vnode ,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!

作者:Fatty
链接:https://juejin.cn/post/6999932053466644517

posted @ 2021-08-29 21:29  古兰精  阅读(406)  评论(0编辑  收藏  举报