虚拟DOM精简实现详解
引言
实现一套虚拟DOM你无需深入理解React的源码或者其他实现版本,只需要知道两件事,大部分实现都很庞大复杂,但实际上虚拟DOM的主体部分可以通过约50行代码实现,
只要50行!
以下是这两个概念:
-
虚拟DOM是真实DOM的某种展现
-
当虚拟DOM树发生变化时,我们会获得新的虚拟树。比较算法会比较这两棵树,找出差异,然后将最小的变化应用到真实DOM。
就只有这么多,让我们深入这些概念。
更新:关于虚拟DOM中实现设置属性和事件的文章在这里
如何表示真实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’),
);
注意到有什么一样的吗? 如果我们把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 1’),
)
)
当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, 需要实现从js对象创建真实DOM。
首先我们做下面的假设,建立方法:
-
我会定义所有DOM节点(元素、文本节点)变量以
$
开头, $parent表示真实DOM元素 -
虚拟DOM表示会以node命名
-
类似React, 只有一个根节点,所有节点都在根节点内部
定义了这些,我们开始编写从虚拟DOM创建真实DOM的createElement函数,暂时忽略props和children, 后面再实现。
function createElement(node) {
if(typeof node === 'string') {
return document.createTextNode(node);
}
return document.createElement(node.type);
}
因为我们同时有文本节点(表示为js字符串)和元素节点(表示为js对象)
{ type: '...', props: {...}, childrenL [...] }
我们可以同时传递虚拟文本节点和元素节点。
考虑到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忽略,暂时不需要添加props实现,引入复杂度。
处理虚拟DOM变化
我们已经实现把虚拟DOM转换成真实DOM,可以想想如何比较虚拟树。
需要实现一套基础逻辑,用来比较新树和旧树,最后只讲必要的变化应用到真实DOM。
如何比较树? 我们需要考虑下面的场景:
-
没有旧节点,节点新增,只需要调用appendChild函数
-
节点在某个位置被删除,只用调用removeChild函数
-
相同位置的节点不同,节点被替换,调用replaceChild函数
-
相同位置节点一样,需要继续比较子节点
这里我们写个函数updateElement, 接收三个参数, $parent 作为虚拟DOM节点真实父元素,newNode, oldNode。
下面详细介绍如何处理上面的场景.
纯粹新节点情况
function updateElement($parent, newNode, oldNode) {
if( !oldeNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
节点被删除情况
如果新虚拟树不存在节点,这意味着需要在真实节点汇中删除旧节点,但如何实现? 我们知道父元素,传入函数的参数,可以调用$parent.removeChild()方法,
传递真实DOM引用。但我们没有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.childNode[index]
);
}
}
节点发生变化
首先我们需要写个函数用来比较新旧节点,确认节点发生变化,
我们应该考虑节点可能同时是元素和文本节点。
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]
);
}
}
比较子代
比较子代,需要查找新旧节点的每个子代进行比较,然后递归调用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
);
}
}
}
组合实现
我已经所有代码放在jsfiddle中,总体实现真的只有我前面说的50行代码,你可以尝试玩下。
当你点击刷新按钮,可以打开开发者工具,查看元素变化。
处理Babel
开始实现props之前,我们需要修复之前实现的小问题,我们开始写节点时没有设置属性:
<div></div>
由于没有属性,Babel编译时会将元素props属性设置为null。
{ type: '', props: null, children: [] }
最好默认设置属性为空对象,这样后面迭代属性时不会发生错误。
为了修正这项,我们把h函数改造成这样:
function h(type, props, ...children) {
return { type, props: props || {}, children };
}
设置属性
设置属性很简单,还记得DOM实现不,我们会像纯js对象一样存储props:
<ul className=”list” style=”list-style: none;”></ul>
虚拟DOM在内存中的展示:
{
type: ‘ul’,
props: { className: ‘list’, style: ’list-style: none;’ }
children: []
}
props对象的键名就是属性名称,键值就是属性值。
我们只需要把props对象设置到真实DOM节点。
我们写个函数包装setAttribute方法:
function setProp($target, name, value) {
$target.setAttribute(name, value);
}
完成设置单个属性后,我们迭代整个props对象,实现全部设置:
function setProps($target, props) {
Object.keys(props).forEach(name => {
setProp($target, name, props[name]);
})
}
还记得createElement函数不? 我们只需要在完成真实DOM节点创建后设置所有props属性即可:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
setProps($el, node.props);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
到这里还没有结束,我们忽略一些细节,首先class
是js保留字,不能在属性名称中使用,这里用className
替代:
<nav className=”navbar light”>
<ul></ul>
</nav>
但是在真实DOM中没有className属性,setProp函数中需要处理这种情况。
另外一点是设置DOM节点的布尔属性(checked、disabled)十分方便:
<input type=”checkbox” checked={false} />
这个例子里面我希望checked
属性不会设置到真实DOM中,但实际上会。
因为你知道这个属性的存在会设置真实节点。我们需要修复下。
注意这里不仅仅是设置属性,也会设置元素引用对应的布尔属性。
function setBooleanProp($target, name, value) {
if (value) {
$target.setAttribute(name, value);
$target[name] = true;
} else {
$target[name] = false;
}
}
最后一点要提的是自定义属性, 对于我们的实现,未来或许需要设置属性,但不会在DOM中显示。
这里我们写个函数检测属性是否是自定义。
现在还是空的,因为暂时没有任何自定义属性。
function isCustomProp(name) {
return false;
}
下面是修复所有问题的setProp完整实现:
function setProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name === ‘className’) {
$target.setAttribute(‘class’, value);
} else if (typeof value === ‘boolean’) {
setBooleanProp($target, name, value);
} else {
$target.setAttribute(name, value);
}
}
比较props
现在我们完成创建带props的元素,需要考虑如何比较props变化。
其实,最终都是设置或删除属性。设置属性函数已经有了,需要写个删除属性函数:
function removeBoolenProp($target, name) {
$target.removeAttribute(name);
$target[name] = false;
}
function removeProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name === ‘className’) {
$target.removeAttribute(‘class’);
} else if (typeof value === ‘boolean’) {
removeBooleanProp($target, name);
} else {
$target.removeAttribute(name);
}
}
下面编写updateProp函数用来比较属性变化,根据比较结果修改真实DOM节点。这里需要处理几种情况:
-
新节点属性不存在,需要删除
-
旧节点属性不存在,需要新增
-
新旧节点中同时存在相同属性,需要比较属性值,如果不等,重新设置新节点属性值
-
其他场景属性没有任何变化,无需处理
下面是更新单个属性的实现:
function updateProp($target, name, newVal, oldVal) {
if (!newVal) {
removeProp($target, name, oldVal);
} else if (!oldVal || newVal !== oldVal) {
setProp($target, name, newVal);
}
}
实现还很简答,但是节点有多个属性,我们再写个可以遍历所有属性,然后每个键值对调用updateProp函数:
function updateProps($target, newProps, oldProps = {}) {
const props = Object.assign({}, newProps, oldProps);
Object.keys(props).forEach(name => {
updateProp($target, name, newProps[name], oldProps[name]);
});
}
注意这里我们创建的是复合对象,包括新旧节点的属性。因此当遍历属性时会遇到undefined
值,函数内部已经处理了,
最后一点需要考虑的是函数放在updateElement的哪个位置,可能节点没有变化,我们要比对子代,会首先检查属性变化。我们把属性比较放在最后一个if语句中在比对子代节点之前:
function updateElement($parent, newNode, oldNode, index = 0) {
...
} else if (newNode.type) {
updateProps(
$parent.childNodes[index],
newNode.props,
oldNode.props
);
...
}
}
事件
当然一般的交互应用中我们需要知道如何处理事件,之前我们通过class 调用querySelector查找节点,然后调用addEventListener绑定事件。
这不是很友好,我更想像React绑定事件的方式:
<button onClick={ () => alert('hi') }></button>
这看起来很棒!这里使用props定义事件监听器,属性名称以on
开头:
function isEventProp(name) {
return /^on/.test(name);
}
为了取出事件名称,这里需要写个函数用来删除on
前缀:
function extractEventName(name) {
return name.slice(2).toLowerCase();
}
似乎我们在Props对象中声明事件,那么需要在setProps/updateProps函数中处理事件。
这里需要考虑下如何比较函数?这里不能根据相同标志比较,可以使用toString()来比较函数代码。有一些含有原生代码的函数让我们无法进行比较。
当然我们可以使用事件冒泡进行处理,可以实现自己的事件管理器,绑定body
或根元素用来内部元素的所有事件。这样每次更新时可以重新添加事件监听器,也不会那么费劲。
这里不会这样实现,实际上只会带来更多问题,事件监听器一般不会频繁变化。
所有元素创建时事件监听器只会设置一次。
我们不想setProps函数处理事件属性到真实DOM节点,需要单独添加事件处理器。还记得自定义属性处理函数不?这里改造下:
function isCustomProp(name) {
return isEventProp(name);
}
向已知带有属性的真实DOM节点添加事件监听器还很简单:
function addEventListers($target, props) {
Object.keys(props).forEach(name => {
if(isEventProp(name)) {
$target.addEventListener(
extractEventName(name),
props[name]
);
}
});
}
把上面的实现放到创建元素函数中:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
setProps($el, node.props);
addEventListeners($el, node.props);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
重新添加事件
有一个简单方案可以实现重新添加事件,缺点是会损害性能。
我们引入一个自定义属性叫做forceUpdate
。
这里要调整changed函数:
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === ‘string’ && node1 !== node2 ||
node1.type !== node2.type ||
node.props.forceUpdate;
}
如果forceUpdate
为真,那么整个节点会重新创建,这样新的事件监听器可以再次添加,这里我们并不想设置到真实DOM节点:
function isCustomProp(name) {
return isEventProp(name) || name === 'forceUpdate';
}
基本上就是这些,这个方案性能方面不好,但很简单。
译者注
- 因译者水平有限,如有错误,欢迎指正交流