如何编写你自己的 Virtual DOM
本文转载自:众成翻译
译者:yanni4night
链接:http://www.zcfy.cc/article/1136
原文:https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060
为了构建你自己的 Virtual DOM,你只需要知道两件事,甚至你都不必深入 React 或者其它 Virtual DOM 实现的源码。因为它们都太庞大和复杂了 —— 但是实际上 Virtual DOM 的主要部分可以用少于 50 行代码实现。50 行!!!
两个概念:
- Virtual DOM 是真实 DOM 的任意一种表达形式;
- 在 Virtual DOM 树上的改动,会创建一个新的 Virtual DOM 树。比较新老 Virtual DOM 树的算法,会计算差异并对真实 DOM 进行最小的更改,所谓“虚拟”
就是这些,让我们深挖每个概念的含义。
更新:关于 Virtual DOM 中设置属性和事件的第二篇文章在这里。
描述 DOM 树
首先,我们需要以某种方式在内存中存储 DOM 树。可以利用纯 JavaScript 对象实现。假如我们有这样一棵树:
<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’] }
] }
这里我们强调两件事:
- 我们用对象来表示 DOM 元素
{ type: ‘…’, props: { … }, children: [ … ] }
- 我们用纯 JS 字符串表示 DOM 的文本节点
但是以这种方式写大型的树是非常困难的。所以我们来写一个帮助函数,使得理解这个结构更容易一些:
function h(type, props, …children) {
return { type, props, children };
}
现在向树中写入数据是这样的:
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’),
);
注意到相似点了么?对对对,如果我们把 React.createElement(…) 替换成我们的 h(…) 就好了 —— 我们确实可以使用所谓的 jsx 编译指令 做到这一点。只要在源码的开头放一行像注释的东西:
/** @jsx h */
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
这一行实际上告诉 Babel:嘿,用 h 而不是 React.createElement 来编译 jsx。你可以将 h
替换成任何东西,都会被编译。
因此,总结上面我所说的来看,我们会以下面的形式写 DOM:
/** @jsx h */
const a = (
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
);
Babel 会把它转译成:
const a = (
h(‘ul’, { className: ‘list’ },
h(‘li’, {}, ‘item 1’),
h(‘li’, {}, ‘item 2’),
);
);
当函数 h
被执行时,它会返回纯 JS 对象 —— 我们的 Virtual DOM 表示形式:
const a = (
{ type: ‘ul’, props: { className: ‘list’ }, children: [
{ type: ‘li’, props: {}, children: [‘item 1’] },
{ type: ‘li’, props: {}, children: [‘item 2’] }
] }
);
应用 DOM 表达形式
Ok,现在我们有了纯 JS 对象以及自己结构的 DOM 树表达形式。非常酷,但是我们得利用它创建一个真实的 DOM。毕竟我们不能直接把表达式写入 DOM。
首先我们先进行一系列假设并设定一些术语:
- 我会用
$
开头的变量代表真实 DOM 节点(元素以及文本),那么 $parent 就是一个真实 DOM 元素; - Virtual DOM 表达形式存储于变量 node 中;
- 像 React 一样,你可以只有一个根节点 —— 其它都是其后代节点
Ok,如前所述,我们写一个函数 createElement(…) 把虚拟 DOM 节点转换成真实 DOM 节点。暂时忘记 props
和 children
—— 过后再说:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
return document.createElement(node.type);
}
因为我们已经有了纯 JS 字符串表示的文本节点和像下面的以 JS 对象表示的元素:
{ type: ‘…’, props: { … }, children: [ … ] }
因此,我们在这里既可以处理虚拟文本节点也可以处理虚拟元素节点。
现在我们来考虑 children —— 每一个要么是一个文本节点要么是一个元素。所以他们都可以用我们的 createElement(…) 函数来创建。啊…你感到了么?我感受到了递归 :)) 于是我们在 children 的每一个元素上调用 createElement(…),并用 appendChild() 加入我们的元素中,像这样:
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;
}
哇,看起来非常赞。我们先把 props 放一放,过后再讨论它,因为理解基本的 Virtual DOM 概念不需要它们,只会徒增复杂性。
处理更新
Ok,现在我们能够把虚拟 DOM 转换为真实 DOM,到了该比较虚拟树差异的时候了。基本上我们要写个算法,比较两棵新旧树的差异,并对真实 DOM 做最少必要的更新。
如何比较树的差异?我们需要处理下面几个问题:
- 某个位置有新节点 —— 因此节点是被增加的,我们需要 appendChild(…) 它;
- 某个位置有旧节点 —— 因此节点是被删除的,我们需要 removeChild(…) 它;
- 某个位置有不同的节点 —— 节点被更新,我们需要 replaceChild(…) 它;
- 节点是相同的,我需要到下一层比较子节点
Ok,我们写一个函数 updateElement(…),输入 3 个参数,parent_**, **_newNode_** and **_oldNode_**,其中 **_parent 是我们的虚拟节点的对应的真实节点的父节点。现在看看我们如何处理上面提到的问题。
有一个新节点
相当简单了,都不必注释:
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
有旧节点
这里有问题了 —— 如果在 Virtual DOM 树的当前位置没有节点 —— 我们应该从真实 DOM 树中移除它 —— 但是我们如果做到?是的,我们知道父元素(传给函数了),于是,我们该调用 parent.removeChild(…)_** 并传入真实的 DOM 元素引用。但是我们并没有这个引用。如果知道在父元素中的位置的话,我们则可以用 **_parent.childNodes[index] 获取引用,这里 index 是索引:
假设这个 index 被传入了我们的函数(后面会看到,确实被传入了)。所以我们的代码是:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
}
}
节点更新
首先我们需要写一个函数来比较两个节点(新和旧),并且告诉我们节点是否被真的更新了。我们应该考虑到元素和文本节点:
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 —— 它只是子节点在
children
中的索引
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
);
}
}
}
综合
好了,我们已经完成了任务,我把所有的代码放到了 JSFiddle,实现部分确实使用了 50 行代码,亦如我承诺你的那样。去玩玩它吧。
打开开发者工具,在你按下 Reload
按钮后观察应用的更新。
总结
恭喜你!我们达到了目的,实现了自己的 Virtual DOM,并且能正常工作。我希望在阅读完这篇文章后,你已经对 Virtual DOM 是如何工作的、React 的内部机制有了基本的了解。
然而,这里我们有些事情没有强调(我会在未来的文章中涉及到):
- 设置元素属性并且比较或更新它们;
- 处理事件 —— 为元素增加事件;
- 让 Virtual DOM 和组件一起工作,像 React 那样;
- 获取到真实 DOM 节点的引用;
- 让 Virtual DOM 与直接操作 DOM 的库一同工作,如 jQuery 极其插件;
- 其它…
P.S.
如果在代码或文字中有任何错误,或者代码可以有的任何优化,请在评论中指出 :) 另外,对于我的英语我很抱歉 :)
更新:关于 Virtual DOM 中设置属性和事件的第二篇文章在这里。