相信写过代码生成器的朋友都会对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 | function h (type, props, ...children) { |
现在我们就可以用下面的方式来表现DOM树:1
2
3
4h('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
4React.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
6const a = (
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
)
)
当h
执行的时候,它会返回一个原始的JS对象,就像下面那样:1
2
3
4
5
6const 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 | function createElement(node) { |
这样我们就可以用下面的JS结构来表示节点:
1 | { |
因此,我们都可以传递文字节点或者虚拟节点。
然后现在来考虑下子节点children
,每个子节点都是一个文字节点或者元素节点。所以它们也可以用我们的createElement(...)
函数来生成。是的,你感觉到没有?这就有点递归的感觉了:))所以我们可以用createElement(...)
来生成每个元素的子节点,然后使用appendChild()
来将它们加入到我们的元素中:
1 | function createElement (node) { |
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(...)
增加新的节点:在相同的位置没有新的节点,所以我们需要用到
removeChild(...)
来删除节点:在相同的位置节点不一样,那么我们需要用
replaceChild(...)
来进行节点的替换:相同位置节点相同的时候,我们需要进入更深一层的子节点进行比较:
OK,那么我们就来写一个叫updateElement(...)
,接收参数为:$parent
,newNode
,oldNode
,$parent
是我们虚拟DOM节点的真实DOM父元素。接下来就来看一下我们怎么来处理上述的情况。
没有旧节点的时候
没有旧节点的时候就是没有纠节点,相当的直接:
1 | function updateElement ($parent, newNode, oldNode) { |
没有新节点的时候
在这里我们会有一个问题,如果在一棵新的虚拟树的当前位置上没有节点的话,我们应该从真实DOM中把它删除掉,但是我们应该怎么做呢?
对的,我们知道它的父元素是谁,那么我们就建议使用$parent.removeChild(...)
。但是我们还没实现它。假设我们知道节点在父节点中的位置,那么我就可以用索引index来操作它$parent.childNodes[index]
.
OK,现在就可以通过index来实现我们的updateElement
:
1 | function updateElement($parent, newNode, oldNode, index = 0) { |
节点改变
首先我们需要写一个函数去比较两个节点(新和旧)然后告诉我们节点的确更新了。元素和文字节点我们都应该考虑到:
1 | function changed(node1, node2) { |
那么现在,通过索引index
我们就可以很简单的把旧的节点换成新的节点:
1 | function updateElement($parent, newNode, oldNode, index = 0) { |
子节点差异
最后,我们要需要遍历所有节点的子节点,并且检查它们是否有改变,实际上是对每个节点都执行一次updateElement(...)
。是的,又是递归。
但是同样有下面几项需要考虑的:
- 我们只比较元素下的子节点(文字节点无子节点)
- 我们将当前节点的引用作为父节点传递
- 我们需要一个一个节点去比较,即使一些节点会得到
undefined
也在所不惜,我们的函数可以处理它 - 索引
index
仅仅是当前子节点数组的索引
1 | if (!oldNode) { |
把他们合在一起
终于到了这一步了,把我们上面整理的东西都合在一起:
1 | /** @jsx h */ |
1 | <button id="reload">RELOAD</button> |
1 | #root { |
打开开发者工具来观察一下当我们点击reload按钮的时候DOM的改变:
总结
恭喜!我们完成了一个简单版本的虚拟DOM。希望你读了这篇文章以后能后了解到虚拟DOM的基本概念以及React的底层是如何去做的。
然而依然有很多东西没有提及到(我尽量在之后的文章来讲述他们):
- 设置元素的属性(props)以及差异更新它们
- 事件处理———对它们的元素对象进行事件监听
- 让我们的虚拟DOM能够用上组件化,像React那样
- 与真实DOM节点的关联
- 与直接修改真实DOM的插件兼并使用,例如jQuery和它的插件
- 还有很多很多