本文主要以 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对象的生成过程

image
从上图可以看出:

  • 通过html的词法分析 => js抽象语法树
  • js抽象语法树 => render function
  • render function => virtual Dom(VNode对象)
    接下来我们看下每一个过程(这里我们主要通过图片来看,更直观):
    1、通过html的词法分析 => js抽象语法树?
    image
    将html结构变成对应的javascript对象,对象的tag属性为div,对象的attrs对象包含id为main,对象的events对象包含click,值为cl,对象的children。
    2、js抽象语法树 => render function?
    image
    通过解析模板对应的js抽象语法树,拼接出一个对应的字符串,然后使用with运算符封装出一个render function,当执行的时候,将当前的vue实例传入
    3、render function => virtual Dom(VNode对象) ?
    image
    每当变量(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
28
var 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
8
function 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生成视图策略

image
我们再一一描述一下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);如下图:
image
2、深度优先遍历
深度优先遍历,从初始访问结点出发,我们知道初始访问结点可能有多个邻接结点,深度优先遍历的策略就是首先访问第一个邻接结点,然后再以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点。如下图:
image
接下来看一下diff的具体过程patchVnoe的实现过程,先看一下流程图:
image
我们再一一描述一下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
    78
    function 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循环的终止条件
image
说到这里本章内容就算完了,对vue中的Virtual Dom有一定认识没?文章第一段说过了,Virtual Dom绝不单单为了解决提高性能问题,其他内容正在研究ing,敬请期待O(∩_∩)O~~