前言
虚拟DOM是如今MVVM框架必须具备的技术特性,我们今天写一个简单的虚拟DOM实现,来学习它的原理。注意,下方的任何代码逻辑都是简化过的不严谨的,只粗放的表达虚拟DOM的原理。
写一个简单的虚拟DOM对象
虚拟DOM对象的本质是一个JS对象,我先简单写一个:
var vdom = Element({
tagName: 'ul',
props: {'class': 'list'},
children: [
Element({tagName: 'li', children: ['item1']}),
Element({tagName: 'li', children: ['item2']})
]
});
代码很好理解,有个ul,它class是list,它有2个子元素,都是li,li的子元素都是文本节点,内容一个叫'item1'
,另一个叫'item2'
。
但是,这不是HTML代码,HTML代码应该是:
<ul class="list">
<li>item1</li>
<li>item2</li>
</ul>
所以现在的问题是,怎么让虚拟DOM对象转换为DOM。关键点就是这个Element构造函数。
写一个简单的Element构造函数
function Element({tagName, props, children}){
if(!(this instanceof Element)){
return new Element({tagName, props, children})
}
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
}
Element.prototype.render = function(){
var el = document.createElement(this.tagName),
props = this.props,
propName,
propValue;
for(propName in props){
propValue = props[propName];
el.setAttribute(propName, propValue);
}
this.children.forEach(function(child){
var childEl = null;
if(child instanceof Element){
childEl = child.render();
}else{
childEl = document.createTextNode(child);
}
el.appendChild(childEl);
});
return el;
};
原理很简单,Element构造函数负责将虚拟DOM对象一层一层的递归解析,每一层都要做这么几个操作:
- document.createElement()创建一个节点
- setAttribute()设置属性
- 遍历children,如果children的某一项也是Element实例,则对这项再来一遍1和2步骤。如果这项是文本节点,则document.createTextNode()
怎么用
先解析,然后插入root元素。
document.querySelector('#root').appendChild(elem.render());
怎么更新虚拟DOM
更新虚拟DOM,也就是用户对虚拟DOM做了操作,操作是有这几种:
- 原本空,现在新增节点
- 原本有,现在删除节点
- 原本有,现在替换节点
- 当前节点相同,对比子节点。
注意,vue.js对虚拟DOM的修改的理解要比这个复杂,并不是这么粗放的归为4类,这里只是简化介绍。
写一个更新节点的方法:
$root是根元素,比如body
function isChanged(elem1, elem2) {
return (typeof elem1 !== typeof elem2) ||
(typeof elem1 === 'string' && elem1 !== elem2) ||
(elem1.type !== elem2.type);
}
function updateElement($root, newElem, oldElem, index = 0) {
if (!oldElem){
$root.appendChild(newElem.render());
} else if (!newElem) {
$root.removeChild($root.childNodes[index]);
} else if (isChanged(newElem, oldElem)) {
if (typeof newElem === 'string') {
$root.childNodes[index].textContent = newElem;
} else {
$root.replaceChild(newElem.render(), $root.childNodes[index]);
}
} else if (newElem.tagName) {
let newLen = newElem.children.length;
let oldLen = oldElem.children.length;
for (let i = 0; i < newLen || i < oldLen; i++) {
updateElement($root.childNodes[index], newElem.children[i], oldElem.children[i], i)
}
}
}
注意,由于只为了说明原理,所以这个例子非常简化,没有对节点属性的变化进行处理。你会看到下方class虽然有变化,但是并没有更新。
var elem = Element({
tagName: 'ul',
props: {'class': 'list'},
children: [
Element({tagName: 'li', children: ['item1']}),
Element({tagName: 'li', children: ['item2']})
]
});
var newElem = Element({
tagName: 'ul',
props: {'class': 'list-1'},
children: [
Element({tagName: 'li', children: ['item1']}),
Element({tagName: 'li', children: ['hahaha']})
]
});
var $root = document.querySelector('#root');
var $refresh = document.querySelector('#refresh');
updateElement($root, elem);
$refresh.addEventListener('click', () => {
updateElement($root, newElem, elem);
});
当点击按钮,会看到页面有更新。
总结
- 虚拟DOM就是一个JS对象
- 对DOM的修改会反映到data上,data会反映到新的虚拟DOM上
- 新的虚拟DOM会跟老的虚拟DOM做对比,也就是使用diff算法做对比
- 最小化修改真实DOM
进阶
如果对虚拟DOM感兴趣,可以学习Vue.js的相关源码。