50行代码实现Virtual DOM
在你创造出自己的Virtual DOM之前,你只需要知道两件事情。你甚至不需要深入了解React的源代码,或者其他Virtual DOM的实现。它们都太庞大和复杂了,但实际上Virtual DOM的部分只需要不超过50行的代码!(当然,你千万不要把它放在生产环境)
这里有2个概念:
- Virtual DOM是真实DOM的映射。
- 当我们在Virtual DOM树改变一些东西的时候,我们得到了一个新的Virtual DOM树,通过算法比较新树和旧树,找到不同的地方,然后只需要在真实的DOM上做出相应的改变。
仅此而已,让我们来深入这两个概念。
构建我们的Virtual DOM树
首先,我们要在内存中存储我们的DOM树,我们能够用纯JS对象来表示它,假设我们有这样的一个结构:
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
看起来非常简单,那我们怎么用JS对象来构造它呢?
{ type: 'ul', props: { 'class': 'list' }, children: [
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] }
] }
这里有两个点需要注意下:
- 我们用JS对象表示DOM的元素:
{ type: '...', props: {...}, children: [...] }
- 我们用JS字符串表示DOM的文本节点。
但是用这样的方式写一个更大的树的结构是非常复杂的,所以让我们先写一个帮助函数,它能让我们更容易的理解结构。
function h(type, props, ...children) {
return {
type,
props,
children
}
}
现在我们能这样去写我们的DOM树:
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
)
这样看起来清晰多了吧?但是我们还可以让它变得更好,你应该听过JSX,对吧?它是怎么工作的呢?
如果你看过Babel JSX的官方文档,你就会知道,Babel会把下面的代码:
<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>
编译成:
React.createElement('ul', { className: 'list' },
React.createElement('li', {}, 'item 1'),
React.createElement('li', {}, 'item 2'),
)
是不是看起来有点熟悉?如果我们能够用我们的h(...)
函数代替React.createElement(…)
,那么我们也能使用JSX语法。其实,我们只需要在源文件头部加上这么一句注释:
/** @jsx h */
它实际上是告诉Babel:'哥们, 帮我编译JSX语法,用h(...)
函数代替React.createElement(…)
,然后Babel就开始编译。
因此,总结我之前说的,我们将用这样的方式去写我们的DOM树:
/** @jsx h */
const a = (
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
)
Babel会帮我们编译成这样的代码:
const a = h( 'ul',{ 'class': 'list' },
h( 'li', null, 'item 1' ),
h( 'li', null, 'item 2' )
);
当h(...)
执行的之后,它将会返回纯的JS对象,即我们的虚拟DOM。
运用Virtual DOM构建真实的DOM
现在我们使用JS对象来表示DOM的结构,这非常酷,但是我们需要用它创建一个真实的DOM。
首先,让我们做一些假设并设置一些术语。
- 我会用带
$
的变量名来表示真实的DOM树, — 因此$parent将会是一个真实的DOM节点。 - Virtual DOM在变量中使用
node
命名。 - 就像在React中,你仅仅只有一个root节点,其他所有的节点都将会在它里面。
如上所述,让我们来写一个createElement(…)
函数把Virtual DOM转换成真实的DOM。
因为我们有两种节点,text和element。因此我们的createElement
函数需要处理这两种情况。
让我们想一下,其实子节点要么是一个element,要么是一个text节点,是text节点的话,我们直接渲染:
document.createTextNode(node)
是element节点的话 需要递归地把它的子节点也构建起来:
const $el = document.createElement(node.type)
node
.children
.map(createElement)
.forEach($el.appendChild.bind($el))
createElement
代码如下:
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
}
现在的完整代码如下:
<div id="root"></div>
/** @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))
WOW,是不是看起来很不错,让我们暂时先抛开props
,我们稍后会谈到它。
比较两棵虚拟DOM树的差异
现在我们已经把virtual DOM转换成一棵真实的DOM树,是时候考虑下怎么比较两棵虚拟DOM树的差异了。最基本的,我们需要一个算法来比较新的树和旧的树,它能够让我们知道什么地方改变了,然后相应的去改变真实的DOM。
怎么比较DOM树呢?我们需要处理下面的情况:
- 添加新节点,我们需要用
appendChild
方法添加节点
- 移除老节点,我们需要用
removeChild
方法移除老的节点
- 节点的替换,我们需要用
replaceChild
方法
- 节点相同,因此我们需要深度比较子节点
让我们开始写updateElement
方法,它需要传递3个参数:$parent
, newNode
和oldNode
。$parent
是我们虚拟节点的真实的父级DOM元素。现在我们来看看怎么处理上面描述的所有的情况。
添加新节点
非常直接,我甚至都不需要写注释。
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
}
}
移除老节点
这里我们遇到一个问题 — 如果在新的Virtual DOM树里面没有某个节点,那我们应该在真实的DOM树移除它。但我们应该怎么做呢?
如果我们已知父元素(通过参数传递),我们就能调用$parent.removeChild(…)
方法把变化映射到真实的DOM上。但前提是我们得知道我们的节点在父元素上的索引,我们才能通过$parent.childNodes[index]
得到该节点的引用。
OK,让我们假设index
将会通过参数传递(确实如此,稍后会看到),我们的代码如下:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
)
}
}
节点变化
首先我们需要写一个函数比较旧树和新树的不同,告诉我们node真的改变了。我们需要考虑文本和元素这两种情况:
function changed(node1, node2) {
return (
typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
)
}
现在,当前的节点有了index
属性,我们能够很简单的用新节点替换它:
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
— 它只是子节点数组的索引。
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
)
}
}
}
到此就基本完成了,当你点击Reload按钮的时候,你可以打开开发者工具观察元素的变化。
你可以在这里找到所有的代码,github。