【vue源码学习】面试官:为什么在Vue列表组件中要写key,有什么作用?

629 阅读9分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

在写Vue.js应用程序时,为什么要在列表组件中写key?有什么作用?为什么不推荐使用index作为key?这几个问题在面试时,常常被面试官问起,本篇文章将从原理上深入剖析解惑。

本篇文章是该专栏的第二篇文章。往期文章:

一、【Vue源码学习】深入理解watch的实现原理 —— Watcher的实现 (juejin.cn)

正文

当数据发生改变时,vue是怎么更新结点的? —— diff算法详解

要知道,当我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,这样会造成很大的开销。那么有没有可能不更新全部的DOM,只更新发生改变的DOM呢?这里就是diff算法的操刀之处!

先来看一个经典的示例:

<ul> 
    <li>1</li> 
    <li>2</li>
</ul>

我们先根据真实DOM生成一颗virtual DOMvirtual DOM就是将真实的DOM的数据抽取出来,以对象的形式模拟树形结构)

上述代码的Virtual DOM Tree 大致如下:

{ 
    tag: 'ul', 
    children: [ 
                { 
                    tag: 'li', 
                    children: [ { vnode: { text: '1' }}] 
                }, 
                { 
                    tag: 'li', 
                    children: [ { vnode: { text: '2' }}] 
                },
              ] 
}

为什么我们要使用虚拟dom,而不是直接使用真实的dom呢?

当数据发生改变时,直接操作真实的dom会引出重绘,在大量修改的情况下,会拉低性能。而使用虚拟dom会更快,通过批量的内存操作(diff算法),找到发生变化的节点,然后再去操作真实的dom,完成视图更新,然后把结果输出到浏览器,在大量修改的情况下性能更优秀,替换效率高。

注:VNodeoldVNode都是对象

小思考(欢迎来评论区讨论交流)

严格来说:

以上所说的好处都只存在于特殊的场景大量修改数据的情况下

而在正常的操作下,并不会有人闲的没事去大量修改数据,有时候仅仅需要修改一两个简单的dom,却要去使用虚拟dom走一遍diff算法的逻辑,在这种场景下,使用虚拟dom的性能还会比直接使用真实dom更优秀吗?

回到示例中:

此时,我们模拟数据发生修改,即将两个li中的数据就行交换,交换后的vnode如下:

{
    tag: 'ul',
    children: [{
            tag: 'li',
            children: [{
                vnode: {
                    text: '2'
                }
            }]
        },
        {
            tag: 'li',
            children: [{
                vnode: {
                    text: '1'
                }
            }]
        },
    ]
}

数据修改后会发生什么?

简单来讲:

virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后VnodeoldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode

下面来深入了解这个过程👇👇👇👇:

为了使后面逻辑更容易理解,这里先来简单回顾一下vue的响应式原理过程

data.png

如上图所示,整个响应式原理的步骤为:

step1:在vnode阶段,数据发生改变

step2:data响应式数据更新

step3:data的改变会通知到观察者Watche。

step4:触发了渲染Watcher的回调函数vm._update(vm._render())去驱动试图更新

图中的整个过程,如果看过第一篇文章 【Vue源码学习】深入理解watch的实现原理 —— Watcher的实现 (juejin.cn),应该能对这个过程有较为清晰的认识,

其实,在step4中的vm._render()生成的就是vnode,vm._update 会带着新的 vnode 去走 __patch__ 过程。

下面我们直接进入 ul 这个 vnode 的 patch 过程(对比新旧节点是否为相同类型的节点):

1.png

如图中所示,新旧节点的对比遵循的规则是:同层级比较

通过对新旧vnode的每一层的对应节点,进行下面的比较判断

  • 节点类型是否相同:
    • 如果类型不相同,直接销毁旧的vnode,渲染新的vnode
    • 如果类型相同,尽可能的做节点的复用(在示例中,tag都是ul,进入👇👇👇)
      • 新vnode是不是文字vnode:
        • 如果是,直接调用浏览器的dom api 把节点直接替换掉文字内容即可。
        • 如果不是开始对子节点对比(开始对比示例中的li 👇👇👇)
      • 新旧vnode是否都有children
        • 如果oldVnode没有,newVnode有:在原来的dom上添加新的子节点
        • 如果oldVnode有,而newVnode没有:在原来的dom上删除旧子节点
        • oldVnode和newVnode都有(即都存在li子节点列表,下面进入diff的核心,即新旧节点的diff对比环节👇👇👇)

在讲对比过程之前,先来了解源码中这个过程比较重要的函数:sameVnode:

    function sameVnode (a, b) { 
        return ( 
            a.key === b.key &&  
                ( 
                    a.tag === b.tag && 
                    a.isComment === b.isComment &&
                    isDef(a.data) === isDef(b.data) &&
                    sameInputType(a, b) 
                )
        )
  }

它是用来判断节点是否可复用的关键函数。

回到diff的核心对比过程:

初始情况下,先用四个指针分别指向新旧节点的首尾,然后根据这些指针,在while循环中不停的对新旧节点的两端进行对比,对比后两端的指针不断向内部收缩,直到没有节点可以对比为止。

每一轮的对比过程:

  1. 旧首节点和新首节点用 sameNode 对比。
  2. 旧尾节点和新尾节点用 sameNode 对比
  3. 旧首节点和新尾节点用 sameNode 对比
  4. 旧尾节点和新首节点用 sameNode 对比
  • 如果单个vnode中又有children子列表,那么就回再去走一遍上面的diff children的过程
  • 如果以上有一项命中,就会递归进入patchVnode
  • 如果以上所有逻辑都匹配不到,就会维护一个map表,再将所有旧子节点的key做key。然后再用新vnode的key区找出在就旧节点中可以复用的位置。

key有什么作用?为什么要用它?

对整个diff算法逻辑有了大致的了解后,再来思考这个问题就有了大致的方向了:

看到上面提到过的sameNode函数:

可以发现如果传入的vnode的key不相同的话,就能提前结束掉sameNode函数的逻辑,直接判定为false,这样在一定程度上能够提高新旧vnode对比的效率。另外,如果所有的vnode都有属于自己唯一标识的key值,那么在进行新旧vnode对比时,可以直接维护一个map,将旧节点的key作为键,然后使用新vnode的key去map中查找,从而避免复杂的循环。这也是经典的空间换时间的思想,这样整个diff过程会更快。

为什么不要使用index作为key?

既然上面说了,key最主要的作用就是用于作为vnode的唯一标识,那么为什么不能使用index作为key呢?下面从两个角度来解答:

这里先模拟一个场景,在一个ul中,通过对数据源中的数组[2,5,3,6]进行v-for循环生成多个li,并用数组的index作为每一个li的key值:

  1. key:0 , value:2
  2. key:1 , value:5
  3. key:2 , value:3
  4. key:3 , value:6

当我们对数组进行反转的修改操作时,即数组变为[6,3,5,2]:

不难想到,此时循环生成的每个新vnode对应的key,value为:

  1. key:0 , value:6
  2. key:1 , value:3
  3. key:2 , value:5
  4. key:3 , value:2

矛盾显而易见,本来按照最合理的逻辑来讲,新的第一个vnode完全可以直接复用旧的第四个vnode,因为它们应该是同一个vnode,所有的数据也是没有变化的。

然而上述修改会导致的后果是:当子节点在进行diff的过程中,旧首节点和新首节点用sameNode对比,这一步的逻辑会命中,从而进行patchVnode操作,检查props有没有变更,这里自然是变更了,所以会通过_props.num = 3去更新这个值,并触发视图重新渲染等一系列操作。

这意味着本可以直接复用的vnode,却还是要去进行一系列的重新更新,会产生巨大的性能消耗。而正是因为使用了index作为key,导致diff的所有优化全部失效。

当我们对数组进行删除操作时,删除后的数组变为[5,3,6]

则新vnode的key,value对应情况为:

  1. 被删除了
  2. key:0 , value:5
  3. key:1 , value:3
  4. key:2 , value:6

下面来走一边diff的逻辑,这关键函数sameNode看来,它感知不到子组件内部的实现,从上述的sameNode函数中就能看到,sameNode只会只会通过判断key、 tag是否有data的存在(不关心内部具体的值)是否是注释节点是否是相同的input type,来判断是否可以复用这个节点。

所以此时在diff过程中,旧的1,2,3和新的2,3,4完全相同,直接复用,最后旧vnode多出了一个4,就会把旧的4删掉。这样的话,本来我们只是应该把旧的1删掉,结果把旧的4删掉了。

由此可见,使用index作为key,一旦数据发生变化,会给我们带来毁灭性的错误。

总结

回到问题本身:

  • key有什么作用?

    key可以用来做列表组件的唯一标识符,可以提高diff算法逻辑的效率,主要体现在:1、如果新旧vnodekey值不同,sameNode函数会直接判定为不可服用。2、有了key,可以直接维护一个map,将旧子节点的key作为键,再用新vnode的key去map中寻找更新的位子,能够加快查找的过程。

  • 为什么不能用index作为key? 因为如果循环的数组发生改变,如反转、删除操作,新vnode的key依然是按0,1,2的顺序排列下来的,会导致vue复用错误的节点,走到patchVnode才会发现,又会去一遍数据更新驱动试图更新的操作,diff的所有优化都会失效。

结语

本篇文章主要是介绍了diff过程,以及为什么列表组件中要用key? 为什么不能用index作为key? 这两个问题的原因。

本文收录在vue源码学习专栏下,本专栏也是基于笔者自己的学习理念:学习要有输入和输出。而写作的,作为该阶段笔者学习vue源码的输出,希望同样能给你答疑解惑。让读这篇文章的你有所收获,既是此次分享最大的意义。感谢你的关注!!!

如果有不清楚的地方或者发现文章的错误也欢迎各位在评论区讨论和指出。

参考文献:

希望与你共同进步!!!