如何创建自己的虚拟DOM

相信写过代码生成器的朋友都会对V-DOM不陌生,我也不例外,在这里翻译一篇挺好的文章来总结一下。

翻译自: How to write your own Virtual DOM
(部分由译者修改)

当创建自己的虚拟DOM的时候,有一样事情你需要知道,就是你不需要使用到任何react/vue的资源或者代码。因为使用到它们的话,将会使我们的虚拟DOM非常的大和复杂,我们尽量在50行代码去完成它。

这里有两个主要的概念:

  • 虚拟DOM食是真实DOM的表现形式
  • 当我们修改虚拟DOM树的任何内容,我们会获得新的虚拟DOM树。算法会比较两棵树(新和旧),找到不同的部分并且只会在真实DOM上更新这些部分。

就是这些,我们开始来更深入的看看这些概念。

更新: 另外一篇文章讲述了怎么在虚拟DOM上设置props和事件,就是这里

表现出我们的DOM树

最开始,我们需要把我们的DOM树保存到内存中。我们可以先使用旧有得JS对象结构。
首先我们会有类似这样的树:

1
2
3
4
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>

看起来挺漂亮的吧?那么怎么用JS对象去表达它呢:

1
2
3
4
5
6
7
8
{
type: 'ul',
props: { 'class': 'list' },
childen:[
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] },
]
}

在这里你有两样事情需要注意:

  • 我们用对象表现了DOM元素:

    1
    { type: '...', props: {...}, children: [...]}
  • 我们用JS的字符串来表现了DOM的文字节点:

但是用这样的一种方法去表现一棵大的树是挺困难的。所以我们写一个helper函数来帮助我们更容易地去了解结构。

1
2
3
function h (type, props, ...children) {
return { type, props, children}
}

现在我们就可以用下面的方式来表现DOM树:

1
2
3
4
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
)

看起来干净很多了是吧? 但是我们可以更进一步,听说过JSX吧? 是的,我们在这里同样可以使用。

如果你阅读过Babel JSX文档,你就会知道,Babel会将下面的代码:

1
2
3
4
<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>

转化成

1
2
3
4
React.createElement('ul', { className: 'list'},
React.createElement('li', {}, 'item 1'),
React.createElement('li', {}, 'item 1'),
);

注意到相似的地方了吗? 是的,它只是将我们的h(...)换成了React.createElement(...)。这证明我们可以使用一个叫 jsx 编译的东西。我们只需要引用一个注释在我们的源文件最顶部:

1
2
3
4
5
6
/** @jsx h */

<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>

那么我们就告诉了Babel: 喂!帮我将JSX转换成 h而不是React.createElement。当然,你也可以用其他东西来替换掉h

所以我们可以这样来写我们的DOM:

1
2
3
4
5
6
7
/** @jsx h */
const a = (
<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>
)

经过babel的编译,可以得到:

1
2
3
4
5
6
const a = (
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
)
)

h执行的时候,它会返回一个原始的JS对象,就像下面那样:

1
2
3
4
5
6
const a = (
{ type: ‘ul’, props: { className: ‘list’ }, children: [
{ type: ‘li’, props: {}, children: [‘item 1’] },
{ type: ‘li’, props: {}, children: [‘item 2’] }
] }
);

## 使用我们的DOM展示
那么现在我们已经可以使用我们自己的结构使用JS对象来表示DOM树。屌爆了,但是我们还需要可以通过它来建立新的真实DOM。因为我们不会只单单执行展示操作。

首先先定义一些假设和术语:

  • 我会在变量前面加$,用来表示所有真实的DOM节点(元素,文字节点),因此 $parent 就是一个真实的DOM元素。

  • 虚拟DOM展示的变量会命名为节点。

  • 跟React一样,你有且只能有一个根节点,其他的所有节点都被包含在根节点下。

好的,像前面一样,我们先来写一个 createElement(...) 用来将虚拟DOM转换成一个现实DOM节点。先别管props 和 children, 我们后面再补全:

1
2
3
4
5
6
function createElement(node) {
if (typeof node === 'string') {
return document.createTextNode(node)
}
return document.createElement(node.type)
}

这样我们就可以用下面的JS结构来表示节点:

1
2
3
4
5
{
type: '...',
props: { ... },
children: [ ... ]
}

因此,我们都可以传递文字节点或者虚拟节点。

然后现在来考虑下子节点children,每个子节点都是一个文字节点或者元素节点。所以它们也可以用我们的createElement(...)函数来生成。是的,你感觉到没有?这就有点递归的感觉了:))所以我们可以用createElement(...)来生成每个元素的子节点,然后使用appendChild()来将它们加入到我们的元素中:

1
2
3
4
5
6
7
8
9
10
function createElement (node) {
if (typeof node === 'string') {
return document.createTextNode(node)
}
const $el = document.createElement(node.type)
node.children
.map(createElement)
.forEach($el.appendChild.bind($el))
return $el
}

Wow! 看起來不错, 暂时先不考虑props,因为它会让虚拟DOM的基本概念变得复杂,不方便我们理解。

来个小总结:

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
/** @jsx h */

function h(type, props, ...children) {
return { type, props, children };
}

function createElement(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}

const a = (
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
);

const $root = document.getElementById('root');
$root.appendChild(createElement(a));

1
<div id="root"></div>

处理改变

接下来我们要把我们的虚拟DOM转换成真实DOM,是时候要考虑我们虚拟DOM树之间的比较。d我们要写一个最基本的算法,用来比较两棵虚拟树之间的差异,并且只把这些差异操作在真实DOM中。

那么怎么去比较树呢?我们需要考虑到下面的一些情况:

  • 在相同的位置没有旧的节点,所以我们需要通过appendChild(...)增加新的节点:
    Image 1

  • 在相同的位置没有新的节点,所以我们需要用到removeChild(...)来删除节点:
    Image 2

  • 在相同的位置节点不一样,那么我们需要用replaceChild(...)来进行节点的替换:
    Image 3

  • 相同位置节点相同的时候,我们需要进入更深一层的子节点进行比较:
    Image 4

OK,那么我们就来写一个叫updateElement(...),接收参数为:$parentnewNodeoldNode$parent是我们虚拟DOM节点的真实DOM父元素。接下来就来看一下我们怎么来处理上述的情况。

没有旧节点的时候

没有旧节点的时候就是没有纠节点,相当的直接:

1
2
3
4
5
6
7
function updateElement ($parent, newNode, oldNode) {
if (!oldNode){
$parent.appendChild(
createElement(newNode)
)
}
}

没有新节点的时候

在这里我们会有一个问题,如果在一棵新的虚拟树的当前位置上没有节点的话,我们应该从真实DOM中把它删除掉,但是我们应该怎么做呢?
对的,我们知道它的父元素是谁,那么我们就建议使用$parent.removeChild(...)。但是我们还没实现它。假设我们知道节点在父节点中的位置,那么我就可以用索引index来操作它$parent.childNodes[index].

OK,现在就可以通过index来实现我们的updateElement:

1
2
3
4
5
6
7
8
9
10
11
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
} else if (!newNode) {
$parent.removeNode(
$parent.childNodes[indexs]
)
}
}

节点改变

首先我们需要写一个函数去比较两个节点(新和旧)然后告诉我们节点的确更新了。元素和文字节点我们都应该考虑到:

1
2
3
4
5
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
}

那么现在,通过索引index我们就可以很简单的把旧的节点换成新的节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
)
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
)
}
}

子节点差异

最后,我们要需要遍历所有节点的子节点,并且检查它们是否有改变,实际上是对每个节点都执行一次updateElement(...)。是的,又是递归。

但是同样有下面几项需要考虑的:

  • 我们只比较元素下的子节点(文字节点无子节点)
  • 我们将当前节点的引用作为父节点传递
  • 我们需要一个一个节点去比较,即使一些节点会得到undefined也在所不惜,我们的函数可以处理它
  • 索引index仅仅是当前子节点数组的索引
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
  if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
);
} else if (newNode.type) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; i++) {
updateElement(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}

把他们合在一起

终于到了这一步了,把我们上面整理的东西都合在一起:

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
/** @jsx h */

function h(type, props, ...children) {
return { type, props, children };
}

function createElement(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}

function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
}

function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
);
} else if (newNode.type) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; i++) {
updateElement(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}

// ---------------------------------------------------------------------

const a = (
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>
);

const b = (
<ul>
<li>item 1</li>
<li>hello!</li>
</ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {
updateElement($root, b, a);
});
1
2
<button id="reload">RELOAD</button>
<div id="root"></div>
1
2
3
4
5
#root {
border: 1px solid black;
padding: 10px;
margin: 30px 0 0 0;
}

打开开发者工具来观察一下当我们点击reload按钮的时候DOM的改变:
Dom Change

总结

恭喜!我们完成了一个简单版本的虚拟DOM。希望你读了这篇文章以后能后了解到虚拟DOM的基本概念以及React的底层是如何去做的。

然而依然有很多东西没有提及到(我尽量在之后的文章来讲述他们):

  • 设置元素的属性(props)以及差异更新它们
  • 事件处理———对它们的元素对象进行事件监听
  • 让我们的虚拟DOM能够用上组件化,像React那样
  • 与真实DOM节点的关联
  • 与直接修改真实DOM的插件兼并使用,例如jQuery和它的插件
  • 还有很多很多