上一节我们学习了Vue中异步渲染队列的原理,本节我们沿着响应式图谱学习下一个部分——渲染页面。
如上图所示,Vue会根据之前得到的变更通知生成一颗新的Virtual DOM树,然后再将新的Virtual DOM树和旧的Virtual DOM树进行diff patch操作。
本节的目标是学习Virtual DOM以及Vue是如何对新旧两颗Virtual DOM树进行diff patch算法。
前面小节的链接在这里:
一、Virtual DOM
1.1. 什么是Virtual DOM
一听到"虚拟DOM"这个词汇,自然的会升起一种它很高级的感觉。没错,它确实很高级,但是稳住不要慌,你可以把它当成我们吃饭用的筷子,对于外国人而言,中国的筷子也是一种高级(共同点:都是外国人发明的【手动滑稽】)。你天天用筷子当然就不会觉得筷子很高级,这个道理是:你越熟悉它,它对你而言就越不神秘。
在js中,简单理解Virtual DOM就是通过 JS的对象结构
来描述 DOM树结构
的一种工具。
1.2. 为什么要使用Virtual DOM
这个问题很多文章是这么回答的:操作DOM是很耗费性能的一件事情,我们可以考虑通过JS对象来模拟DOM对象,毕竟操作JS对象比操作DOM省时的多。
事实上这种说法是不对的,操作JS对象确实比操作DOM省时,但是在Virtual DOM中并不能减少DOM操作,这一步DOM操作是由Virtual DOM在diff patch的过程中完成的,通过这一步事实上它减少的是我们前端人员直接通过DOM API去增删改查DOM节点的操作,从而提高了我们的开发效率而并非产品性能。
在计算机界一直流传着这么一句话:
翻译过来就是说:软件开发中遇到的所有问题都可以通过增加一层抽象而得以解决。
Virtual DOM也是类似,它也是分层思想的一种体现,为什么这么说?当我们在开发的时候是基于一定的DSL的,比方说我们前端会使用HTML、JS、CSS来写代码,那么框架帮我们抽象出一层Virtual DOM,通过这层Virtual DOM,框架可以将不同的Virtual DOM节点适配到不同的view显示端,其中包括像H5、小程序以及App native,从而使我们的代码具备一次编写多端执行的可能性。
看完抽象的概念,我们再来看一下Virtual DOM在代码中具体是怎么体现的。
1.3. Virtual DOM的具体表现形式
以下面这段html代码为例:
- Item 1
- Item 2
- Item 3
Virtual DOM抽象出来的js对象为:
var VNode = {
tagName: 'ul', // 标签名
props: { // 属性用对象存储键值对
id: 'list'
},
children: [ // 子节点
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
]
}
对比上面的代码,很容易看出来它们之间的映射关系。既然DOM节点可以转换成JS对象,反之亦然,Virtual DOM需要做的就是:
- 将你编写好的DOM树结构(如.vue文件中的template)转换成JS对象树结构,然后再将JS对象树构建成真正的DOM树即可。
- 当检测到状态变更时,生成新的JS对象树并与旧的对象树进行比对,通过diff算法最终确认要进行多少更新。
小结
- Virtual DOM是分层思想的一种体现,通过增加抽象层来帮助我们完成一次编写多端通用的功能。
- Virtual DOM是通过JS对象结构来描述DOM树结构的工具。
- Virtual DOM检测到状态变更时会生成新的对象树,通过diff算法比对新旧对象树来最终确认DOM更新策略。
二、Diff Patch
2.1. 什么是diff算法
作为面向百度工程师,第一步当然是你们懂的了【手动滑稽】
百度百科给出的答案是:
- Virtual DOM中采用的算法
- 将树结构按层级划分,只比较同级元素,不同层级的节点只有创建和删除操作
- 为列表结构的每个单元添加唯一的key
有了这个概念我们继续往下学习。
那到底什么是diff算法?
打个比方,把老虎变成大象最短需要几步?换种说法,把Tiger这个字符串变成Elephant字符串最短需要几步?从Tom到Michael到Sunny呢?
为了解出这个题,你需要设计一种最优的字符串变更和匹对的算法,而Virtual DOM为了确定最优的DOM变更策略,它的解题算法就是diff。
2.2. Virtual DOM为什么要选择diff算法
我们都知道DOM操作的开销非常昂贵,轻微的操作都有可能引起页面重排损耗性能,所以要尽量减少DOM操作,diff算法的作用就是通过比对新旧Virtual DOM树的差异,最小化的执行DOM操作,从而提高开发人员的效率。
最小化操作dom的过程,我们就不用再考虑那么多直接操作DOM带来的问题了,主要目的是为了提效,开发者的水平参差不齐,不用手动操作DOM了,从侧面来讲也是对性能的优化。
2.3. diff策略
我们的应用通过Vue框架其实会转换成一颗虚拟DOM树,当我们进行了一些页面上的操作时,或者说我们得到了一些异步数据更新响应之后,其实是将左边的DOM树转化成右边的DOM树,这个转化过程中就要应用到diff的策略。
现在主流的MVVM框架基本都会按照以下策略:
- 按tree层级diff
- 按类型进行diff(组件类型或者是元素类型)
- 列表diff
2.3.1. 按tree层级diff
如下图所示,对每一层进行diff。这主要是因为在web UI当中很少会出现DOM节点跨层级移动,这种情况少的可以忽略不计,因此Virtual DOM的diff策略是在新旧节点树之间按层级进行diff从而得到差异。
2.3.2. 按类型进行diff
无论Virtual DOM中的节点数据对应的是一个原生DOM节点还是Vue的一个组件,不同类型的节点所具有的子树节点之间结构往往差异明显,因此对不同类型的节点子树进行递归diff的投入成本与产出比将会比较高昂,为了提升diff效率,Virtual DOM只对相同类型的节点进行diff,当新旧节点发生了类型的改变时则并不进行子树的比较,而是直接创建新类型的Virtual DOM替换旧节点。
如下图所示,假设我们diff到了这一层
现在要将父节点的五角星转换成下图的三角形节点
按照规则,首先是同层级进行diff,可以看到在这一层中五角星和三角形不是一个类型,如下图所示:
那么这个组件以及它的子组件都会被完全的销毁,哪怕这个组件下面的两个五角星子组件跟原来的组件是同一个组件,它们仍然需要再一次被创建,如下图所示:
2.3.3. 列表diff
在列表diff的过程中,可以给每一项都设置一个key,通过key可以提升diff的效率。如下图所示,左图没有key,所以老的节点全部删除,新的节点再全部创建;右图添加key值,所以只需要将A移动到B,将C移动到D即可。
我们来看一下源码中是怎么进行列表diff的,以vue 2.6.11版本为例,源码在src/vdom/patch.js中
关于updateChildren函数的源码解析参考文章:
https://zhuanlan.zhihu.com/p/...
具体细节很多,我们通过图解来看一下它具体的思想是什么样的。
如下图所示,假设我们旧的Virtual DOM层级上面的节点数是这么一个排列,现在要变成新的排列,浅灰色圆圈代表Virtual Node,也就是我们的虚拟节点,里面的深灰色小圆圈代表的是DOM的实体
现在我们页面上的DOM实体是以123456来排列的,我们来看图解过程,在算法开始之前,vue里面首先会定义四个指针,这四个指针分别是oldStartIdx/oldEndIdx、newStartIdx/newEndIdx,它们的名字其实也就代表了它们的意思,就是老的Virtual DOM节点开始的位置和结束的位置、新的Virtual DOM开始的位置以及结束的位置
代码的判断逻辑是,首先vue会判断oldStartIdx以及newStartIdx这两个指针所对应的节点的Virtual DOM是否为同一个,如下图所示
图示中我们发现它们就是同一个(旧树是1,新树是1),这时候第一轮循环就完成了,此时oldStartIdx指针往后移动了一位,newStartIdx也会往后移动了一位,如下图所示
然后进入到第二个循环,对比下一个Virtual Node,按照上一步的逻辑,还是先比对oldStartIdx和newStartIdx的值是否相等,这个时候我们发现2和5并不是同一个Virtual Node
接下来就换一种方式比较,去比较oldEndIdx和newEndIdx,靠近末尾的两个节点是否为同一个节点?这个时候我们发现它们又是同一个节点(旧树是6,新树是6)
与前面相似,第二轮循环完成,此时oldEndIdx减一,newEndIdx也进行减一,两个结束下标向前移动一位,如下图所示:
接下来比较第三个循环,还是先比较两个开始下标oldStartIdx/newStartIdx所指向的Virtual Node是否一致,我们可以看到不一致(旧树是2,新树是5),如下图所示:
然后再比较两个结束下标oldEndIdx/newEndIdx指向的Virtual Node是否一致,可以看到也不一致(旧树是5,新树是2),如下图所示:
之前的两种比对方式节点都不同,这个时候就会比较oldStartIdx和newEndIdx,进行捺向比较,这个时候发现这两个节点是一致的(旧树是2,新树是2),但是它的位置发生了改变,所以这个时候就需要把oldStartIdx所指向的Virtual DOM节点里面的真实DOM节点(深灰色2)挪到oldEndIdx所指向的Virtual DOM节点的真实DOM节点之后,同时oldStartIdx也会向后移动一位(加一),newEndIdx也会向前移动一位(减一),如下图所示:
(oldStartIdx后移一位,newEndIdx前移一位)
接下来进入第四个循环,同样的先判断两个开始下标oldStartIdx/newStartIdx,可以看到节点不一致(旧树是3,新树是5),如下图所示:
然后再比较两个结束下标oldEndIdx/newEndIdx指向的Virtual Node是否一致,可以看到也不一致(旧树是5,新树是7),如下图所示:
接着进行捺向比较,比较oldStartIdx跟newEndIdx指向的Virtual Node是否一致,可以看到不一致(旧树是3,新树是7),如下图所示:
这个时候就会再进行另外一种交叉方向比对,就是oldEndIdx和newStartIdx的对比,这是一个撇方向的对比,这时候发现对应的节点是一致的(旧树是5,新树是5),然后就会把oldEndIdx所指向的实际的DOM节点(深灰色5)挪到oldStartIdx所指向的实际节点的前面,同时,我们的oldEndIdx向前移动一位(减一),newStartIdx往后移动一位(加一),如下图所示:
(oldStartIdx前移一位,newEndIdx后移一位)
然后进入第五个循环,同样的先判断两个开始下标oldStartIdx/newStartIdx,可以看到节点不一致(旧树是3,新树是7),如下图所示:
然后再比较两个结束下标oldEndIdx/newEndIdx指向的Virtual Node是否一致,可以看到也不一致(旧树是4,新树是7),如下图所示:
接着进行捺向比较,oldStartIdx和newEndIdx比较,发现3和7也不一致
然后就会进行撇向比较,也就是oldEndIdx和newStartIdx所指向的节点,4和7也不一致
这个时候vue就会对oldStartIdx和oldEndIdx这两个指针所指向的节点之间的所有节点进行一次遍历,然后寻找newStartIdx所指向的节点是否存在于这些老的Virtual DOM当中,如果找到的话就会把它挪到oldStartIdx所指向的节点之前。
这里我们会发现其实是找不到的,7不在oldStartIdx和oldEndIdx这两个指针之间,这时候就需要新创建一个节点7,在完成了这一步之后我们的newEndIdx也会向前移动一位(减一)
这个时候我们就会发现,newEndIdx已经小于newStartIdx了,这也标志着我们的New Vnode列表已经生成完毕了,接下来一步就是把我们的Old Vnode列表当中多余的部分给删除掉。
多余的部分其实就是oldStartIdx和oldEndIdx这两个指针之间的那部分,也就是我们的3、4节点,删除掉这部分,如下图所示:
这个时候我们就发现了新的Virtual DOM的列表其实就是下方的这个New Vnode列表,而实际页面上的DOM节点1、5、7、2、6也是按照这个New Vnode列表顺序来维持的,那么Old Vnode上的Virtual DOM的生命周期也就到此为止了,也就是可以被销毁了,如下图所示:
2.3.3.1. key的作用
在vue的官方文档上面着重的说了一句:
为了给Vue一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每一项提供一个唯一 key 属性
文档地址:https://cn.vuejs.org/v2/guide...
key属性在这个算法当中又有什么作用呢?
我们假设前面的Old Vnode当中多了一个节点7
当我们没有设置key的时候,我们的匹对顺序第一步还是按照两个StartIdx所指向的节点进行比较
第二步是两个EndIdx进行比较
第三步是捺向的方向进行比较,也就是oldStartIdx跟newEndIdx所指向的节点进行比较
第四步是撇向的方向进行比较,也就是oldEndIdx跟newStartIdx所指向的节点进行比较
这个时候发现都不一致的时候,按照我们前面说的,如果没有设置key的情况下就会去遍历oldStartIdx和oldEndIdx之间去寻找是否存在这么一个节点,如果存在的话就会把它挪到前面去。
如果有设置了key属性,首先会去寻找在这个key map当中是否存在着对应的一个序号如下图,这里我们可以发现7这个节点也就是key-7对应的就是我们上面的列表当中的第四项
这个时候我们就不需要再对oldStartIdx和oldEndIdx之间的节点进行遍历了,就减少了这次循环操作,可以直接把7这个节点挪到5这个节点后面去,最后删除多余的节点就得到了新的节点树1、5、7、2、6,如下图所示:
从这里我们就可以看出来,如果设置了一个key,算法复杂度为O(n)
当我们不设置key的时候,算法复杂度的最好情况才是一个O(n),这种情况就是我们根本就没有进行遍历,刚好完全就是匹对的情况,最坏的情况就会上升到O(n²),其实就是说我们对每一个oldStartIdx跟oldEndIdx之间的区间都进行一次遍历。
关于key,官方文档描述如下:
文档地址:https://cn.vuejs.org/v2/api/#key
Vue的整个diff过程就是整个patch
方法的流程,整个流程也会通过递归地调用patchVnode
来完成对整棵Virtual DOM Tree的更新,正是因为它的节点操作是在diff过程中同时进行的,也正是因为这种形式提升了Vue在增删改查DOM节点时的效率。
2.4. 小结
- diff算法用来比对新旧Virtual DOM的差异,使我们尽可能少的操作真实DOM以提高开发效率。
- diff算法分为两个粒度,一个是组件级别(Component diff),另一个是元素级别(Element Diff)。组件级别的diff算法比较简单,节点不相同就进行创建和替换,节点相同的话就会对其子节点进行更新;对子节点进行更新也就是元素级别的diff,通过插入、移动、删除等方式使旧列表与新列表一致。
Vue中的diff算法大致分为5个步骤:
- 先从两棵树的开始下标进行比对
- 再从两棵树的结束下标进行比对
- 然后从捺向进行比对
- 接着从撇向进行比对
- 最后从Old Vnode开始下标位置和结束下标位置之间进行遍历查找(如果有设置key,则直接找到key对应的节点进行复用,无需再进行遍历)
- 列表渲染时,使用key可以很好的提高性能。
后面要学习的内容在这里:
三、参考
合格前端系列第五弹- Virtual Dom && Diff
本文由博客一文多发平台 OpenWrite 发布!