什么是虚拟DOM
虚拟DOM是一种编程思想,它是用JS对象(树形结构)模拟出真实DOM所需要的内容。
为什么要使用虚拟DOM
在此之前,先了解下浏览器是怎么渲染html页面的。
以下是webkit引擎渲染的详细流程,其他引擎渲染方式会有所不同:
渲染流程:
-
- 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
-
- 解析html中通过内嵌,外链,嵌入式引入的CSS样式,生成CSSOM树
-
- 根据DOM树和CSSOM树生成另外一棵用于渲染的Render树
-
- 布局Render树 - 对渲染渲染树的每一个节点进行布局处理,确定其在屏幕上的显示位置
-
- 绘制Render树 - 最后根据Render树将每一个节点绘制出来
以上内容可以看出,重新绘制一次页面会很消耗浏览器的性能。
实现虚拟DOM
真实DOM
<div class="box">
<p class="para">hello world!</p>
<div id="box">box</div>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
</div>
React通过render函数生成虚拟DOM结果:
{
tag: "div",
props: {
class: "box"
},
children: [{
tag: "p",
props: { class: "para" },
children: [ "hello world!" ]
}, {
tag: "div",
props: { id: "box" },
children: [ "box" ]
}, {
tag: "ul",
props: {},
children: [
{
tag: "li",
props: {},
children: [ "first" ]
},
{
tag: "li",
props: {},
children: [ "second" ]
},
{
tag: "li",
props: {},
children: [ "third" ]
}
]
}]
}
为了消耗更少的性能,React每次setState都会将修改内容push到一个队列中,然后按特定的规则,生成虚拟DOM,这里如果有多次setState,react只取最后一次setState,这样也避免了生成多个虚拟DOM而消耗性能。然后再和上次的虚拟DOM进行比对,此时就引入了diff算法。
传统 diff 算法的复杂度为 O(n^3),显然这是无法满足性能要求的。React 通过制定大胆的策略,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。
虚拟DOM中的diff算法优化策略:
-
同级比较,因为Web UI中DOM节点跨层级移动的情况很好,所以可以忽略不计。
-
对于两个不同类型的组件,会生成不同的树形结构。
-
对于同一个组件,虚拟DOM比较的方式,通过同层唯一的key值,来确定哪些子元素在不同的渲染下保持稳定。
- 同级比较
diff算法中只会比较同层级的元素,一旦发现某一级之间有所不同,则会弃置其子级,直接用从新的差异的一级以及其下的所有子级替换老的。因为重新创建子级比逐层比较会容易的多,尤其是子级的层级也比较多,结构比较复杂的情况。
比对两个虚拟DOM会有三种操作:删除,替换,更新。
VNode是现在的虚拟DOM,newVNode是新的虚拟DOM:
删除:newVNode不存在时。
替换:VNode和newVNode之间key不同或者类型不同
更新:相同的类型和key,但是VNode和newVNode不同
- 引用key值
在循环渲染的列表中,如果没有给item加key值,vue和react都会发出警告。因为在虚拟DOM比对时,key值让两个相同的元素一一对应,也就可以更加快速地知道哪些节点新增,移动和删除。
逐层节点的比较
考虑有下面的 DOM 结构转换:
A 节点被整个移动到 D 节点下,直观的考虑 DOM Diff 操作应该是:
A.parent.remove(A);
D.append(A);
但因为 React 只会简单的考虑同层节点的位置变换,对于不同层的节点,只有简单的创建和删除。当根节点发现子节点中 A 不见了,就会直接销毁 A;而当 D 发现自己多了一个子节点 A,则会创建一个新的 A 作为子节点。因此对于这种结构的转变的实际操作是:
A.destroy();
A = new A();
A.append(new B());
A.append(new C());
D.append(A);
可以看到,以 A 为根节点的树被整个重新创建。
这种情况下同一个组件,保证DOM结构的稳定性有助于性能的提升。例如,我们有时可以通过 CSS 隐藏或显示某些节点,而不是真的移除或添加 DOM 节点。
DOM diff和生命周期
react生命周期与DOM diff算法有着密切的关系:
-
constructor: 构造函数,组件被创建时执行
-
componentDidMount: 当组件添加到 DOM 树之后执行
-
componentWillUnmount: 当组件从 DOM 树中移除之后执行,在 React 中可以认为组件被销毁
-
componentDidUpdate: 当组件更新时执行
当 DOM 树进行如下转变时。来观察这几个方法的执行情况:
C will unmount.
C is created.
B is updated.
A is updated.
C did mount.
D is updated.
R is updated.
可以看到,C 节点是完全重建后再添加到 D 节点之下,而不是将其“移动”过去。
相同类型节点的比较
第二种节点的比较是相同类型的节点,算法就相对简单而容易理解。React 会对属性进行重设从而实现节点的转换。
renderA: <div id="before" />
renderB: <div id="after" />
=> [replaceAttribute id "after"]
虚拟 DOM 的 style 属性稍有不同,其值并不是一个简单字符串而必须为一个对象,因此转换过程如下:
renderA: <div style={{color: 'red'}} />
renderB: <div style={{fontWeight: 'bold'}} />
=> [removeStyle color], [addStyle font-weight 'bold']
列表节点的比较
列表节点的操作通常包括添加、删除和排序。例如下图,我们需要往 B 和 C 直接插入节点 F,在 jQuery 中我们可能会直接使用 $(B).after(F) 来实现。而在 React 中,我们只会告诉 React 新的界面应该是 A-B-F-C-D-E,由 Diff 算法完成更新界面。
这时如果每个节点都没有唯一的标识,React 无法识别每一个节点,那么更新过程会很低效,即,将 C 更新成 F,D 更新成 C,E 更新成 D,最后再插入一个 E 节点。效果如下图所示:
可以看到,React 会逐个对节点进行更新,转换到目标节点。而最后插入新的节点 E,涉及到的 DOM 操作非常多。而如果给每个节点唯一的标识(key),那么 React 能够找到正确的位置去插入新的节点,入下图所示:
对于列表节点顺序的调整其实也类似于插入或删除,下面结合示例代码我们看下其转换的过程。
即将同一层的节点位置进行调整。如果未提供 key,那么 React 认为 B 和 C 之后的对应位置组件类型不同,因此完全删除后重建,控制台输出如下:
B will unmount.
C will unmount.
C is created.
B is created.
C did mount.
B did mount.
A is updated.
R is updated.
而如果提供了 key,控制台输出如下:
C is updated.
B is updated.
A is updated.
R is updated.
可以看到,对于列表节点提供唯一的 key 属性可以帮助 React 定位到正确的节点进行比较,从而大幅减少 DOM 操作次数,提高了性能。
总结
经过分析react diff的三大策略,我们能够在开发中更加进一步的提高react的渲染效率。
- 在开发组件时,保持稳定的 DOM 结构会有助于性能的提升;
- 使用 shouldComponentUpdate()方法节省diff的开销
- 在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,在一定程度上会影响 React 的渲染性能。
参考文章
本文主要内容来自以下文章,感谢各位同行写出来理论+实践的文章,对于我理解这部分内容提供了很大帮助。这里特别感谢!