50行代码实现虚拟DOM
50行代码实现虚拟DOM
你不需要深入react源码,或者深入其他虚拟DOM实现的代码。你只要知道两点就能建立自己的虚拟DOM。虚拟DOM虽然体系庞大复杂,但是事实上虚拟DOM的主要部分只用50行代码则可。
记住下面两个概念:
-
虚拟DOM是任何形式DOM的表示。 -
当改变虚拟DOM树时,会得到一个新的虚拟树。算法会比较这两个树的差别,且对DOM做一下小的必要的修改,以至于他能对虚拟DOM进行映射。
为开发者提供实现职业提升、专业进阶和可持续成长。web开发相关技能(Vue,React,nodejs,JS基础,前端性能优化),前端面试题集锦等
渲染DOM树
首先,需要将DOM树存储在内存中。例子中用普通的JS对象表示,假设有这个DOM树:
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
part 1
如果只用纯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文本节点。
50行代码实现虚拟DOM
part 2
但是用这种方式来写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,那么他是怎么工作的?
50行代码实现虚拟DOM
part 3
根据JSX官方Babel文档,Babel将部分1的代码转译为部分2
// 部分1
<ul class="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 */
<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>
这段代码是这样的,他告诉Babel:嗨,将React.createElement
调用函数 代替 h
函数去转译这个jsx文件。你可以放任何函数代替h
,然后就会按照相应的方式转译代码。
part 4
因此,总结上文,我们以下列方式写DOM :
/** @jsx h */
const a = (
<ul className="list">
<li>item 1</li>
<li>item 2</li>
</ul>
);
这样,Babel就会将这段代码解析成这样:
const a = (
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
);
);
当函数h
执行的时候,它将返回纯JS对象---我们的虚拟DOM的表现形式:
const a = (
{ type: 'ul',
props: { 'class': 'list'},
children:
[
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] }
]
}
);
尝试一下这个代码(不用忘记设置Babel):
/** @jsx h */
function h(type, props, ...children) {
return { type, props, children };
}
const a = (
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
);
console.log(a);
执行结果如下所示:
应用DOM表示
现在,我们已经将DOM树表示为具有自己结构的纯JS对象。是不是很cool! 但是我们需要根据它建立一个DOM对象。因为我们不能将我们的表示添加到这个纯JS对象中。
首先,我们做些假设且设置一些术语:
-
我将以 $
开头编写所有具有实际DOM节点(元素、文本节点)的变量---——因此$parent
将是DOM元素。 -
虚拟DOM的表示 将在名为node的变量中。 -
像React一样,只能有一个根节点——--所有其他节点都在根结点里面。
写一个函数 createElement(…)
, 这个函数接受一个虚拟DOM返回一DOM节点。现在先忽略 props
and children
,后续再设置。
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
return document.createElement(node.type);
}
因为可以同时有 文本节点(即纯JS字符串) 和 元素(即JS对象,如:{ type: ‘…’, props: { … }, children: [ … ] }
)
因此,我们可以在这里同时传递虚拟文本节点和虚拟元素节点——这样就可以了。
现在我们来考虑一下children属性,他们不是文本节点就是元素节点。所以他们也可用函数 createElement(…)
创造出来。类似递归,所以可以为每个元素的子元素调用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;
}
咦!看起来很cool~。现在先不讨论 props
节点,放在后边介绍。
代码实际操作一波:
/** @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));
Vue,React,nodejs,JS基础,前端性能优化
处理变更
现在我们可以将你虚拟DOM转换为DOM,是时候区分虚拟DOM树了。因此,我们需要编写一个算法,它将比较两个虚拟树——旧的和新的——并只对实际DOM进行必要的更改。
怎么区分不同不得树结果?看下列的例子:
-
有些地方没有旧的节点-----所以需要添加这样的节点,需要利用 appendChild(…)
, 如下图 -
有些没有新的节点------因此这些节点被删除了,需要利用 removeChild(…)
,如下图 -
有些地方存在不同的节点,因此这些节点被改变了,需要利用 replaceChild(…)
,如下图 -
一些节点相同----所以需要看下一层级比较子节点的不同
这样,我们可以写一个叫做updateElement(…)
的函数,他有三个参数---$parent
,newNode
,oldNode
。其中$parent
是虚拟节点的DOM的父元素。下面是我们如何处理上述的所有情况。
Vue,React,nodejs,JS基础,前端性能优化
没有旧节点
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
没有新节点
有一个问题——如果在新虚拟树的当前位置没有节点——我们应该从实际DOM中删除它——但我们应该怎么做呢? 是的,我们知道父元素,因此我们应该调用$parent.removeChild(…)
并传递DOM元素引用那里。但是我们并没有这样调用。如果我们知道节点在parent中的位置,我们可以用$parent.childNodes[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
}
现在,在父节点中有了当前节点的索引,这样就可以很容易的用新创建的节点替换它。
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
);
}
}
}
把这些都放在一起
把所有代码放在一起,咦!50行代码就能实现!
/** @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如何在底层工作。
如果觉得这篇文章还不错,来个【分享、点赞、在看】三连吧,让更多的人也看到~