为了构建你自己的 Virtual DOM,你只需要知道两件事,甚至你都不必深入 React 或者其它 Virtual DOM 实现的源码。因为它们都太庞大和复杂了 —— 但是实际上 Virtual DOM 的主要部分可以用少于 50 行代码实现。50 行!!!
两个概念:
就是这些,让我们深挖每个概念的含义。
更新:关于 Virtual 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’] }
] }
这里我们强调两件事:
{ type: ‘…’, props: { … }, children: [ … ] }
但是以这种方式写大型的树是非常困难的。所以我们来写一个帮助函数,使得理解这个结构更容易一些:
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’] }
] }
);
JSFiddle
Ok,现在我们有了纯 JS 对象以及自己结构的 DOM 树表达形式。非常酷,但是我们得利用它创建一个真实的 DOM。毕竟我们不能直接把表达式写入 DOM。
首先我们先进行一系列假设并设定一些术语:
$
开头的变量代表真实 DOM 节点(元素以及文本),那么 $parent 就是一个真实 DOM 元素;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 概念不需要它们,只会徒增复杂性。
JSFiddle
Ok,现在我们能够把虚拟 DOM 转换为真实 DOM,到了该比较虚拟树差异的时候了。基本上我们要写个算法,比较两棵新旧树的差异,并对真实 DOM 做最少必要的更新。
如何比较树的差异?我们需要处理下面几个问题:
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
,没关系,我们的函数能处理它;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 的内部机制有了基本的了解。
然而,这里我们有些事情没有强调(我会在未来的文章中涉及到):
往期精选文章 |
---|
使用虚拟dom和JavaScript构建完全响应式的UI框架 |
扩展 Vue 组件 |
使用Three.js制作酷炫无比的无穷隧道特效 |
一个治愈JavaScript疲劳的学习计划 |
全栈工程师技能大全 |
WEB前端性能优化常见方法 |
一小时内搭建一个全栈Web应用框架 |
干货:CSS 专业技巧 |
四步实现React页面过渡动画效果 |
让你分分钟理解 JavaScript 闭包 |
小手一抖,资料全有。长按二维码关注京程一灯,阅读更多技术文章和业界动态。