Virtual DOM是React中的一个很重要的概念,在日常开发中,前端工程师们需要将后台的数据呈现到界面中,同时要能对用户的操作提供反馈,作用到UI上。这些都离不开DOM操作(还不清楚DOM是什么的也就不用往后看了。。),但是我们知道,频繁的DOM操作会造成极大的资源浪费,也通常是性能瓶颈的原因。于是React 引入了Virtual DOM。
为什么我们需要Vitaul DOM?
前言中已经说到,引入Virtual DOM的原因是,避免频繁的DOM操作。那DOM操作的性能到底是消耗到哪去了呢?我所查资料的观点普遍是以下两个方面:
- js访问DOM
- DOM操作引起的浏览器的重绘重排操作
抱着实践出真知的态度,我先写了以下两个demo进行测试:
第一次对比试验:
![1000次访问DOM](http://sherhoooo.cn:9010/articleimg/1488421397815%E5%9B%BE%E7%89%871.png)
![1000次字符串计算,1次访问DOM](http://sherhoooo.cn:9010/articleimg/1488421402124%E5%9B%BE%E7%89%872.png)
第一张图中,访问了1000次DOM,并对其属性进行了操作(不会引起重绘重排),第二张图,访问了1次DOM,但进行了1000次字符串操作。但结果是相差甚大,并且1000次如此简单的DOM操作就消耗了6.315ms,这显然是不能接受的。接下来看看,这6.315ms消耗去了哪里。
![1000次访问DOM](http://sherhoooo.cn:9010/articleimg/1488421957003%E5%9B%BE%E7%89%871.png)
![1000次字符串计算,1次访问DOM](http://sherhoooo.cn:9010/articleimg/1488421959741%E5%9B%BE%E7%89%872.png)
从以上Chrome Timeline时间线分析来看,主要还是script执行时间的差异比较大。但看起来并不如之前的差异那么明显。
第二次对比试验:
这次我将DOM节点属性操作替换成了innerHTML的内容操作,我们再来看看效果
![](http://sherhoooo.cn:9010/articleimg/1488423131398%E5%9B%BE%E7%89%873.png)
![](http://sherhoooo.cn:9010/articleimg/1488423134528%E5%9B%BE%E7%89%874.png)
很明显,总时间一下爆炸,同时对比上面的两张图,可以看到1000次的DOM节点的innerHTML内容操作比1000次的DOM节点属性操作整整翻了两个数量级。再来看看Timeline。
![](http://sherhoooo.cn:9010/articleimg/1488520477126%E5%9B%BE%E7%89%871.png)
![](http://sherhoooo.cn:9010/articleimg/1488520479941%E5%9B%BE%E7%89%872.png)
消耗最为巨大的这次变成了Loading,先看看loading代表的是什么
![](http://sherhoooo.cn:9010/articleimg/1488520534073%E5%9B%BE%E7%89%873.png)
可以看出,这里应该是Parse HTML消耗了大量时间,也就是解析HTML
我们知道浏览器展现出我们的网页是需要经过这样一个过程的:
浏览器解析DOM生成DOM树 + 解析CSS生成样式树 => 进行layout布局和paint绘制 => 展现到设备上
那么从上面那张图我们可以看出来,JS操作DOM的innerHTML消耗了巨大的时间在Parse HTMl上,这里应该没有涉及到大面积的重绘重排,因为时间饼图中render和paint的部分并没有太多。但其消耗还是很庞大的,一千次微小的DOM操作就共用时接近3s。
由此可见,Js操作DOM的确慢,而且真正的性能瓶颈应该是引起了浏览器的后续操作,也就是解析HTML以及重绘重排等,这些都是非常消耗性能的。因此我们为了尽量避免因操作改变DOM而想了一些方式来进行改善,例如使用Canvas替代原有的DOM动画,或者使用Css3动画。以及,将列表数据渲染进页面时,以一个根节点保存一次性插入,而不是每行数据都插入渲染。
当然,在以前我们使用jQuery的时候,在我们对DOM性能优化相当了解并且能注意到的时候,是可以写出性能很高的代码的,但是并不是所有工程师都能做到那一点,因此,Virtual DOM就应运而生了。
Virtual DOM 是什么?
我所理解的Virtual DOM是一个黑盒,我们不需要去关心它如何映射渲染到DOM上,可以随心所欲的去操作而不担心性能消耗。那下面就让我们具体来看一看Virtual DOM是什么吧。
![](http://sherhoooo.cn:9010/articleimg/1488535898473%E5%9B%BE%E7%89%874.png)
如果说DOM是一棵枝繁叶茂的树,那Virtual DOM就是一棵被修剪了很多多余的东西,只保留了最基础的一个树形的Js的数据结构。
操作DOM会引发浏览器后续的操作,但操作Js数据结构却不会。我们以下面的例子来看看:
![](http://sherhoooo.cn:9010/articleimg/1488536109160%E5%9B%BE%E7%89%875.png)
现在需要将上图左边的DOM结构替换成右边的结构,这种情景在实战项目中是经常会遇到的。但是如果直接操作DOM的话,进行移除的话可能就是四次删除,五次插入,这种消耗是很大的。但是使用Virtual DOM,那就是比较两个结构的差异,发现仅仅改变了四次内容,一次插入。这种消耗就小很多,无非加上一个比较的时间。
React 中的Virtual DOM
在React中,我们知道,当一个父组件的state发生改变的时候,会引发所有子组件的render,即使这个子组件本身的state是没有改变的。这点在我刚学React的时候非常不能理解,因为我觉得这样频繁无故的渲染会造成性能浪费。但是看完了Virtual DOM之后,我理解了,原来render
函数渲染出来的仅仅是Virtual DOM,因为操作Js的数据结构是很快很方便的,所以即使重新渲染一遍Virtual DOM树也是非常快的。同时,渲染出来的组件的新的Virtual DOM树会跟旧的Virtual DOM树进行差异比较,如果有修改,变动,那就将新的Virtual DOM渲染成DOM树。如果没有,则不变动
![](http://sherhoooo.cn:9010/articleimg/1488554969987%E5%9B%BE%E7%89%876.png)
简单实现 低配版 Virtual DOM
从前面的部分我们可以看到,Virtual DOM需要做的事情有两件:
- 从DOM树中模拟出相应的Virtual DOM,Js的操作都作用在Virtual DOM上
- 以Diff算法比较新旧两颗DOM树,找出其中的差异
- 将差异进行处理,以最小代价渲染成DOM
呐,我们分析一下,如果想自己实现一个低配版我们需要做哪些事情呢?往下看。
以Js对象模拟出Virtual DOM树
一棵树嘛,最重要的是哪个部分?自然是每个树的节点。那我们首先建立一个节点类。
function Element (tagName, props, children) {
this.tagName = tagName
this.props = props
this.children = children
}
var el = function (tagName, props, children) {
return new Element(tagName, props, children)
}
DOM节点中最重要的特征——标签名、属性、以及子节点,都在这个节点类里面有相应表示。以这样一个节点类的实例去表示DOM的一个节点。
呐,现在如果我们想将一堆DOM结构以上面的节点类转成虚拟DOM该怎么做呢?
![](http://sherhoooo.cn:9010/articleimg/1488556361618%E5%9B%BE%E7%89%878.png)
是的,朋友们!就是这么简单!我们实现了从DOM到Virtual DOM的一个过程。但是,问题又来了,当我们想从Virtual DOM到DOM又该怎么办呢?接着看。
Element.prototype.render = function () {
var el = document.createElement(this.tagName)
var props = this.props
for (var propName in props) {
var propValue = props[propName]
el.setAttribute(propName, propValue)
}
children.forEach(function (child) {
var childEl = (child instanceof Element)
? child.render()
: document.createElement(childEl)
})
return el
}
我们往Element类上面增加了一个静态方法,这个方法用于将我们之前建立的节点实例还原成真实的DOM元素。
我们可以看到,这个方法内部新建了一个对应标签的DOM元素,并且将属性名还原,最后递归调用,得到内部子元素。返回该新建的DOM元素。
然而,到现在我们也只是完成了DOM与Virtual DOM之间的相互转化。Virtual DOM实现的最重要的部分,其实还没有完成,那就是Diff算法。真正的Diff算法我也不懂,这里也只能简单描述一下Virtual DOM中的Diff算法的思路。
- 首先对Virtual DOM树进行一个深度遍历,也就是以深度优先的原则进行,对每一个遍历到的Virtual DOM给一个标记,譬如,顶层的div标记为0。
![](http://sherhoooo.cn:9010/articleimg/1488557573698%E5%9B%BE%E7%89%8710.png)
这样每个节点上都有我们的标记,一旦Virtual DOM重新生成,在比较新旧两颗Virtual DOM树的时候,就可以直接比较对应节点上的变化,如果有变化,则把这个变化与编号一起推入一个差异数组。
// 用数组存储新旧节点的不同
patches[0] = [{difference}, {difference}, ...]
这样我们就得到了这次Virtual DOM树的一个改变内容。但是推入进差异数组中的差异到底怎么表示呢?首先这样一个差异对象,起码有一个type
属性是用来表示这次差异的内容吧,不然我哪知道是进行了节点替换还是节点顺序重排呢,这里我简单的列了几个,当然真正的实践中肯定不止这几种类型,其他可以自行列举。
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
当我们知道了Virtual DOM上的差异之后,剩下的最后一步自然就是,将这个差异作用到DOM上。
既然Virtual DOM可以进行深度遍历标记,那DOM与Virtual DOM是一样的树状结构,自然也是可以进行深度的遍历标记,那么标记相同,自然就可以将那个差异数组上的每个差异对象对应上其所匹配的DOM元素。而每个DOM元素拿到了其对应差异(以currentPatches表示),就会依据差异的不同,进行不同的处理。这里同样列举几个:
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)
}
})
}
这段代码也是很好理解的吧,根据当前差异currentPatches的类型type来进行不同的DOM操作,这样就能将Virtual DOM中有差异的部分,作用到DOM中,而那些并没有差异的部分,就不需要进行额外的变动,自然就节省了很多资源。
最后,Virtual DOM理解清楚一点之后,理解React会更清楚一些
第三部分实现低配版Virtual DOM代码和思路源自深度剖析:如何实现一个 Virtual DOM 算法, 如有侵权,立删。