vue虚拟dom

本文将结合vue2.0源码向各位介绍下vue virtual dom。

什么是virtual dom

vue2.0和react都引入了virtual dom的概念,virtual dom就是一种在js中模拟dom对象树来优化dom操作的技术或思想。

为什么要引入vdom

1、dom元素太复杂,如果每次都重新生成dom元素,浪费性能。
做个实验,打印出一个空元素的属性:

1
2
3
4
var testDiv = document.createElement('div');
for(var attr in testDiv ){
console.log(attr)
}

打印结果可以看出,dom元素的属性太多了,而vdom定义了真实dom的一些关键信息,这样模拟出来的虚拟dom对象简单的很多(vnode)。

2、创建dom,删除dom,插入dom等一系列dom操作很耗性能。
而vdom很好地对dom做了一层映射关系,进而将dom的一系列操作映射到了操作vdom。
vdom完全是由js实现的,和浏览器没有任何关系,得益于js的执行速度,相比于操作真实dom,提高了效率和性能。eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//伪代码
var mydivVirtual = {
tagName: 'DIV',
className: 'a'
};
var newmydivVirtual = {
tagName: 'DIV',
className: 'b'
}
if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className !== newmydivVirtual.className) {
change(mydiv)
}

// 会执行相应的修改 mydiv.className = 'b';
//最后 <div class='b'></div>

VNode

接下来,我们来了解下VNode:
页面上每个节点都有对应的vnode,vnode的几个比较重要的属性:

  • tag:即这个vnode的标签属性
  • data:包含了最后渲染成真实dom节点后,节点上的class,attribute,style以及绑定的事件
  • children:是vnode的子节点
  • text:是文本属性(只有文本节点和注释节点该属性才有值)
  • elm:为这个vnode对应的真实dom节点
  • key:是vnode的标记,在diff过程中可以提高diff的效率,后文有讲解
    …..

vdom的实现

先来看下整体流程:
vdom的整体流程图
首先,组件初始化(init)时挂载了组件(mountComponent):

1
2
3
4
5
6
7
//vue组件初始化
function init () {
...
//组件挂载
mountComponent();
...
}

组件挂载(mountComponent)时,定义了updateComponent函数,实际执行update,update的第一个参数是由render生成的一个vnode节点,同时初始化了watcher,记录了数据的依赖,当data changed时,会进行update:

1
2
3
4
5
6
7
8
9
10
11
//组件挂载
function mountComponent () {
...
let updateComponent = () => {
//render()结果是一个新的vnode
update(render(), ...);
}
//把updateComponent方法传进watcher,watcher记录了数据依赖,data changed时,执行updateComponent
new Watcher(..., updateComponent, ...);
...
}

其中,watcher的实现,对数据进行监听:

1
2
3
4
5
6
7
function Watcher () {
...
收集数据依赖
...
data changed --> updateComponent
...
}

render的实现,返回vnode节点:

1
2
3
4
5
6
function render () {
...
let vnode = createElement();
...
return vnode;
}

createElement的实现,创建vnode节点 :

1
2
3
4
5
6
function createElement() {
...
let vnode = new Vnode();
...
return vnode;
}

update包括diff(对新旧vnode进行比较)和patch(根据diff结果在oldVNode上面打的补丁更新真实dom)的过程(代码里就是指patch, patchVnode, updateChildren几个方法的过程),算法基于snabbdom算法,复杂度为O(n):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//更新
function update (vnode, ...) {
...
if(!prevVnode) {//如果prevVnode不存在,就用新的vnode来创建真实的dom
...
patch(..., vnode, ...);
...
} else {//prevVNode存在,就对prevVnode和vnode进行diff,并将需要更新的dom以patch的形式打到prevVnode上,并完成真实的dom的更新工作
...
patch(prevVnode, vnode, ...);
...
}
...
}

其中,patch是virtual dom的核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//virtual dom的核心方法,主要完成了prevVnode和vnode的diff,并根据需要打patch,最后完成真实dom的更新工作。
function patch (oldVnode, vnode) {
...
if(isUndef(oldVnode)) {//oldVnode不存在时,在父节点底下直接创建新的子节点
createElm(vnode, ..., parentElm);
} else {
if(sameVnode(oldVnode, vnode)) {//两个节点基本属性一致时,对oldVnode和vnode进行diff,并对oldVnode打patch
patchVnode(oldVnode, vnode, ...);
} else {//否则跳过diff,直接根据vnode创建一个新的dom,同时删除老的dom
createElm(vnode, ..., parentElm);
removeElm(oldVnode, ..., parentElm);
...
}
}
...
}

sameVNode用来比较两个节点的基本属性是否一致,一致时表示两个节点值得对比,才进行diff,否则无需对比,直接生成新的dom:

1
2
3
4
5
6
7
8
9
10
//判断两个节点的基本属性是否一致,是否值得比较
function sameVnode(a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
);
}

接下来,patchVnode又分了好几种情况:
1、首先进行文本节点的判断,若oldVnode.text !== vnode.text,那么就会直接进行文本节点的替换;
2、在vnode没有文本节点的情况下,进入子节点的diff;
3、当oldCh和ch都存在且不相同的情况下,调用updateChildren对子节点进行diff;
4、 若oldCh不存在,ch存在,首先清空oldVnode的文本节点,同时调用addVnodes方法将ch添加到elm真实dom节点当中;
5、若oldCh存在,ch不存在,则删除elm真实节点下的oldCh子节点;
6、若oldVnode有文本节点,而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
//
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm,//在patch之前,vnode的属性是null的,因为在patch之前还没真实的dom,此处vnode的el属性引用的是oldVnode的el属性
oldCh = oldVnode.children,
ch = vnode.children;
if(ifUndef(vnode.text)) {//vnode没有文本节点
if(isDef(oldCh) && isDef(ch)) {//oldVnode和vnode都有children
//对子节点进行diff
updateChildren(elm, oldCh, ch, ...);
} else if(isDef(ch)) {//只有vnode存在children
if(ifDef(oldVnode.text)) {//若oldVnode有text,清空原来的文本节点
nodeOps.setTextContent(elm, '');
}
//同时将ch添加到elm真实的dom节点中
addVnodes(elm, ch, ...);
} else if(isDef(oldCh)) {//只有oldVnode存在children
//删除elm下的oldCh节点
removeVnode(elm, oldCh, ...);
} else if(isDef(oldVnode.text)) {//只有oldVnode有文本节点,那就清空这个文本节点
nodeOps.setTextContent(elm, '');
}
} else if(ifDef(oldVnode.text !== vnode.text)) {//vnode有文本节点,直接将elm的内容设为该文本
nodeOps.setTextContent(elm, vnode.text);
}
}

updateChildren是diff的核心:
首先,给oldVnode和vnode的firstChild和lastChild分别设置oldStartVnode,oldEndVnode,newStartVnode,newEndVnode,接着开始patch:
1)如果oldStartVnode和newStartVnode基本属性相同,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一子节点,重复上述流程:
sameVnode(oldStartVnode, newStartVnode)
2)如果oldEndVnode和newEndVnode基本属性相同,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设为上一子节点,重复上述流程:
sameVnode(oldEndVnode, newEndVnode)
3)如果oldStartVnode和newEndVnode基本属性相同,调用patchVnode进行patch,把oldStartVnode.elm移至oldEndVnode.elm之后,然后把oldStartVnode设为下一子节点,把newEndVnode设为上一子节点,重复上述流程:
sameVnode(oldStartVnode, newEndVnode)
4)如果oldEndVnode和newStartVnode基本属性相同,调用patchVnode进行patch,把oldEndVnode.elm移至oldStartVnode.elm之后,然后把oldEndVnode 设为上一子节点,把newStartVnode设为下一子节点,重复上述流程:
sameVnode(oldEndVnode, newStartVnode)
5)如果上述都不匹配,则尝试在oldChildren中查找与newStartVnode的key相同的子节点:
  如果找不到相同key子节点,说明newStartVnode是一个新节点,就在oldStartVnode.elm前创建一个新节点,并把newStartVnode设为下一个节点,重复上述流程;
  如果找得到相同key子节点,就比较这两个节点基本属性是否相同:
    如果相同,就调用patchVnode进行patch,把找到的节点移到oldStartVnode之前,并把newStartVnode设为下一个节点,重复上述流程:
    key匹配且sameVnode
    如果不相同,则说明newStartVnode是一个新节点,就在oldStartVnode.elm前创建一个新节点,并把newStartVnode设为下一个节点,重复上述流程;

如果oldStartVnode和oldEndVnode重合了,或newStartVnode和newEndVnode重合了,表示diff结束了,如果oldChildren还有剩余节点,说明这些旧的节点已经不需要了,删除这些旧节点,如果newChildren还有剩余节点,说明这些节点是多出来的,新增这些子节点。

总结

引入虚拟dom实际上有优点也有缺点:

尺寸

更多的功能意味着更多的代码。幸运的是vue.js 2.0仍是相当小的(20+k);

内存

虚拟dom需要在内存中维护一份dom的副本。需要在更新速度和使用内存空间取得平衡。

不是适合所有情况

如果需要大量修改dom,虚拟dom是合适的,但是如果是单一的,但又需要频繁更新的话,虚拟dom将会花费更多的时间处理计算的工作。所以,如果你有一个dom相对较少的界面,用虚拟dom,可能会更慢。但对于绝大多数情况,应该是合适的。