本文主要以 vue 的 2.2.4 版本来进行分析。
Virtual Dom?
这个概念伴随着react的出现而得到广泛的认识,现在也在各大mvvm框架得到应用,为什么大家都要用它了?其实随着web应用越来越复杂,数据交互越来越频繁,使用jquery或者原生dom api,通过事件和变量已无法满足,比如状态太多,代码不易阅读和维护,使用mvvm框架后,不在需要我们去操作dom,而是框架帮我们做,视图改变对应的状态改变,然后状态变化,我们再重绘整个视图就好了,这种做法很好,但是如果频繁的操作或者数据量很多的情况下,性能上是会遇到问题的,代价太大,那能不能只去操作数据改变的视图?virtual dom能有效的解决这个问题(但是绝不单单为了解决这个问题),所以我说,它是一种提高界面渲染性能的技术方案
核心思想是:对复杂的文档DOM结构,提供一种方便的工具,进行最小化地DOM操作。
实现概括
使用JavaScript对象模拟Dom树结构,当数据发生改变时候,并不是直接全量更新,而是通过虚拟dom比对出最小的差异,再修改真实Dom,避免整个视图重新构建、重绘;接下来从三个方面详细介绍实现过程:
- VNode对象的生成过程
- VNode生成视图策略-patch
- 新旧VNode的diff算法
VNode对象的生成过程
从上图可以看出:
- 通过html的词法分析 => js抽象语法树
- js抽象语法树 => render function
- render function => virtual Dom(VNode对象)
接下来我们看下每一个过程(这里我们主要通过图片来看,更直观):
1、通过html的词法分析 => js抽象语法树?
将html结构变成对应的javascript对象,对象的tag属性为div,对象的attrs对象包含id为main,对象的events对象包含click,值为cl,对象的children。
2、js抽象语法树 => render function?
通过解析模板对应的js抽象语法树,拼接出一个对应的字符串,然后使用with运算符封装出一个render function,当执行的时候,将当前的vue实例传入
3、render function => virtual Dom(VNode对象) ?
每当变量(message)改变,都会重新只想render function,进而生成一个新的VNode对象,才会有后续提到的前后diff,找到最小差异,然后更新到视图。
看到这里,已经知道Vnode的生成过程,那么接下来看一下vue中是如何diff?如何提升渲染性能?
在看介绍之前,我们先看一下生成Vnode的构造函数?1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28var VNode = function VNode(
tag,
data,
children,
text,
elm,
context,
componentOptions
) {
this.tag = tag;
this.data = data;
this.children = children;
this.text = text;
this.elm = elm;
this.ns = undefined;
this.context = context;
this.functionalContext = undefined;
this.key = data && data.key;
this.componentOptions = componentOptions;
this.componentInstance = undefined;
this.parent = undefined;
this.raw = false;
this.isStatic = false;
this.isRootInsert = true;
this.isComment = false;
this.isCloned = false;
this.isOnce = false;
};
这里我们着重关注几个属性,tag(标签名)、data(属性)、children(子元素信息)、key(元素标识)、isCommemt(是否是注释标签),这几个属性,在dom对比中起很大作用,看下面代码:1
2
3
4
5
6
7
8function sameVnode(vnode1, vnode2) {
return (
vnode1.key === vnode2.key &&
vnode1.tag === vnode2.tag &&
vnode1.isComment === vnode2.isComment &&
!vnode1.data === !vnode2.data
)
}
在vue中,如果具有相同的key、tag、isComment以及都具有data属性,不管data属性是否一样,那么就认为是同一个元素。
VNode生成视图策略
我们再一一描述一下patch函数所做的事情(oldVnode和vnode为前后两个“VNode”对象):
第一步:若vnode不存在,可知意图是销毁元素,那么接着去判断oldVnode是否存在,若不存在,则直接return,若存在,则销毁oldVnode,然后return,执行完成。
第二步:若vnode存在,然后判断oldVnode是否存在,若不存在,创建新的根节点
第三步:若oldVnode和vnode都存在,然后判断oldVnode是否是真实节点(通过nodeType的值判断,真实元素的nodeType值是存在的,虚拟dom对象的nodeType值是undefined),如果是真实节点,则好比初始化渲染,则直接调用createElm创建新元素插入页面,然后删除原始页面元素,如果oldVnode和vnode都是虚拟dom对象,则调用patchvnode方法,比较出最小差异,然后更新页面元素。
新旧VNode的diff算法
diff算法的核心包括两个方面:同层比较和深度优先遍历
1、同层比较
在mvvm框架使用过程中,我们知道不会跨越层级修改DOM元素,因此上我们只比较同层级,不跨层比较,如果逐层逐层搜索遍历,时间复杂度将会达到O(n^3)的级别,代价很高,但是如果比较同层级的方式,时间复杂度可以降低最多到O(n);如下图:
2、深度优先遍历
深度优先遍历,从初始访问结点出发,我们知道初始访问结点可能有多个邻接结点,深度优先遍历的策略就是首先访问第一个邻接结点,然后再以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点。如下图:
接下来看一下diff的具体过程patchVnoe的实现过程,先看一下流程图:
我们再一一描述一下patch函数所做的事情(oldVnode和vnode为前后两个VNode对象):
第一步:若oldVnode跟vnode恒等,证明他们的引用路径相同,那么不需要做任何事情
第二步:若oldVnode跟vnode都是静态节点,且具有相同的key,同时vnode是克隆节点或是v-once指令控制的节点,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作
第三步:若vnode存在text值,说明是文本节点,然后当vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以
第四步:若vnode的text为null,说明包含子节点,这个时候分为四种情况
- 1、若oldVnode和vnode都存在子节点,并不恒等,根据深度优先遍历原则,开始对比子节点,调用updateChildren,后续会详细说明
- 2、若只有vnode存在子节点,则新增这些子节点,(这里注意一下:如果oldVnode的text不为空,首先将text置为空)
- 3、若只有oldVnode存在子节点,则删除这些子节点
- 4、若只有oldVnode存在text值,则将其text值置为空字符串
刚提到第一种情况,接下来我们详细看一下updateChildren函数的实现
patchVnode中更新子节点的算法其实是在updateChildren函数中实现的,因此上这里内容更关键,更难理解一点,我们先看一下代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
var oldStartIdx = 0;
var newStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, elmToMove, refElm;
var canMove = !removeOnly;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(
oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm,
oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] :
null;
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
/* istanbul ignore if */
if ("development" !== 'production' && !elmToMove) {
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
);
}
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm,
oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode
.elm);
newStartVnode = newCh[++newStartIdx];
}
}
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx,
insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
我们一一解析一下:
1、获取oldVnode和vnode的firstChild、lastChild,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode,并记录他们序号oldStartIdx、oldEndIdx、newStartIdx、newEndIdx
2、通过while循环,进行比较,终止条件就是’oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx‘
3、若oldStartIdx > oldEndIdx 新增节点;newStartIdx > newEndIdx 删除节点
接下来看一下while循环中的内容,前两个判断没什么好说的,从第三个说起
1、如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一个子节点,重复上述流程
2、如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述过程
3、如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述过程
4、如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述过程
5、如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点
6、如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述过程
7、如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述过程
一直重复上述过程,直到触发while循环的终止条件
说到这里本章内容就算完了,对vue中的Virtual Dom有一定认识没?文章第一段说过了,Virtual Dom绝不单单为了解决提高性能问题,其他内容正在研究ing,敬请期待O(∩_∩)O~~