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