一文看透虚拟DOM(Virtual DOM)
老规矩先看目录:
是什么?
虚拟DOM的本质就是一个对象,这个对象有三个属性,tag,attrs,children。们可以用这个对象来表示页面中的节点。
var element = {
tag: 'ul', // 节点标签名
attrs: { // DOM的属性,用一个对象存储键值对
id: 'list'
},
children: [ // 该节点的子节点
{tag: 'li', attrs: {class: 'item'}, children: ["Item 1"]},
{tag: 'li', attrs: {class: 'item'}, children: ["Item 2"]},
{tag: 'li', attrs: {class: 'item'}, children: ["Item 3"]},
]
}
对应的HTML节点是这样的:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
为什么?
想象一下这样的一个场景,如果我们要在网页中绘制一个表格,我们遍历后端给的数据然后,创建对应的标签插入到页面中,如果此时后端所给的数据变了,那么我们便需要重新遍历后端给的数据,然后创建对应的节点,然后插入到页面中。
如果我们还想使用JS操作表格里面的元素,那么随着数据与操作功能的增加,JS代码会变得非常繁杂且不便于维护。
对于从事前端的同学应该知道这会存在很严重的性能问题,由于Javascript是存在很多的标准的,浏览器为了适应这些标准从而将DOM元素做的非常复杂,这大大增加了我们操作DOM背后的工作量。这是一个DIV节点的第一层结构。
为了减少JS的代码量我们有了许多的MVVM框架来帮助我们,本质上这些框架要做的就是监听状态(数据)的改变,然后更新视图。但是在具体实现上这些框架所用的方法是不同的,有Angular的脏检查
,Vue的依赖收集
等,而这里所说的虚拟DOM(Virtual DOM)
实际上也是一种方法,主要被React所使用。
其主要工作原理就是,用一个对象来表示页面中的元素,这个对象也就是所说的虚拟DOM。之后我们根据虚拟DOM生成真实的元素节点然后插入到页面中,当页面中的数据或者元素更改从而导致对应的虚拟DOM更改,我们就使用一个算法遍历我们的
怎么做?
1.使用JS对象模拟DOM树
我们需要利用一个构造函数来生成对象,使用构造函数的原因是:
-
方便我们后续创建对应的节点时使用递归 -
方便后续子元素的类型检查
function Element (tag, attrs, children) {
this.tagName = tagName
this.props = props
this.children = children
}
function el(tag, attrs, children) {
return new Element(tag, attrs, children)
}
之后生成我们所需要的对象:
var ul = el('ul', {id: 'list'}, [
el('li', {class: 'item'}, ['Item 1']),
el('li', {class: 'item'}, ['Item 2']),
el('li', {class: 'item'}, ['Item 3'])
])
之后就可以根据对象来生成所需要的DOM节点了,这个比较重要:
Element.prototype.render = function(){
let el = document.createElement(this.tag);
let attrs = this.attrs;
for(let attrName in attrs) {
let attrValue = attrs[attrName];
el.setAttribute(attrName,attrValue);
}
let children = this.children || [];
children.forEach( child => {
let childEle = (child instanceof Element)? // 判断子元素是不是节点
child.render(): // 如果是递归继续渲染
document.createTextNode(child); // 否则创建一个文本节点便好
el.appendChild(childEle);
});
return el;
}
let ulRoot = ul.render();
document.body.appendChild(ulRoot);
2.比较两棵虚拟DOM树的差异
在上面我们已经根据虚拟DOM生成了真实元素节点,并且把它插入到了页面中,接下来我们的虚拟DOM将会更新(也就是真实情况中数据将会改变),我们需要使用一个算法比较新旧两棵虚拟DOM树的差别(也就是diff算法),然后生成一个补丁patches(记录两棵树的差别的对象),之后根据这个补丁来修改页面中的节点元素。
对于比较两棵树的不同我们采用的是深度优先遍历,遍历到的每一个节点都会有一个记号(也就是当前节点的索引),在遍历的过程中比较两棵树节点的不同,然后记录在一个对象里:
这里就先放一下大概的比较过程,具体diff算法的细节还是请看戴嘉华
大佬的这篇文章[1],里面也有完整的diff算法流程。
这里主要是遍历虚拟DOM 树
的每一个节点得到index从而生成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
})
}
如果两棵树标记为0的节点是不同的那么补丁大概是这样:
patches[0] = [{difference}, {difference}, ...] // 用数组存储新旧节点的不同
3.比较具体两个节点的区别
上面说了我们怎么比较两棵树的,那补丁到底是什么样的呢?
因为对于两个节点来说可能有很多处不同,比如标签名,一个是div一个是span,比如属性值等等,也有可能是标签里面的文本内容等等,所以我们首先将这些区别分成了四种类型:
-
替换掉原来的节点,例如把上面的div换成了section -
移动、删除、新增子节点,例如上面div的子节点,把p和ul顺序互换 -
修改了节点的属性 -
对于文本节点,文本内容可能会改变。例如修改上面的文本节点2内容为Virtual DOM 2。
对应的;
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
所以看见下面几个例子:
如果是标签名不一样从之前的div变为section
,我们需要直接替换掉之前的节点:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}]
如果给div新增了属性id为container,就记录下:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}, {
type: PROPS,
props: {
id: "container"
}
}]
如果是文本节点不一样,那么我们需要记录一下:
patches[2] = [{
type: TEXT,
content: "Virtual DOM2"
}]
节点的标签、属性、文本的更改我们都可以记录了,那么如果是同一级节点的位置修改了,比如之前div的子节点是p,span,section,现在变成span,section,p,我们要完全替换掉之前的三个标签吗?这样显然开销比较大了,创建DOM再插入是比较昂贵的操作。我们只需要使用算法将其进行移动便好了。
这个算法称之为列表对比算法。
假如旧节点的顺序是这样的:
a b c d e f g h i
现在对节点进行了删除、插入、移动的操作。新增j节点,删除e节点,移动h节点:
新节点的顺序是:
a b c h d f g i j
那么究竟如何用最简单的删除、移动、增加完成旧到新的改变呢?
现在知道了新旧的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance[2]),最常见的解决算法是 Levenshtein Distance[3],通过动态规划求解,时间复杂度为 O(M * N)。但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣可以参考代码[4]。
得到的补丁是这样:
patches[0] = [{
type: REORDER,
moves: [{remove or insert}, {remove or insert}, ...]
}]
需要注意的是:很多时候可能两个节点名是一样的,所以我们需要给每个节点增加一个独一无二的记号key,我们在列表进行比较的时候使用key进行比较,这样当节点只是移动位置的时候我们才可以复用节点。
这样我们使用深度优先遍历虚拟DOM树,遍历得到的当前节点就与新树上的节点比较,如果有差异就记录下来。得到补丁(patches)之后我们就需要根据补丁来真正修改页面中的节点了,完整 diff 算法代码可见 diff.js[5]。
4.修改页面中的元素
使用深度优先遍历最初生成的真实的DOM树ulRoot,在遍历的过程中找到当前节点对应的补丁,然后进行修改。
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(function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
node.parentNode.replaceChild(currentPatch.node.render(), node)
break
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
node.textContent = currentPatch.content
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}
完整代码可见 patch.js[6]。
5.总结
最后我们可以发现其实完整的虚拟DOM算法是分为三大部分的,我们可以捋一下具体的过程。
首先我们需要使用Element构造函数生成虚拟DOM
,之后使用render方法将其转换为真实的DOM对象,从而插入到页面之中。
当页面中的数据修改我们得到新的虚拟DOM树,使用diff方法,遍历旧的虚拟DOM对象和新虚拟DOM树进行比对得到patches补丁,之后由patch方法
传入补丁与真实的DOM对象更改页面,这个过程也是深度优先遍历真实的DOM节点从patches中找到当前节点对应的补丁从而进行节点操作。
最后想说一点的是,对于将虚拟DOM生成真实的对象的render方法,要重点掌握,这是之前字节考过的原题。
// 1. 构建虚拟DOM
var tree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: blue'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li')])
])
// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)
// 3. 生成新的虚拟DOM
var newTree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: red'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li'), el('li')])
])
// 4. 比较两棵虚拟DOM树的不同
var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上应用变更
patch(root, patches)
本文所实现的完整代码存放在 Github[7],仅供学习。
虚拟DOM的性能怎么样?
这个问题我推荐大家去看尤大知乎上的这个回答[8],这里简要的说一下。
首先我们要知道JS的计算一般是要远远便宜与DOM操作的,如果我们只有少部分的数据要改变,我们使用虚拟DOM,得到差别后再去修改页面中的元素还是效率比较高的,但是如果有大量的数据要改变相比于直接操作DOM来说我们还要增加diff算法的消耗。所以对于React来说如果有大量数据要改变的情况下还是通过其他的算法去实现的,应该是直接重写innerHtml。
所以对于虚拟DOM来说,其性能优势往往只在一些特定的场合其作用,如在初始渲染时(不需要像Vue那样依赖收集,所以比较快),拥有大量数据但只有小部分数据更改时。
所以对于虚拟DOM来说其真正的优先是在于:
-
其为函数式UI提供了思路,即像UI = f(data)这种构建UI的方式。 -
其可以渲染到DOM以外的其他场合中,也就是跨平台开发,如ReactNative。
最后看一下虚拟DOM与各大框架在性能上的比较,来自于尤大:
-
初始渲染:Virtual DOM > 脏检查 >= 依赖收集 -
小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化 -
大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化
参考
这篇文章: https://www.zhihu.com/question/29504639
[2]Edition Distance: https://link.zhihu.com/?target=https%3A//en.wikipedia.org/wiki/Edit_distance
[3]Levenshtein Distance: https://link.zhihu.com/?target=https%3A//en.wikipedia.org/wiki/Levenshtein_distance
[4]代码: https://link.zhihu.com/?target=https%3A//github.com/livoras/list-diff/blob/master/lib/diff.js
[5]diff.js: https://link.zhihu.com/?target=https%3A//github.com/livoras/simple-virtual-dom/blob/master/lib/diff.js
[6]patch.js: https://link.zhihu.com/?target=https%3A//github.com/livoras/simple-virtual-dom/blob/master/lib/patch.js
[7]Github: https://link.zhihu.com/?target=https%3A//github.com/livoras/simple-virtual-dom
[8]这个回答: https://www.zhihu.com/question/31809713/answer/53544875