我用index作为key也没啥问题啊 ????

Proud lion 前端 2021-08-26

前言

所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 indexrandom 作为 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>
复制代码

看结果

我用index作为key也没啥问题啊 ????

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

diff

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

我用index作为key也没啥问题啊 ????

通常来讲,我们说 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也没啥问题啊 ????

第二步:

我用index作为key也没啥问题啊 ????

第三步:

我用index作为key也没啥问题啊 ????

第四步:

我用index作为key也没啥问题啊 ????

第五步:

我用index作为key也没啥问题啊 ????

第六步:

我用index作为key也没啥问题啊 ????

理论上,只要你滑动的足够快,这几张图就可以动起来😊

上面描述updateChildren过程的图片均摘自 Vue技术揭秘 组件更新章节,建议大家翻阅原文

我尝试了半天实在做不出来动画,同时感觉这几张图已经可以带给我们足够直观的感受了,所以直接搬运了

侵删

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

看结果

我用index作为key也没啥问题啊 ????

法克,我们明明把 Vue更新流程捋清楚了,用 index 作为 key 会导致 Vnode 错误复用啊,怎么这里表现却正常了呢?

我们再看一下更新流程简化图:

我用index作为key也没啥问题啊 ????

组件类型的 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>
复制代码

看结果

我用index作为key也没啥问题啊 ????

这次我们没有组件类型 Vnode ,不会执行 prePatch,为啥表现还是正常的呢?

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

到这里,我们已经完全明白,列表渲染的场景下,为什么推荐使用唯一不变的 id 作为 key了。抛开代码规范不谈,即使某些场景下,问题并未以 bug 的形式暴露出来,但是不能复用、或者错误复用 Vnode ,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!

最后的1分

纸上得来终觉浅,绝知此事要躬行

我第一次读完 Vue2 源码的时候,以为自己已经清晰的明白了这部分知识,直到团队里的小伙伴拿着一个纯文本类型的列表来质问我

不得已仔细 debug 了一遍更新流程,才算解开了心中疑惑,补上了这 1分 的缺口

引用如下:

Vue2源码

Vue技术揭秘

作者:Fatty

链接:https://juejin.cn/post/6999932053466644517

来源:掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Apipost 私有化火热进行中

评论