构建你自己的虚拟DOM,你需要知道2个事情。你不需要理解 React,或者深入其他虚拟DOM的实现源码。他们太过庞大和复杂,事实上,虚拟DOM的核心代码的实现甚至可以少于50行代码。
下面是2个原则:
- 虚拟DOM是任意一种真实DOM的表现。
- 当你修改了虚拟DOM树,我们会得到一棵新的树。算法会比较这2棵树,找到差异,对真实DOM做最小的更新。
下面让我们深入上面的2个原则。
表示我们的dom树
首先,我们需要一种方式来存储dom树。我们可以使用 javascript 对象。假设我们有下面这样一颗树:
<ul class=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
看起来是不是很简单,我们如何用 javascript 对象表示这棵树呢?
{
"type":"ul",
"props":{
"class":"list"
},
"children":[
{
"type":"li",
"props":{
},
"children":[
"item 1"
]
},
{
"type":"li",
"props":{
},
"children":[
"item 2"
]
}
]
}
我们可以注意到2个事情:
- 我们用对象表示DOM元素。
{ type: ‘…’, props: { … }, children: [ … ] }
- 我们用字符串表示DOM文本元素。
但是用这种方式编写一棵很大的树会很困难,所我们需要一个帮助方法,这样可以使我们很容易理解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’),
);
是不是很相似?如果把 React.createElement(…) 替换为 h(…),我们就可以使用 jsx 语法。我们只需要在文件头部添加一行注释。
/** @jsx h */
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
它会告诉 Babel 把 React.createElement(…) 替换为 h(…)。当然,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 对象 - 虚拟DOM:
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。因为我们不能直接追加表达式到真实的DOM中。
首先我们做一些假设,制定一下术语:
-
真实DOM节点的变量我会以
$开头,因此 $parent 就是一个真实的DOM元素 -
虚拟DOM表达式将在名为node的变量中
-
比如在
React中,你只会有一个根节点(one root node),其他所有的节点都在根节点下
入上文所说,让我们创建 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,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 放到一边,后面再讨论。理解虚拟DOM的基础原则不需要用到它们, 而且它们会增加复杂度。
处理更新
现在我们可以把一个虚拟DOM转化为真实的DOM,是时候取考虑比对我们的虚拟DOM树。就是说,我们需要实现一个算法,算法会比对2棵虚拟DOM树 - 新的和旧的,真实的DOM只做必要的更新。
如何比较2棵树?我们需要处理下一个例子:
- 有些地方没有旧的节点,我们调用 appendChild(…) 添加一个新的节点。

- 有些地方没有新的节点,我们调用 removeChild(…) 删除这个节点。

- 如果一个地方有不同的2个节点,我们调用 replaceChild(…) 更新这个节点。

- 如果节点相同,我们需要更深入,去比较子节点。

让我们创建一个函数 updateElement(…),函数有3个参数-parent 是真实的DOM节点,虚拟DOM的父亲节点。现在让我们看看怎么处理上面提到的所有例子。
没有旧的节点
这个很简单,我都不用注释:
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
没有新的节点
如果新的虚拟DOM树的当前位置没有节点,我们需要从真实DOM中删除对应的节点。我们知道父亲节点,这样就可以调用 parent.childNodes[index] 拿到这个引用,inex 是节点在父亲节点中的位置。
假设 index 会传递到我们的方法中。代码如下:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
}
}
节点更新
首先我们创建一个方法用于比较2个节点(新的和旧的),告诉我们哪个节点有变化。我们需要同时考虑元素节点和文本节点。
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]
);
}
}
比对children
最好,我们需要遍历节点的子节点并且进行比对。我们把这个过程叫 *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
);
}
}
}
合在一起
/** @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);
});
结论
恭喜,我们做到了。我们实现了虚拟DOM。通过这个文章,我们读者能理解虚拟DOM的基本原则,以及 React 底层是怎么工作的。
然而有一些这里没有提到的:
- 设置元素属性(props),区别和更新它们
- 事件处理,添加事件到元素中。
- 让虚拟DOM支持组件,就像
React - 从真实DOM中获取引用
- 引入可以处理真实DOM变化的三方库,比如
jQuery和它的插件