虚拟DOM是啥?
Posted 前端大集锦
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟DOM是啥?相关的知识,希望对你有一定的参考价值。
一、浏览器渲染html的步骤
HTML被HTML解析器解析成DOM Tree, CSS则被CSS解析器解析成CSSOM Tree。
DOM Tree和CSSOM Tree解析完成后,被附加到一起,形成渲染树(Render Tree)。
节点信息计算(重排),这个过程被叫做Layout(Webkit)或者Reflow(Mozilla)。即根据渲染树计算每个节点的几何信息生成布局。
渲染绘制(重绘),这个过程被叫做(Painting 或者 Repaint)。即根据计算好的信息绘制整个页面。
Display显示到屏幕上
以上5步简述浏览器的一次渲染过程,每一次的dom更改或者css几何属性更改,都会引起一次浏览器的重排/重绘过程。
二、什么是虚拟DOM(三步骤)
虚拟DOM(Virtual DOM)就是用JS来模拟DOM结构。
Virtual DOM 算法:
步骤一:用JS对象生成Virtual DOM树,然后用这个树构建一个真正的 DOM 树,插到文档当中
步骤二:当状态变更的时候,重新构造一棵新的对象树,然后用新的树和旧的树进行比较,记录两棵虚拟DOM树的差异
步骤三:把步骤二所记录的差异应用到步骤一所构建的真正的DOM树上,视图就更新了
三、为什么要使用虚拟DOM?(两个前提)
【1】前提一:直接操作DOM很慢
let div = document.createElement('div')
let props = ''
for (let key in div) {
props = `${props} ${key}`
}
console.log(props)
如上图所示,如果我们把一个简单的div元素的属性都打印出来,你会看到真正的 DOM 元素非常庞大,由此可以得知DOM是很慢的。而且操作它们的时候你要小心翼翼,轻微的触碰可能就会导致页面重排。浏览器里一遍又一遍的渲染DOM是非常非常消耗性能的,常常会出现页面卡死的情况,影响用户的体验;所以尽量减少对DOM的操作成为了优化前端性能的必要手段,虚拟DOM就是将DOM的对比放在了js层,通过高效的 diff 算法计算,避免对整棵DOM树进行变更,而是进行针对性的视图变更,从而提高渲染效率。
【2】前提二:JavaScript很快
得益于V8引擎的出现,让javascript可以高效地运行,在性能上有了极大的提高。相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,而且更简单。DOM 树上的结构、属性信息我们都可以很容易地用 JavaScript 对象表示出来,为Virtual DOM的产生提供了大前提。
四、Virtual DOM 算法实现
【4.1】用js来模拟DOM节点
用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,操作内存中的js对象的速度显然要快多了。等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。
那具体怎么实现呢?看一下Element方法的具体实现:
/*
tagName: 节点名(如div)
props: 节点的属性(如class)
children: 子节点(如ul的li)。
除了这三个参数会被保存在对象上外,还保存了key和count。
*/
function Element(tagName, props, children) {
// 保证只能通过如下方式调用:new Element()
if (!(this instanceof Element)) {
return new Element(tagName, props, children);
}
// 设置虚拟dom相关属性
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
this.key = props ? props.key : undefined;
let count = 0;
this.children.forEach((child) => {
if (child instanceof Element) {
count += child.count;
}
count++;
});
this.count = count;
}
有了js对象后,最终还需要将其映射成真实的DOM:根据DOM名调用源生的createElement创建真实DOM,将DOM的属性全都加到这个DOM元素上,如果有子元素继续递归调用创建子元素,并appendChild挂到该DOM元素上。这样就完成了从创建虚拟DOM到将其映射成真实DOM的全部工作。
Element.prototype.render = function () {
// 创建标签
const el = document.createElement(this.tagName);
// 设置标签属性
const props = this.props;
for (const propName in props) {
setAttr(el, propName, props[propName]);
}
// 依次创建子节点标签
this.children.forEach((child) => {
//如果子节点仍然为element,则递归的创建子节点,否则直接创建文本类型节点
const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child);
el.appendChild(childEl);
});
return el;
};
对一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。
vdom.render();
通过上面的Element方法,我们可以很简单地用javascript表示DOM结构。例如:
const vdom = Element('div', { id: 'virtual-container' }, [
Element('p', {}, ['Virtual DOM']),
Element('ul', {}, [
Element('li', { class: 'item' }, ['Item 1']),
Element('li', { class: 'item' }, ['Item 2']),
Element('li', { class: 'item' }, ['Item 3']),
]),
Element('div', {}, ['Hellow World']),
]);
const root = vdom.render(); // 生成真实的DOM树
document.getElementById('virtualDom').appendChild(root); // 添加到页面
上面的vdom是一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。如下:
<div id="virtualDom">
<div id="virtual-container">
<p>Virtual DOM</p>
<ul>
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div>
</div>
页面展示效果:
【4.2】Diff算法(比较两颗虚拟DOM树的差异)
上面我们已经完成了创建虚拟DOM并将其映射成真实DOM的工作,这样所有的更新都可以先反映到虚拟DOM上,如何反映呢?需要明确一下Diff算法。
4.2.1、如何比较两颗DOM树
计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,这样算法复杂的就可以达到O(n),避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历。
4.2.2 、记录节点之间的差异
在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个叫patches的对象里面。
// diff 函数,对比两棵树
function diff (oldTree, newTree) {
var index = 0 // 当前节点的标志
var patches = {} // 用来记录每个节点差异的对象
dfsWalk(oldTree, newTree, index, patches)
return patches
}
// 对两棵树进行深度优先遍历
function dfsWalk (oldNode, newNode, index, patches) {
// 对比oldNode和newNode的不同,记录下来
patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)
}
// 遍历子节点
function diffChildren (oldChildren, newChildren, index, patches) {
var leftNode = null
var currentNodeIndex = index
oldChildren.forEach(function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点
leftNode = child
})
}
例如:假设上面的ul有差异,ul的当前标记为3,那么记录为 patches[3] = [ // 用数组存储ul新旧节点的不同 ]
4.2.3、差异类型
由于我们对DOM树采取的是同级比较,因此节点之间的差异可以归结为以下4种类型:
修改节点属性, 用PROPS表示
修改节点文本内容, 用TEXT表示
修改节点类型, 用REPLACE表示
调整子节点,包括移动、增加、删除等,用REORDER表示
我们新创建一棵树,用于和之前的树进行比较
const newTree = Element('div', { id: 'virtual-container' }, [
Element('h3', {}, ['Virtual DOM']), // REPLACE
Element('ul', { class: 'itemWrap' }, [ // PROPS
Element('li', { class: 'item' }, ['Item 1']),
// Element('li', { class: 'item' }, ['Item 2']), // REORDER remove
Element('li', { class: 'item' }, ['Item 3']),
]),
Element('div', {}, ['after update']), // TEXT
]);
修改节点属性, 用PROPS表示 // newTree将ul的class属性改了
修改节点文本内容, 用TEXT表示 // newTree将div的节点内容Hello World改成了after update
修改节点类型, 用REPLACE表示 // newTree将p替换成h3
调整子节点,包括移动、增加、删除等,用REORDER表示 // newTree将ul下的第二个li子节点删除
1、对于节点替换,判断新旧节点的tagName
和是不是一样的,如果不一样的说明需要替换掉。例如:newTree将p替换成h3,就记录如下:
patches[1] = [{
type: REPALCE,
node: newNode // el('h3', props, children)
}]
2、如果给元素新增了属性,例如:newTree的ul新增了class属性,就记录如下:
patches[3] = [{
type: PROPS,
props: {
class: "itemWrap"
}
}]
3、如果是文本节点改变,例如:newTree将div的节点内容Hello World改成了after update,就记录如下:
patches[10] = [{
type: TEXT,
content: "after update"
}]
4、如果是删除子节点,例如:newTree将ul下的第二个li子节点删除,就记录如下:
patches[3] = [{
type: PROPS,
props: {
class: "itemWrap"
}
}, {
type: REORDER,
moves: [
{ index: 2, type: 0 }
]
}]
5、那如果把我div的子节点重新排序呢?例如p, ul, div的顺序换成了div, p, ul。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如p和div的tagName不同,p会被div所替代。最终,三个节点都会被替换,这样DOM开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。这牵涉到两个列表的对比算法
4.2.3、列表对比算法
假设现在可以英文字母唯一地标识每一个子节点
旧的节点顺序:
a b c d e f g h i
现在对节点进行了删除、插入、移动的操作。新增j节点,删除e节点,移动h节点:
新的节点顺序:
a b c h d f g i j
现在知道了新旧节点的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance),最常见的解决算法是 Levenshtein Distance,通过动态规划求解,时间复杂度为 O(M * N)。但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣可以参考代码。
我们能够获取到某个父节点的子节点的操作,就可以记录下来:
patches[0] = [{
type: REORDER,
moves: [{remove or insert}, {remove or insert}, ...]
}]
但是要注意的是,因为tagName是可重复的,不能用这个来进行对比。所以需要给子节点加上唯一标识key,列表对比的时候,使用key进行对比,这样才能复用老的 DOM 树上的节点。
这样,我们就可以通过深度优先遍历两棵树,每层的节点进行对比,记录下每个节点的差异了。完整 diff 算法代码可见 diff.js。
【4.3】把差异应用到真实的DOM树上
虚拟DOM有了,Diff也有了,现在就可以将Diff应用到真实DOM上了。
深度遍历DOM将Diff的内容更新进去:
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}
function dfsWalk (node, walker, patches) {
var currentPatches = patches[walker.index] // 从patches拿出当前节点的差异
var len = node.childNodes
? node.childNodes.length
: 0
for (var i = 0; i < len; i++) { // 深度遍历子节点
var child = node.childNodes[i]
walker.index++
dfsWalk(child, walker, patches)
}
if (currentPatches) {
applyPatches(node, currentPatches) // 对当前节点进行DOM操作
}
}
具体更新的代码如下,applyPatches根据不同类型的差异对当前节点进行 DOM 操作:
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE: {
const newNode = (typeof currentPatch.node === 'string')
? document.createTextNode(currentPatch.node)
: currentPatch.node.render();
node.parentNode.replaceChild(newNode, node);
break;
}
case REORDER:
reorderChildren(node, currentPatch.moves);
break;
case PROPS:
setProps(node, currentPatch.props);
break;
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content;
} else {
// ie
node.nodeValue = currentPatch.content;
}
break;
default:
throw new Error(`Unknown patch type ${currentPatch.type}`);
}
});
}
虚拟DOM的目的是将所有操作累加起来,统计计算出所有的变化后,统一更新一次DOM。
【4.4】总结
Virtual DOM 算法主要是实现上面步骤的三个函数:ELement,diff,patch。然后就可以实际的进行使用
// 1. 构建虚拟DOM
var tree = Element('div', {'id': 'container'}, [
Element('h1', {style: 'color: blue'}, ['simple virtal dom']),
Element('p', ['Hello, virtual-dom']),
Element('ul', [Element('li')])
])
// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)
// 3. 生成新的虚拟DOM
var newTree = Element('div', {'id': 'container'}, [
Element('h1', {style: 'color: red'}, ['simple virtal dom']),
Element('p', ['Hello, virtual-dom']),
Element('ul', [Element('li'), Element('li')])
])
// 4. 比较两棵虚拟DOM树的不同
var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上应用变更
patch(root, patches)
以上是关于虚拟DOM是啥?的主要内容,如果未能解决你的问题,请参考以下文章