vdom解析
前言
谈到vdom,vue借鉴snabbdom、react的vdom拿司徒的介绍来说:
最开始经典的深度优先遍历DFS算法,其复杂度为O(n^3),存在高昂的diff成本,然后是cito.js的横空出世,它对今后所有虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度距离算法应用(算法复杂度 为O(n^2))。但这样的diff算法太过复杂了,于是后来者snabbdom将kivi.js进行简化,去掉编辑长度距离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把snabbdom整个库整合掉了。
所以目前VirtualDOM的主流算法大致相同,snabbdom与react的reconilation方式也基本相同。
什么是vdom
Shadow DOM和VirtualDom是一回事么?
拿react官网的解释来,Shadow DOM是一种浏览器技术,主要用于在Web组件中确定变量和CSS的范围。虚拟DOM是由JavaScript中的库在浏览器API之上实现的概念。
而VirtualDom就是虚拟化DOM的JavaScript树,通过vnode,实现无状态的组件,当组件状态发生更新时,然后触发VirtualDom数据的变化,然后通过VirtualDom和真实DOM的比对,再对真实dom更新。
通过遍历一个dom,看下属性的数量1
2
3for (let j in document.body) {
console.log(j);
}
加上事件总共262个,如果再加上无数的子节点。262的指数级增长,所以浏览器维护成本也是很高的1
2
3
4
5
6
7
8
9
10
11
12
13
14text
VM14185:2 link
VM14185:2 vLink
VM14185:2 aLink
VM14185:2 bgColor
VM14185:2 background
VM14185:2 onblur
VM14185:2 onerror
VM14185:2 onfocus
VM14185:2 onload
VM14185:2 onresize
VM14185:2 onscroll
VM14185:2 onafterprint
...
snabbdom源码解析
先看vnode结构1
2
3
4
5
6
7
8{
sel: sel, // selector
data: data, // vnode提供的接口内容
children: children, //子节点为vnode
text: text, // 如果文本节点,则存储text
elm: elm, // 当前元素
key: key // 序表索引用
}
了解了结构,再看看h.js他的vnode包装函数,
简而言之,通过传入选择器,children,props组装成真正的vdom。
这个h类似jsx编译的pragma1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21module.exports = function h(sel, b, c) {
var data = {}, children, text, i;
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
} else if (b !== undefined) {
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else { data = b; }
}
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]);
}
}
if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g') {
addNS(data, children, sel);
}
return VNode(sel, data, children, text, undefined);
};
中间的addNS为svg特殊处理1
2
3
4
5
6
7
8
9function addNS(data, children, sel) {
data.ns = 'http://www.w3.org/2000/svg';
if (sel !== 'foreignObject' && children !== undefined) {
for (var i = 0; i < children.length; ++i) {
addNS(children[i].data, children[i].children, children[i].sel);
}
}
}
知道了vdom的结构,和生成过程。那来看看他的主流程是怎么运行的。
拿官方示例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
46var snabbdom = require('snabbdom')
// 初始化snabbdom,得到patch
var patch = snabbdom.init([
require('snabbdom/modules/class'),
require('snabbdom/modules/props'),
require('snabbdom/modules/style'),
require('snabbdom/modules/eventlisteners')
])
// h是一个生成vnode的包装函数,在工程里,我们通常使用webpack或者browserify对jsx编译
var h = require('snabbdom/h')
// 构造一个virtual dom,在实际中,我们通常希望一个无状态的vnode
// 并且我们通过state来创造vnode
// react使用具有render方法的对象来作为组件,这个组件可以接受props和state
// 在snabbdom里面,我们同样可以实现类似效果
// function component(state){return h(...)}
var vnode =
h(
'div#container.two.classes',
{on: {click: someFn}},
[
h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
' and this is just normal text',
h('a', {props: {href: '/foo'}},
'I\'ll take you places!')
]
)
// 得到初始的容器,注意container是一个dom element
var container = document.getElementById('container')
// 将vnode patch到container中
// patch函数会对第一个参数做处理,如果第一个参数不是vnode,那么就把它包装成vnode
// patch过后,vnode发生变化,代表了现在virtual dom的状态
patch(container, vnode)
// 创建一个新的vnode
var newVnode =
h(
'div#container.two.classes',
{on: {click: anotherEventHandler}},
[
h('span', {style: {fontWeight: 'normal', fontStyle: 'italics'}},
'This is now italics'),
' and this is still just normal text',
h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]
)
// 将新的vnode patch到vnode上,现在newVnode代表vdom的状态
patch(vnode, newVnode)
snabbdom的diff流程
核心点就是两个
- 同个vnode则更新node
- 非同个vnode重建node
1 | if (sameVnode(oldVnode, vnode)) { |
需要注意的是看第一个条件同个vnode则更新node1
2
3
4// key和sel作比对
function sameVnode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
用key和选择器做判断,所以在vue或者react中数组里用index作为key也就没有效果了。
子元素递归处理
总共两个阶段 :
第一阶段是对比,新旧children数组至少有个全部都被对比过。
第二阶段是批量更新,如果新数组全部对比过,旧数组剩下的应该被删除了,如果旧数据全部对比过,新的剩余该增加。
第一阶段
孩子遍历的对比有五种
1.新老起始vnode为同一个,则更新
2.新老结束vnode为同一个,则更新
3.老的起始和新结束为同一个,说明老数组向右移动了,放在数组最后一个
4.老的结束和新的开始为同一个,说明老数组向左移动了,放在数组第一个
5.根据key匹配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
49function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
var oldStartIdx = 0, 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, before;
// 保证一个数组跑完
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);
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}
}
}
第二阶段
1 | if (oldStartIdx > oldEndIdx) { |
react的diff流程
1.比对不同类型的元素
销毁、重建
2.比对同一类型的元素(shouldUpdateReactComponent)
更新属性
3.比对同类型的组件
只更新props
4.子节点递归
在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation。
参考react的官网解释
同类型更新流程
1 | _updateRenderedComponent: function (transaction, context) { |
拓展
类似的库可以看mithril, inferno, kivi, frzr