一,虚拟DOM
1. 什么是虚拟DOM
虚拟DOM说到底,就是用 JS 去描述一段 html 代码,比如:
<li key="li">
Virtual dom
<li>
上面这一段代码,用虚拟 DOM 表示的话就是
{
tag: 'li',
text: 'Virtual dom',
key: 'li',
...
}
来段稍微复杂一点的:
<div id="div1" class="container">
<ul style="font-size: 28px">
<li>a</li>
</ul>
</div>
用 JS 来描述便是:
{
tag: 'div',
props: {
id: 'div1',
className: 'container'
},
children: {
tal: 'url',
props: {
style: 'font-size: 20px'
},
children: {
tag: 'li',
children: 'a'
}
}
}
2. 为什么要有虚拟 DOM(优点)
首先,如果你能保证你总是能精确地操作DOM,那么直接操作DOM能带来最好的性能,但是每次都直接去操作DOM,一方面心智成本太高,另一方面代码也不易维护。
所以就有了虚拟DOM的概念,它本质上是用JS对象去描述一个DOM节点,相当于对真实 DOM 的一层抽象。
优点有:
- 能保证一定的性能下限,能够保证在不手动优化的情况下,提供还算可以的性能,总比粗暴地去操作DOM性能好一些;
- 不用手动操作DOM,能够提高开发的效率;
- 能支持跨平台,因为它是用JS对象来抽象描述真实DOM,所以当比如服务端渲染SSR的时候,就可以使用虚拟DOM来完成。
3. 虚拟DOM真的比真实DOM性能好吗(缺点)
当然是不一定,直接操作 DOM 速度肯定是最快的。
- 虚拟DOM虽然能保证一定程度的性能下限,但是无法做到极致的性能优化;
- 首次大量渲染 DOM 时,因为多了一层虚拟 DOM 的计算,所以速度会慢一些。
二,diff 算法
有了虚拟DOM,那这个虚拟DOM和真实DOM之间的一个比较过程是怎样的呢?这便是 diff 算法的用途了。
1. 比较策略
首先,先从宏观上把握一下 diff 算法的一个策略,策略是这样的:
- 同层比较
也就是新旧节点在进行比较的时候,只会在同层级间比较,不会跨级比较。
- 如果 tag 不同,则直接删掉重建,不再深度比较;
- 同时比较 tag 和 key,如果两者都相同,则认为是相同节点,不再深度比较。
2. 流程概述
- diff 算法是组件
patch()
更新的一个过程,首先会判断是否为同一标签(tag)
:- 如果不是,那么就接用新的 vnode 替换旧的 vnode ;
- 如果是就进入
patchVNode
阶段,先判断是否为文本节点:- 是文本节点的话,就直接判断文本是否相同,不同就直接替换文本;(文本节点没有子元素)
- 不是文本节点的话,就判断是否有子节点:
- 如果旧的vnode有,而新的vnode没有,就需要删除旧节点下的DOM;
- 如果旧的vnode没有,而新的vnode有,就需要创建新的DOM;
- 如果都有子节点,就需要进行子节点的比较(通过
updateChildren
)这是diff算法比较关键的一点,后面介绍updateChildren
函数时会说到。
总体上可以用下面这个图表示:
以上过程,在 vue 中,主要是通过patch()
、patchVnode()
、updateChildren()
几个函数来实现的,接下来,就把这几个函数都仔细介绍下(仅仅是根据核心源码的总结,具体代码就不贴了,还没到研究源码这一阶段)。
3. 具体分析
3.1 前置知识
isDef()
、isUndef()
这两个函数是用来判断vonde
是否存在的,而vonde
本质上又是个JS对象,所以实际这俩函数就用来判断vnode
是不是undefined
或者null
sameVnode()
在源码中,sameVnode
用来判断两个节点是否为相同节点
。那么相同节点的判断依据是什么呢?在vue中,判断依据便是key
、tag
等静态属性值是否相等。
在此需要注意一点:相同节点
不代表是相等节点
,相同节点是通过vnode1 === vnode2
来判断的。
举个例子:<div>hello</div>
和<div>hi</div>
是相同节点,而非相等节点。
3.2 patch()
patch()
函数是对新旧vnode
做一个简单的判断,还没有进入详细的比较阶段。
- 首先,判断
vnode
是否存在,如果不存在,就代表整个旧节点应该删除; - 如果
vnode
存在的话,再判断oldVnode
是否存在,如果不存在,说明需要新增整个vnode
; - 如果
vnode
和oldVnode
都存在,就需要判断二者是否为相同节点
,如果是的话,就调用patchVnode()
方法; - 如果二者不是
相同节点
,那这种情况一般是初始化页面。
3.3 patchVnode()
在patchVnode()
中,就开始对新旧两个vnode
进行比较了。
- 首先判断
oldVnode
和vnode
是否为相等节点,是的话就结束函数的执行; - 更新节点属性;
- 接着判断
vnode
是否为文本节点,即判断vnode.text
是否存在,如果存在,即vnode
是文本节点,只需更新节点文本即可; - 如果不是文本节点,继续判断
oldVnode
和vnode
是否有子节点:- 如果两者都没有子节点,就判断
oldVnode.text
是否有内容,有内容清空即可; - 如果
vnode
有子节点,而oldVnode
没有,则新增所有的子节点; - 如果
vnode
没有子节点,而oldVnode
有,则需删除所有的子节点; - 如果两者都有子节点,就进入到
updateChildren()
函数进行下一步比较并更新孩子节点。
- 如果两者都没有子节点,就判断
3.4 updateChildren()
重头戏来了,这是最关键的一步,也是比较难理解的一步。
首先,这个方法有两个重要的参数:oldCh
和newCh
,这两个参数都是一个数组,前者为oldVndoe
的子节点;后者为vnode
的子节点。
总的来说,这个方法的作用,就是对两个数组中的子节点进行一一比较,找到相同的节点再进行patchVnode()
去比较更新(也就是回到了上文的3.3);而剩下的呢,就根据实际情况进行删除或新增。(比如两个数组经过比较后,newCh
中还有多余的节点,那就说明这是需要新增的节点;如果oldCh
中还有多余的节点,那就说明这是需要删除的节点)
对于这个需求,如果是我这种菜鸡来实现的话,我第一反应想到的便是直接循环遍历两个数组,进行一一对比,但很显然,vue 不会这么做,肯定有更优雅,复杂度更小的做法。
在 vue 中,是通过四个指针来实现的,这四个指针分别设为两个数组的头尾,下文用旧头``旧尾``新头``新尾
来表示。接下来就来说说,vue 如何利用这四个指针来实现两个数组的比对。
首先是旧头``新头
进行比较,如果二者相同,就对二者执行patchVnode()
,也就是上文中的 3.3 小节,并向中间移动这两个指针。
如果旧头``新头
不相同呢?就对旧尾``新尾
进行比较
如果旧头与新头``旧尾与新尾
都不相同,那就进行交叉比较,首先是旧头``新尾
比较
如果旧头与新头``旧尾与新尾
都不相同,那就进行交叉比较,首先是旧头``新尾
比较
如果二者相同,那么就执行patchVnode()
,并把旧头
移动到尾部;
如果旧头``新尾
不相同,那就比较新头``旧尾
:
如果二者相同,那么就执行patchVnode()
,并把旧尾
插到到头部;
如果交叉比较也都匹配不上的话,那就要进行暴力对比了:也就是针对新头
这个节点,去遍历oldCh
中的所有节点,来进行一一匹配。
值得注意的是,在暴力比对之前,需要先生成一个oldCh
的key-->index
的映射表,在一一对比的时候,直接拿新头
的key
值去和这个映射表对比就好了;如果节点没有key
值呢?就会默认为undefined
(key
值的重要性体现出来了)
暴力对比一轮下来,如果可以找到相匹配的那么就对新头
进行移动操作,如果找不到就直接将新头
插入。
【这里有个细节需要注意:当暴力比对匹配时,需要将oldCh
中所匹配的节点置为undefined
,这样指针移动的时候就可以跳过这个节点】
那么到此,updateChildren()
这个方法的过程就讲完了,总体概括起来便是:
- 设置新旧首尾指针;
- 旧头新头比较;
- 旧尾新尾比较;
- 旧头新尾比较;
- 旧尾新头比较;
- 交叉比较;
- 暴力比较。
4. key
值的重要之处
通过上面diff
算法的描述,我们可以知道key
值在两个地方扮演着重要的角色。
- 首先是在
sameNode()
中,判断新旧两个节点是否相同时,需要用到key
值; - 在交叉比较不匹配,需要进行暴力对比时,是需要用到
oldCh
的key-->inde
映射表,以及新节点的key
值来循环比较。
本文是在搜索了vue-diff
相关文章后做出的总结,主要参考了两篇文章:
其中第一篇的动图比较多,适合新手阅读。