从一个前端菜鸟的角度聊一聊虚拟DOM
想要解释一下标题,对于虚拟DOM我并没有去深入了解,只是略微知道个大概原理,这里也只是略微说一下原理。
虚拟DOM是什么
首先,什么是DOM?
Document_Object_Model,文档对象模型。将html页面元素与一个个的对象一一对应,方便我们使用js来操控html文档。此处就不细讲了,具体可以看这里。
那么,虚拟DOM又是什么呢?
DOM在页面元素和对象之间建立了一个映射关系,虚拟DOM同样是建立了页面元素和对象之间的映射关系,使用js对象来模拟DOM,通过对js对象的操作来操作页面元素。事实上,你可以把虚拟DOM理解为DOM的简化版。
页面中的DOM树有着固定的结构,如果现在页面中有一张学生表如下,按学号来排列。此时你需要在点击“数学”两个字的时候让它按照数学成绩排序,点击“新增”的时候新添加一条数据。那你要怎么办?重新渲染整个DOM树吗?这显然是不合适的。所以使用虚拟DOM,比较新的DOM和旧的DOM之间的差异,修改差异部分就好了。
学号 | 数学 | 语文 |
---|---|---|
11201 | 85 | 77 |
11202 | 93 | 52 |
11203 | 74 | 69 |
新增 |
实现一个简单的虚拟DOM
事实上,虚拟DOM的实现精髓在于比较差异部分所使用的算法,但是这里不涉及。只是为了描述原理简要写一下它的实现。
首先你需要知道我们想要得到的是哪些数据。假设有下面一段html
<div>
<p><span>my name is wenchuyang</span></p>
<span>my name is wenhuan</span>
</div>
我们可以转换为虚拟DOM数据。节点有三个数据项,分别是tag,children和text。对于文字节点tag为’#text’,text的值则为文字内容。对于普通节点tag值是标签名,children值是子节点。将上边的html按要求转换结果如下:
let nodeData = {
tag: 'div',
children: [
{
tag: 'p',
children: [
{
tag: 'span',
children: [
{
tag: '#text',
text: 'my name is wenchuyang'
}
]
}
]
},
{
tag: 'span',
children: [
{
tag: '#text',
text: 'my name is wenhuan'
}
]
}
]
}
如果我们想要得到这样的node数据,可以新建一个VNode类,用来新建node。如下
class VNode {
constructor(tag, children, text){
this.tag = tag
this.children = children
this.text = text
}
render(){//将这个元素放到html里边
if(this.tag === '#text'){
return document.createTextNode(this.text)
}
let el = document.createElement(this.tag)
this.children.forEach(vChild => {
el.appendChild(vChild.render())
});
return el
}
}
这里用的是ES6的写法,如果你看不惯的话可以使用ES5像下边这样
function VNode(tag, children, text){
this.tag = tag
this.children = children
this.text = text
}
VNode.prototype.render = function(){
if(this.tag === '#text'){
return document.createTextNode(this.text)
}
let el = document.createElement(this.tag)
this.children.forEach(vChild => {
el.appendChild(vChild.render())
});
return el
}
结果都是一样的,就不分别解释了。
这样我们创建了一个VNode类来得到节点,并使用render方法进行html渲染。加一个函数稍稍优化一下
function v(tag, children, text){
if(typeof children === 'string'){
text = children
children = []
}
return new VNode(tag, children, text)
}
这样我们如果想要得到一个text节点的话,只需要let textNode = v('#text', 'I am a text node')
即可,不需要手动输入children为空数组。用这样的方法生成上边的html的话,只需要运行let nodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'my name is wenhuan')])])
,使用console.log(nodes.render())
可以在控制台打印出渲染过后的html。
或许你会问,这样子有什么用呢?
如果你要把页面中的第二句"my name is wenhuan"改成"hello",难道要再重新生成一遍,像下边这样?
let nodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'hello')])])
let root = document.querySelector('#root')
root.innerHTML = ''
root.appendChild(nodes.render)
显然是不合适的,不然我们费这么大劲也没什么意义了。我们需要一个diff算法,比较开始的DOM和我们的虚拟DOM有什么区别,然后只需要修改这微小的差异即可。比如我们如果说需要把“my name is wenhuan”改成“hello”,只需要修改这个text节点而已。复杂一点的diff算法的话会考虑排序方面的问题,这里不做深入了解,毕竟——不会啊emmm
所以我们写了如下简单的diff算法
function patchElement(parent, newVNode, oldVNode, index = 0) {
if(!oldVNode) {
parent.appendChild(newVNode.render())
} else if(!newVNode) {
parent.removeChild(parent.childNodes[index])
} else if(newVNode.tag !== oldVNode.tag || newVNode.text !== oldVNode.text) {
parent.replaceChild(newVNode.render(), parent.childNodes[index])
} else {
for(let i = 0; i < newVNode.children.length || i < oldVNode.children.length; i++) {
patchElement(parent.childNodes[index], newVNode.children[i], oldVNode.children[i], i)
}
}
}
唔,解释一下,四个参数分别是父节点,新的节点,旧的节点以及默认为0的index。
如果旧的node不存在,那么表示这个节点是新增的,所以使用appendChild
将其添加进去。
如果新的node不存在,那么表示我们需要删除旧的节点,所以使用removeChild
。
而如果说新node与旧node的tag不相同,或者说text值不同,那就是新旧两个节点不同,所以直接使用新的节点替换掉旧的节点即可。
如果上述情况都不满足,那么说明你的parent传错了。emmm那就说明新节点和旧节点本身相同但是它们的子节点不同,所以递归调用该函数,此时index参数起到了作用,对新旧节点的子节点进行遍历。当然我们知道,如果是打乱节点的排列顺序像这样第一个与第一个比较第二个与第二个比较的话,是没有用的。这里不做讨论。
所以如果有了这个函数的话,我们想要把“my name is wenhuan”修改成“hello”的话,就可以
let newNodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'hello')])])
patchElement(root, newNodes, nodes)
当然,你要是给包裹住text的span加一个id或者是class,可以使代码看上去更简单
<div>
<p><span>my name is wenchuyang</span></p>
<span id="change">my name is wenhuan</span>
</div>
let parentNode = document.querySelector('#change')
let newNode = v('#text', 'hello')
let oldNode = parentNode.childNodes[0]
patchElement(parentNode, newNode, oldNode)
这样就可以通过虚拟DOM,完成页面小部分的修改渲染了。
写在后面
简单的来说,虚拟DOM先使用类来得到节点数据,然后通过render方法进行节点的渲染,让它成为html标签元素,再使用diff算法,比较你需要修改的DOM和新生成的虚拟DOM有哪些不同之处,然后只需要将不同之处反映到真实的DOM树上就大功告成了。