投身烈火
Keep Walking

Picodom -- 1kb的Virtual DOM库

话说,对于Virtual DOM解析的文章不少。但是,要不就是浅尝辄止,说到把dom解析成树型数据结构就结束了,不讲补丁算法,要不就是补丁算法说的太深奥完全理解不了。今天发现个好货,Picodom,这个库用了200多行代码就把 virtual dom 和 patch 算法实现了,这下好了,有了实际代码理论也好理解,废话少说,快来一起读代码吧~

咱们先看看咋使,官方给出了一个小栗子

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
import { h, patch } from "picodom"
/** @jsx h */
// 加了这个之后babel就会用h作为jsx中的vnode构造函数
let element, oldNode
/**
* 重新刷新界面用的
* @param newNode 新的vnode
* @return 更新后的dom对象
*/
function render(newNode) {
// 为了方便理解我稍微修改了下这个函数
element = patch(
document.body, // 要更新的节点的父级
element, // 要更新的节点对应的dom对象
oldNode, // 根据旧状态构造的vnode
newNode // 根据新状态构造的vnode
)
// patch函数会更新界面,返回更新后的dom节点
oldNode = newNode
// 更新后将传入的vnode标记为旧的
return element
}
/**
* 根据不同的state生成不同的vnode对象
* @param state 新的状态
* @return 构造好的vnode对象
*/
function view(state) {
return (
<div>
<h1>{state}</h1>
<input
oninput={e => render(view(e.target.value))}
// 每次input框的值变化时都重新运行render,来刷新界面
value={state}
type="text"
/>
</div>
)
}
render(view("Hello Picodom!"))
// 启动~!

picodom 的 核心的一共就俩文件,h.js 用来实现 virtual dom ,patch.js 用来实现补丁算法:

1
2
3
4
|
|-- index.js // 导出h和patch方法
|-- h.js // 实现 virtual dom
|-- patch.js // 实现 patch

先看看 virtual dom 是怎么实现的吧。

平时写jsx没感觉,但是看babel转换后的代码就可以看到,比如这样的结构:

1
2
3
4
5
6
7
8
<div>
<h1>{state}</h1>
<input
oninput={e => console.log(e.target.value)}
value={state}
type="text"
/>
</div>

转换后应该是类似这样的:

1
2
3
4
5
6
7
8
9
// 假设fn是vnode构造函数
fn('div', null,
fn('h1', null, state),
fn('input', {
oninput: e => console.log(e.target.value),
value: state,
type: "text"
})
)

从上面的伪码可已看出,一般 virtual dom 的构造函数接收的参数中,第一个参数是标签名,第二个参数是属性,后面的参数是子节点。ok,那我们来看下 h.js

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
/**
* virtual dom 节点构造函数
* @param tag 标签名
* @param data 标签属性
* @return 构造好的vnode
*/
export function h(tag, data) {
var node
var stack = []
var children = []
for (var i = arguments.length; i-- > 2;) {
stack[stack.length] = arguments[i]
// 除了前两个参数,其他的参数作为子节点的数据,都推入stack备用
}
while (stack.length) {
// 循环stack
node = stack.pop()
// 逐个取出子节点的数据
if (Array.isArray(node)) {
for (var i = node.length; i--;) {
stack[stack.length] = node[i]
}
// 如果子节点数据是个数组,就把他展开
} else if (node != null && node !== true && node !== false) {
// 子节点数据是null、true、false时不做处理
if (typeof node === "number") {
node = node + ""
// 如果子节点数据是数字,则转换成字符串
}
children[children.length] = node
//把子节点数据存到children里
}
}
// 上面的循环结束后,所有的子节点数据都已经展开,并存到children中
return typeof tag === "string" ?
{
tag: tag,
data: data || {},
children: children
} :
tag(data, children)
// 如果tag是字符串就返回一个节点的描述对象
// 如果tag不是字符串就认为tag是组件构造函数,将节点的属性信息和子节点信息都传进去,让其创建节点描述对象
}

看过上面的代码我们不难想象,最终构造出的 virtual dom,就是一个描述dom片段的树状结构的对象,每个节点有tag、data、children三个属性,tag、data描述当前节点,children描述子节点,最终描述出整个dom片段。

看过 virtual dom 的构造函数之后,是不是觉得比想象中简单?只要理清逻辑,其实你也能写出来对不对?

好的,那我们再来看看patch.js,代码中不重要的函数我先标注作用,想看具体实现就往后拉吧

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/*
* 非重点函数的功能
* createElementFrom 根据vnode创建节点,里面会递归children创建子节点
* updateElementData 根据新的vnode更新当前dom对象的属性值
* getKeyFrom 获取vnode的key属性,key属性使用来标记元素唯一性的
*/
/**
* 补丁算法实现函数
* @param parent 要更新节点的父节点dom对象
* @param element 要更新节点的dom对象
* @param oldNode 要更新节点旧的vnode对象
* @param node 要更新节点新的vnode对象
* @return 刷新后的dom节点
*/
export function patch(parent, element, oldNode, node) {
if (oldNode == null) {
element = parent.insertBefore(createElementFrom(node), element)
// 如果没有旧的vnode对象则直接创建插入节点
} else if (node.tag && node.tag === oldNode.tag) {
updateElementData(element, oldNode.data, node.data)
// 如果当前节点的标签名没有变化,则直接当前节点的属性
var len = node.children.length
var oldLen = oldNode.children.length
var reusableChildren = {}
var oldElements = []
var newKeys = {}
// 下面主要做的就是对比子节点
for (var i = 0; i < oldLen; i++) {
var oldElement = element.childNodes[i]
oldElements[i] = oldElement
var oldChild = oldNode.children[i]
var oldKey = getKeyFrom(oldChild)
if (null != oldKey) {
reusableChildren[oldKey] = [oldElement, oldChil]
}
}
// 用旧vnode的子节点构造一个可复用的列表
var i = 0
var j = 0
while (j < len) {
var oldElement = oldElements[i]
var oldChild = oldNode.children[i]
var newChild = node.children[j]
var oldKey = getKeyFrom(oldChild)
if (newKeys[oldKey]) {
i++
continue
}
var newKey = getKeyFrom(newChild)
var reusableChild = reusableChildren[newKey] || []
if (null == newKey) {
if (null == oldKey) {
patch(element, oldElement, oldChild, newChild)
j++
}
i++
} else {
if (oldKey === newKey) {
patch(element, reusableChild[0], reusableChild[1], newChild)
i++
} else if (reusableChild[0]) {
element.insertBefore(reusableChild[0], oldElement)
patch(element, reusableChild[0], reusableChild[1], newChild)
} else {
patch(element, oldElement, null, newChild)
}
j++
newKeys[newKey] = newChild
}
}
// 根据key复用旧节点
while (i < oldLen) {
var oldChild = oldNode.children[i]
var oldKey = getKeyFrom(oldChild)
if (null == oldKey) {
removeElement(element, oldElements[i], oldChild)
}
i++
}
// 移除没有key的旧节点
for (var i in reusableChildren) {
var reusableChild = reusableChildren[i]
var reusableNode = reusableChild[1]
if (!newKeys[reusableNode.data.key]) {
removeElement(element, reusableChild[0], reusableNode)
}
}
// 根据新的key过滤掉无用的节点
} else if (node !== oldNode) {
var i = element
parent.replaceChild((element = createElementFrom(node)), i)
// 如果标签名变了就创建新节点替换当前节点,replaceChild是dom的api
}
return element
}

嗯,根据key更换节点的逻辑我也看得比较模糊,坑先挖下,后续再填吧。总的来说 picodom 非常适合用来做研究,从中可以大概了解到virtual dom 和 patch 的基本原理,至于实用性的话……拿来做jsx解析的模板语言没准儿能行吧……

好的那么由于时间不足,本期的博客就先写到这里,如果不出意外的话,maybe可能也许大概下周五会更新吧,能不能准时更新,就全看米娜桑点赞转发安利留言的热情了~!

白了个白~!