Bluetom

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
3
for (let j in document.body) {
console.log(j);
}

加上事件总共262个,如果再加上无数的子节点。262的指数级增长,所以浏览器维护成本也是很高的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
text
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编译的pragma

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.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
9
function 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
46
var 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
2
3
4
5
6
7
8
9
10
11
12
13
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm;
parent = api.parentNode(elm);

createElm(vnode, insertedVnodeQueue);

if (parent !== null) {
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}

需要注意的是看第一个条件同个vnode则更新node

1
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
49
function 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
2
3
4
5
6
   if (oldStartIdx > oldEndIdx) {
before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}

react的diff流程

1.比对不同类型的元素
销毁、重建
2.比对同一类型的元素(shouldUpdateReactComponent)
更新属性
3.比对同类型的组件
只更新props
4.子节点递归
在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation。
参考react的官网解释

同类型更新流程
undefined

https://holmeshe.me/understanding-react-js-source-code-virtual-dom-diff-IX/#ReactDOMComponent-reconcilerUpdateChildren-%E2%80%94-Virtual-DOM-operations

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
_updateRenderedComponent: function (transaction, context) {
var prevComponentInstance = this._renderedComponent; // scr: -> 1)

// scr: ------------------------------------------------------> 2)
var prevRenderedElement = prevComponentInstance._currentElement;

// scr: create a new DOM tree
var nextRenderedElement = this._renderValidatedComponent();

var debugID = 0;

// scr: DEV code
...

if (shouldUpdateReactComponent( // scr: ----------------------> 3)
prevRenderedElement,
nextRenderedElement)
) {
ReactReconciler.receiveComponent( // scr: ------------------> 5)
prevComponentInstance,
nextRenderedElement,
transaction,
this._processChildContext(context)
);
} else { // scr: ---------------------------------------------> 4)
// scr: code that is not applicable this time
...
}
}

拓展

类似的库可以看mithril, inferno, kivi, frzr

Bluetom

作为挨踢业的前段湿 搬过砖也画过画:爱看、爱听、爱玩儿、爱折腾、爱打撸啊撸、intj

Proudly published with Hexo