遇到的问题
在使用Vue渲染“可删减”的列表时,错误的使用index作为key,导致列表视图出现错乱。
- 复现步骤:右侧有两行,在第一行的Input里输入1,在第二行Input里输入2,然后点第一行的“ד删除第一行
- 期待结果:删除第一行后,应该变成“请输入 dog 的个数:2”
- 实际结果:删除第一行后,变成了“请输入 dog 的个数:1”
为什么cat变成了dog,但是<input />
里的1没有变成2呢?
这个问题一下子很难解释,下面我们通过几个小问题,一步一步来分析。
如果我们使用正确的值做为key,那么这个问题其实根本就没有意义。但是,如果我们参透了其中的出错原因,这将给我们带来极大的提升。
为什么会触发组件update
- 测试1:打开浏览器控制台,然后删除第一行,查看日志,思考为什么。
- 测试2:先重置页面,然后删除最后一行,查看日志,思考为什么。
测试1的结果
你会发现,删除第一行后,watch
和updated钩子
都执行了,这个结果其实给了我们第一个提示:
删除第一行这句话本质上其实是:删除vue实例数据中list的第一项,并不是删除dom的第一个节点!
对于在dom中的这三个节点,其实是做了如下的变化:
VDOM的diff算法
之所以会这种方式进行dom更新,这决定于vdom的diff算法,我们通过阅读vue源码中src/core/vdom/patch.js
这个文件来一探究竟。具体的来说,就是其中的updateChildren
方法:
不要被这么多变量吓到,其实主要是三组变量,每组四个:
- 第一组是四个指针,分别指向oldCh和newCh的头和尾
- 第二组是四个vnode,分别是四个指针所指的节点
- 第三组是四个辅助变量(413行),用来移动vnode
在我们的例子里,大概是这样:
继续往下看:
又是一坨代码,但也不要被吓到,你会发现if、else if里的逻辑都是差不多的,仔细读两遍,你就会发现其实大概就是:
-
空节点跳过处理
-
指针1对应的vnode跟指针3对应的node比,如果是same的就patchVnode进行更新,如果不是same的就往下走
-
指针2对应的vnode跟指针4对应的node比,如果是same的就patchVnode进行更新,如果不是same的就往下走
-
指针1对应的vnode跟指针4对应的node比,如果是same的就patchVnode进行更新,如果不是same的就往下走
-
指针2对应的vnode跟指针3对应的node比,如果是same的就patchVnode进行更新,如果不是same的就往下走
-
最终,如果到了这一步还不是same的,那就用key最终确认一次
- 先构造一个key to index的map
- 判断当前old vnode的key是否在map里
- 如果不在,就直接createElm
- 如果在,并且是same的,那就patchVnode,然后更新节点内容、顺序
- 如果在,但是不是same的,那就视作新element,执行createElm
-
当while循环退出时,如果指针1和指针2还没重合,那就代表此时指针1和指针2区域内的vnode是待删除的,所以直接removeVnodes。而如果是指针3和指针4还没重合,那就代表指针3和指针4之间的vnode是待添加的,所以直接addVnodes。至此整个过程结束。
怎么用上面的过程解释“测试1”的结果
再看一下这个图:
按照上面的diff算法,我们会先判断cat和dog是否是same的,其中sameNode方法如下:
也就是说,只要key相同,并且tag、isComment、isDef(data)、sameInputType都是true,那么diff算法就认为是同一个vnode,在这里旧的cat节点和新的dog节点,它们的key都是0,显然符合这个条件。
所以,代码会进入到这里:
此时会使用patchVnode
方法来"patch"旧的这个cat节点,怎么patch呢?
简单地说,就是使用新的props,让这个cat节点进行re-render,re-render的过程中必然也做一些诸如:触发watch,调用updated声明周期钩子之类的事情。 记住这句话,以后会用到!
接下来,dog变成pig,也是同样的道理。
最后,左边oldCh的pig节点哪去了呢?
其实到了这里,while循环就已经退出了,看上一小节的第7步,此时pig节点会直接被remove掉。
关于patchVnode的细节在这里没有写,需要自己去看,关键的地方是在
src/core/vdom/patch.js
的545,552,572行
需要强调一点
可能有人会疑惑,即使我不知道diff算法的细节,在我们删除第一行时,也就是删掉list的第一项时,会触发视图更新,视图更新了,那cat节点肯定就会变成dog,这应该是理所当然的啊。
这里需要强调的是,使用diff算法时,"合适"的原有的节点是会被复用的!cat之所以变成dog,不是因为新建了一个dog节点,而是cat节点被复用,然后使用新的props,通过re-render
实现了视图的更新!
测试2为什么不触发log的打印
到这里,我们就已经解释了:为什么“测试1”会触发watch
和updated
的打印了。
那么为什么测试2不会触发上述打印呢?其实原因很简单,因为patchVnode
提前return
了,没有触发re-render:
回到最开始的问题
如下图,到这里我们应该已经理解为什么删除第一行后,cat会变成dog。但是,为什么<input />
里的1没有变成2呢?
一个简单的解释
我们之前说过:patchVnode
的结果,其实就是使用新的props,让这个cat节点进行re-render。
这里是re-render,它的执行不是unmount一个节点,然后再mount一个新的节点,而是直接使用新的props来receive(更新)一个节点,节点的instance并没有重置,所以re-render的过程中,data压根就没变。
receive这个词出自:React实现原理
一些练手的问题 [可选]
使用空、常量1、index、unique的稳定值、random的随机值来作为key,依次预测视图如何表现、控制台如何打印:
这样就结束了吗?
有一个更深层次的问题:这是一个feature还是一个bug?
我又用React写了一个同样的例子:点击查看React版本的问题
你会发现,不管是React还是Vue都会存在这个问题,这肯定不是一个bug,那么这两个框架为什么要这么设计呢?
如果感兴趣,请关注下一篇文章:《思考如何自己写一个React框架》