在vue官网中是这样介绍key在列表渲染元素中的作用的
但是key是如何做到“最大限度减少动态元素并且尽可能的尝试复用/再利用相同类型元素”的呢?刚开始使用vue框架的同学有的喜欢用v-for的index系数来作为key的值,这样和用元素的唯一id作为key有什么区别呢?这次来给大家讲解。
上篇文章结合源码给大家讲解了vue2.0的生命周期和响应式原理,模板一开始要经过编译成virtualDOM,之后vue在渲染VirtualDOM时通过get函数进行依赖收集和当数据改变时通过set函数进行派发更新。本次我们来讨论派发更新时vue是怎样通过新老virtualDOM的对比,最后更新实际DOM的。
为了更加生动一些,首先我们来看一个简单的网页demo:
代码如下,需要注意的是在循环列表中的key属性被赋值为了列表循环项目的index序号:
第一次渲染时,网页打出log如下:
点击香蕉的删除按钮后,发现重新load了苹果这一项:
但是如果把key属性换成item.id,网页就不会再重新load苹果这一项:
那么为什么把key设成了index后,浏览器就需要重新load苹果这一项呢?本次我们结合源码一边调试一边来进行讲解。
一、编译
正如人体一样,作为一个有机的整体,各个系统其实都是有相关性的。为了讲清楚vue的diff算法,我们需要先来简要了解下vue是如何构建出virtualDOM的。vue是靠render function来生成virtualDOM的,而产生render function的过程叫做编译(compile)主要分三个步骤:
其中parse会把template模板转换成抽象语法树(AST),而且AST中的节点可以分为3种类型:标签节点、表达式节点和静态字符串节点。下面来一一讲解。
对于标签节点我们设定的type为1,并且在节点中会加入tag属性。
对于标签节点中文字中的变量表达式我们会通过parseText先进行检查,将表达式进行处理
再将text和expression插入节点元素,而如果没有变量表达式就会把type设为3,并直接将text插入节点元素。
在parse之后的optimize过程中,还会对所有节点判断是否是静态节点,如果是type为3的节点那么就是静态节点,其他的静态节点的情况没有影响暂不讨论。
最后通过generate一步通过VNode函数将AST节点树转化成render function。
二、派发更新
在修改model的值时,上次分析到会调用闭包中的dep来逐一通知到所依赖收集时收集到的Watcher对象,Watcher对象会调用update来修改视图。
最终将新老VNode进行patch对比,下面就主要来介绍下这个patch的过程。讲代码的时候会比较枯燥,但是还是需要明白其中的原理,最后代码调试的时候才能明白。所以还是要先来熟悉一下源码的处理流程。
Patch函数的4个参数为新老两个VNode、是否为服务端渲染标识、removeOnly参数。
我们先分析上面三种情况:
1.如果没有定义vnode,但是oldVnode定义了,说明是销毁了当前的节点。就调用oldVnode的destroy钩子,之后返回。
2.如果没有定义oldVnode,说明是第一次渲染,直接通过createElm函数来创建元素。
3.如果oldVnode和vnode通过sameVnode函数判断出来是相同节点(sameVnode函数后面会详细说明),就通过patchVnode函数来处理oldVnode和vnode。
最终返回vnode.elm。
在情况3中,是如何通过sameVnode来判断是否是相同节点的呢?
在函数中:
1.判断节点的key值是否相同,这个key值就是我们显式给标签节点赋的key值,如果没有显式赋值那就是undefined。
2.判断节点的标签是否相同,对于type为1的标签节点,在构造AST的时候都会插入一个tag元素即为元素的标签。
3.是否同为代码注释。
4.是否共同赋了data值。
5.如果为input元素,检查type属性。
后面异步相关的判断暂不分析,如果这5点都相同,就可以判断是相同节点,下面来看patchVnode函数的处理逻辑。
重点部分的逻辑都已经用红框标出来了,也是我们要重点分析的部分:
1.如果新老节点的node节点结构相同,就直接返回。
2.如果新老节点都是静态节点并且key相同,只要将老节点的componentInstance赋给新节点即可,直接返回。
3.如果新节点是标签节点,未定义text字段
1)如果新旧节点都定义了子节点,且子节点的node结构不相等,就调用updateChildren函数,后面详细分析。
2)如果只有新节点有子节点,就通过addVnode函数往新旧节点的elm上添加节点。
3)如果只有旧节点有子节点,就通过removeVnodes函数删除旧节点上的子节点。
4)如果都没有子节点,就通过nodeOps的setTextContent api把elm中的text赋为空字符串。
4.如果新Vnode节点为文字类型的节点,即编译的时候type为2或3,没有子节点。那么直接给新旧节点的elm赋值新节点的text元素即可。
下面来详细分析下新旧节点都有子节点,但是结构不同的时候如何通过updateChildren函数进行处理:
在函数中我们可以看到一些变量,他们的含义已经在下图中标出:
在while循环过程中,4个后缀为Idx的变量会逐渐向中间靠拢,直到End的索引小于Start索引的时候才会停止,在while过程中这4个索引所代表的节点会相互比对:
1)如果oldStartVnode和newStartVnode通过sameVnode函数判断为相同节点,就通过patchVnode函数处理这两个Vnode节点,然后将Start索引和其所代表的节点后移。
2)如果oldEndVnode和newEndVnode判断为相同节点,就通过patchVnode函数处理这两个Vnode节点,然后将End索引和其所代表的节点前移。
3)如果oldStartVnode和newEndVnode为相同节点,就通过patchVnode函数处理这两个Vnode节点,然后将oldStartVnode节点插入到oldEndVnode节点之后,将oldStart节点后移,newEnd节点前移。
4)如果oldEndVnode和newStartVnode为相同节点,就通过patchVnode函数处理这两个Vnode节点,然后将oldEndVnode节点插入到oldStartVnode节点之前,将newStart节点后移,oldEnd节点前移。
当以上条件都不满足的时候,就会通过第二个方框中的逻辑进行处理。
createKeyToOldIdx函数返回的oldKeyToIdx变量为一个从key映射到map的对象。
通过newStartVnode的key值来查找是否有对应的节点。如果没有说明是新的子节点,通过createElm创建节点,并将newStartIdx后移。
如果有对应的key值,就比较是否是同样节点,再迭代进行patchVnode构造新旧节点的elm元素。
最后,while循环终止:
1)如果是oldVnode没有节点再继续循环,就把newVnode剩下的节点添加到parentElm中。
2)如果是newVnode没有节点再继续循环,就把oldVnode剩下的节点从parentElm中删除。
经历过所有循环后,oldVnode的结构就与vnode结构一样,并且每个叶子节点都通过patchVnode做过了处理。这样就完成了派发更新的过程。
三、代码调试
下面我们最后来看文章开头的时候的例子,我们在浏览器中打开sources标签,找到我们在例子中引入的vue.js文件,在patchVnode函数的入口处打一个断点,然后点击香蕉的删除。此时我们v-for的key使用的是循环中的index。
当我们点击香蕉的删除按钮时,patchVnode的调试参数可以看出oldVnode和vnode都是最外层的div标签节点,oldVnode中的children有两个子节点,而删除了香蕉以后,vnode中的children只有一个子节点。
为了便于演示,先画出了删除香蕉前和删除香蕉后的DOM图,看后续调试步骤的时候可以参考:
继续往下调试,不是静态节点并且新旧节点都有子节点,所以会进入到updateChildren函数中。
进入到updateChildren中以后,在这一层的updateChildren中我们可以看到oldCh和newCh分别为2个和1个节点。而oldStartIdx所指向的oldStartVnode为一个div节点,与newStartIdx所指的newStartVnode结构相同,都是由h1、img、span组成的。由于删除了香蕉以后,苹果成了第一项,按照index赋值key,苹果的key变成了0。在updateChildren中会被sameVnode判定为同样节点,所以会进入patchVnode函数。
进入patchVnode后,进而再获取子节点进行updateChildren,此时要比较的子节点就是h1、img、span三个元素。
进入updateChildren后经过sameVnode判断,h1节点为同样节点,所以h1节点继续进入patchVnode函数进行对比:
两个h1节点下面都有子节点,下面再对两个h1的子节点进行updateChildren:
两个h1下的文本节点通过sameVnode检查为同样节点,文本节点进入patchVnode函数做节点更新:
这回作为叶子文本节点,两个节点都是没有子节点的,所以走到最下面判断出两个文本节点的text字段不同:
在经过了setTextContent的api把文本节点的内容更换后,可以看到浏览器中的“香蕉”变成了“苹果”:
同样的在对img节点调用update钩子的时候,就可以更新图片了:
最后循环到了最上面一层的div,可以判断出newVnode没有新的节点了,所以需要删除oldStartIdx和oldEndIdx之间的节点:
parentElm 删除旧节点之后就只剩下“苹果”一个节点:
当浏览器执行完所有的同步渲染流程后,会执行异步onload事件,来重新加载img标签。
在用循环的index作为v-for的key值的时候,我们可以看出vue框架需要更新老的vnode节点的text值和img的src属性,而本来可以被复用的苹果节点,却被当做多余的节点被丢弃了,这样在节点比较多的场景下就会造成框架执行逻辑的复杂和浏览器的重复加载相同的内容。那么如果我们给v-for列表中的key值设定元素自己唯一的id是否就可以避免呢?我们往下看,把key属性改成元素自身的id:
这时删除香蕉时DOM图转化如下:
在id为app的子节点进入updateChildren函数时,可以走到如下逻辑,由于newCh中的唯一的一个节点的key值和oldCh第一个节点的key值并不相同,所以不会对oldStartVnode和newStartVnode进行patchVnode,而是对oldEndVnode和newEndVnode两个节点进行patchVnode比较:
而这两个key为1的节点中,所有的数据都是一样的,所以并没有需要特意更新和浏览器重新加载的操作,中间过程就不再截图了。最后经过oldEndIdx和newEndIdx减1,while循环终止,走到了最下面判断removeVnode函数中,删除index为0的vnode节点。
可以看出用元素的唯一id来赋值key,一般情况下真的可以优化框架执行效率和浏览器负载。
大家也可以思考一下,如果我们要做一个列表,这个列表只可以从最下面添加项目,那么这时采用index和元素唯一id作为key值有没有区别呢?如果我们要做一个IM聊天的消息列表,这个消息列表可以下拉加载历史消息,那么这个时候,用index值和元素唯一id是否有区别呢?
参考:《剖析 Vue.js 内部运行机制》