React虚拟DOM和diff算法

841 阅读7分钟

什么是虚拟DOM

虚拟DOM是一种编程思想,它是用JS对象(树形结构)模拟出真实DOM所需要的内容。

为什么要使用虚拟DOM

在此之前,先了解下浏览器是怎么渲染html页面的。

以下是webkit引擎渲染的详细流程,其他引擎渲染方式会有所不同:

image.png

渲染流程:

    1. 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
    1. 解析html中通过内嵌,外链,嵌入式引入的CSS样式,生成CSSOM树
    1. 根据DOM树和CSSOM树生成另外一棵用于渲染的Render树
    1. 布局Render树 - 对渲染渲染树的每一个节点进行布局处理,确定其在屏幕上的显示位置
    1. 绘制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值,来确定哪些子元素在不同的渲染下保持稳定。

  1. 同级比较

image.png

diff算法中只会比较同层级的元素,一旦发现某一级之间有所不同,则会弃置其子级,直接用从新的差异的一级以及其下的所有子级替换老的。因为重新创建子级比逐层比较会容易的多,尤其是子级的层级也比较多,结构比较复杂的情况。

比对两个虚拟DOM会有三种操作:删除,替换,更新。

VNode是现在的虚拟DOM,newVNode是新的虚拟DOM:

删除:newVNode不存在时。

替换:VNode和newVNode之间key不同或者类型不同

更新:相同的类型和key,但是VNode和newVNode不同

  1. 引用key值

在循环渲染的列表中,如果没有给item加key值,vue和react都会发出警告。因为在虚拟DOM比对时,key值让两个相同的元素一一对应,也就可以更加快速地知道哪些节点新增,移动和删除。

逐层节点的比较

考虑有下面的 DOM 结构转换:

image.png

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 树进行如下转变时。来观察这几个方法的执行情况:

image.png

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 算法完成更新界面。

image.png

这时如果每个节点都没有唯一的标识,React 无法识别每一个节点,那么更新过程会很低效,即,将 C 更新成 F,D 更新成 C,E 更新成 D,最后再插入一个 E 节点。效果如下图所示:

image.png

可以看到,React 会逐个对节点进行更新,转换到目标节点。而最后插入新的节点 E,涉及到的 DOM 操作非常多。而如果给每个节点唯一的标识(key),那么 React 能够找到正确的位置去插入新的节点,入下图所示:

image.png

对于列表节点顺序的调整其实也类似于插入或删除,下面结合示例代码我们看下其转换的过程。

image.png

即将同一层的节点位置进行调整。如果未提供 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 的渲染性能。

参考文章

本文主要内容来自以下文章,感谢各位同行写出来理论+实践的文章,对于我理解这部分内容提供了很大帮助。这里特别感谢!

深入浅出 React(四):虚拟 DOM Diff 算法解析

【React】深入理解虚拟dom和diff算法

虚拟DOM介绍

浅析浏览器渲染原理

浏览器渲染原理与过程