从一个小Demo看React的diff算法

前言

React的虚拟Dom和其diff算法,是React渲染效率远远高于传统dom操作渲染效率的主要原因。一方面,虚拟Dom的存在,使得在操作Dom时,不再直接操作页面Dom,而是对虚拟Dom进行相关操作运算。再通过运算结果,结合diff算法,得出变更过的部分Dom,进行局部更新。另一方面,当存在十分频繁的操作时,会进行操作的合并。直接在运算出最终状态之后才进行Dom的更新。从而大大提高Dom的渲染效率。
对于React如何通过diff算法来对比出做出变动的Dom,React内部有着复杂的运算过程,此文不做具体代码层级的讨论。仅仅通过一个小小Demo来宏观上的探讨下diff的运算思路。

diff的对比思路

React的diff对比是采用深度遍历的规则进行遍历比对的。以下图的Dom结构为例:
《从一个小Demo看React的diff算法》
对比过程为:对比组件1(没有变化)-> 对比组件2(没有变化)-> 对比组件4(没有变化)-> 对比组件5(组件5被移除,记录一个移除操作)-> 对比组件3(没有变化)->对比组件3子组件(新增了一个组件5,记录一个新增操作)。对比结束,此时变动数据记录了两个节点的变动,在渲染时,便会执行一次组件5的移除,和一次组件5的新增。其它节点不做变更,从而实现页面Dom的更新操作。

Demo初探

接下来,我们设计一个简单的demo,来分析页面变化时的整个过程。
首先我们创建几个相同的Demo组件:

    import React, { Component } from 'react';
    export default class Demo1 extends Component {
        componentWillMount() {
            console.log('加载组件1');
        }
        componentWillUnmount() {
            console.log('销毁组件1')
        }
        render () {
            return <div>{this.props.children}</div>
        }
    }

组件除了将其内部的Dom直接渲染之外,还在组件加载前和卸载前分别在控制台中打印出日志。
接下来通过代码组合出上图中的组件结构,并通过事件触发组件结构的变化。

    // 变化前
    <Demo1>1
        <Demo2>2
            <Demo4>4</Demo4>
            <Demo5>5</Demo5>
        </Demo2>
        <Demo3>3</Demo3>
    </Demo1>
    
    // 变化后
    <Demo1>1
        <Demo2>2
            <Demo4>4</Demo4>
        </Demo2>
        <Demo3>3
            <Demo5>5</Demo5>
        </Demo3>
    </Demo1>

执行变更操作之后,控制台会打印出日志

    加载组件5
    销毁组件5

结果通分析中一样,分别执行了一次组件5的加载操作和一次组件5的卸载操作。
接下来来分析一些复杂的情况。
首先看下面这种Dom的删除
《从一个小Demo看React的diff算法》
按照前面的分析,比对过程为:对比组件1(没有变化)-> 对比组件2(没有变化)-> 对比组件4(组件4被移除,记录一个移除操作)-> 对比组件5(没有变化)-> 对比组件6(没有变化)-> 对比组件3(没有变化)。对比结束。按照这个分析,用代码进行测试后,控制台日志应该会输出:

    销毁组件4

这一条日志。然而,在实际测试后,会发现输出日志为:

    加载组件5
    加载组件6
    销毁组件4
    销毁组件5
    销毁组件6

可以发现,除了“销毁组件4”这一个操作之外,还进行了组件5和组件6的销毁和加载操作。难道是我们之前的分析是错误的?别急,我们再来进行另外一个实验:
《从一个小Demo看React的diff算法》
同样只删除了一个组件,只是删除的组件位置不同,按照上次的实验结果,控制台输出日志应该为:

    加载组件4
    加载组件5
    销毁组件4
    销毁组件5
    销毁组件6

然而,实际的实验结果又出乎我们的预料。实际输出结果仅为:

    销毁组件6

这个现象十分有趣。仅仅是删除了不同位置的组件,diff分析的过程却完全不一样。其实,如果你继续实验删除组件5,你会发现,所得的结果跟前两次也是完全不同。
其实diff算法在进行虚拟Dom的变更比对时,并不能精确的进行一对一的比对(当然react提供了解决方案,后面讨论)。当一个父节点发生变更时,会销毁掉其下所有的子节点。而其兄弟节点,则会按照节点顺序进行一对一的顺序比对。那么在上面第一个例子的比对顺序其实是这样的:对比组件1(没有变化)-> 对比组件2(没有变化)-> 对比组件4(组件4变更为组件5,记录一次组件4的移除操作和一次组件5的新增操作)->对比组件5(组件5变更为组件6,记录一次组件5的移除操作和一次组件6的新增操作)->对比组件6(组件6被移除,记录一次组件6的移除操作)。对比结束。按照这个分析思路,控制台的输出结果就不难理解了。
同样当我们在第二个例子中移除组件6时。组件4和组件5的顺序并没有变化,所以对比时,仍然是跟自身组件的虚拟Dom进行比对,没有变化,所以也就只有一次组件6的移除操作。
我们可以进一步通过新增及修改操作来进一步验证猜想。
通过在组件4前新增一个组件和在组件6后新增一个组件的对比。可以发现结果与我们的猜想结果完全一致。具体实验推演过程,此处不在赘述。
对于修改,由于修改并未改变该组件及其兄弟组件的个数及顺序,所以仅仅会执行替换组件及其子组件的新增操作和被替换组件的移除操作。
同级的组件分析完了,那么如果是跨层级的组件操作呢?比如下面这种dom变更:
《从一个小Demo看React的diff算法》
这种变更,由于组件2,组件4,组件5三个组件的结构均未有任何变化,那么会不会复用其整个结构,只进行相对位置的变更呢?实验发现,控制台日志输出为:

    加载组件3
    加载组件2
    加载组件4
    加载组件5
    销毁组件2
    销毁组件4
    销毁组件5
    销毁组件3

可见组件2及其子组件发生变化时,组件2以及其下的所有子组件均会被重新渲染。那么为什么组件3也会重新渲染呢?其实原因并不是其增加了子节点,而是因为其兄弟节点2被移除,影响了其相对位置而造成的。其完整的对比流程为:对比组件1(没有变化)-> 对比组件2(组件二变更为组件3,记录一次组件2的移除操作以及其子组件:组件4和组件5的移除操作,同时记录组件3的新增操作,以及其子组件:组件2,组件4和组件5的移除操作)-> 对比组件3(组件3被移除,记录一次组件3的移除操作
分析可见:当一个节点变化时,其下的所有子节点会全部被重新渲染。比如在上个例子中,不进行结构的变更,只是将组件2替换为组件6,组件4和组件5保持不变,但由于组件4和组件5是组件2的子组件,组件2的变更依然会导致组件4和组件4被重新渲染。
此外,分析输出的结果,可以看到,react在进行局部Dom的更新时,会先执行新组件的加载,再执行组件的移除操作。

被忽略的key

在我们以前的开发工作中,肯定遇到过列表的渲染。此时React会强制我们为列表的每一条数据设置一个唯一的key值(否则控制台会报警告),并且官方禁止使用列表数据的下标来作为key值。在React 16及以后版本中,新增的以数组的形式来渲染多个同级的兄弟节点的写法中,同样要求我们为每一项添加唯一key值。你可能很疑惑这个必须加的key,似乎并没有什么实质的作用,为何却是一个必加项。

渲染效率的提升

其实,在React进行diff运算时,key值是十分关键的,因为每一个key就是该虚拟Dom节点的身份证,在我们之前的实验中,由于没有定义key值,diff运算在进行虚拟Dom的比对时,并不知道这个虚拟Dom跟之前的哪个虚拟Dom是一样的,所以只能采用顺序比对的方案,进行一对一比对。所以才有了之前分析中的由于位置的不同,导致了完全不同的输出结果。而当我们为每一个组件添加key值之后,由于有了唯一标示,在进行diff运算时,便能进行精确的比对,不再受到位置变动的影响。
回到最初的删除实验,为每一个组件添加上唯一的key:
《从一个小Demo看React的diff算法》

    // 变化前
    <Demo1 key={1}>1
        <Demo2 key={2}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={3}>3</Demo3>
    </Demo1>
    
    // 变化后
    <Demo1 key={1}>1
        <Demo2 key={2}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={3}>3</Demo3>
    </Demo1>

运行发现,其输出日志正是我们最初设想的那样:

    销毁组件4

相对于没有key值的操作,避免了组件5和组件6的重新渲染。大大提高了渲染的效率。此时,为什么列表类数据必须加一个唯一的key值,就显而易见了。试想一下在一个无限滚动的移动端列表页面,加载了1000条数据。此时将第一条删除,那么,在没有key值的情况下,要重新渲染这个列表,需要将第一条之后的999条数据全部重新渲染。而有了key值,仅仅只需要对第一条数据进行一次移除操作就可以完成。可见,key值对渲染效率的提升,绝对是巨大的。

key不可设置为数据下标

那么,为什么不能将key值设置为数据的下标呢?其实很简单,因为下标都是从0开始的,还是这个移动端的列表,删除了第一条数据,如果将key值设置为了数据下标。那么原来的key值为1的数据,在重新渲染后,key值会重新被设置为0,那么在进行比对时,会把这条数据跟变更前的key为0的数据进行比对,很明显,这两条数据并不是同一条,所以依然会因为数据不同,而导致整个列表的重新渲染。

key值必须唯一?

除此之外,还有一个开发中的共识,就是key值必须唯一。但key值真的不能相同吗?
按照之前的实验以及分析,可以看出:当在进行兄弟节点的比对时,key值能够作为唯一的标示进行精确的比对。但是对于非兄弟组件,由于diff运算采用的是深度遍历,且父组件的变动会完全更新子组件,所以理论上key值对于非兄弟组件的作用,就显得微乎其微。那么对于非兄弟组件,key值相同应该是可行的。那么用实验验证一下我们的猜想。

    // 变更前
    <Demo1 key={1}>1
        <Demo2 key={1}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={2}>3
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo3>
    </Demo1>
    // 变更后
    <Demo1 key={1}>1
        <Demo2 key={1}>2
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={2}>3
            <Demo4 key={4}>4</Demo4>
            <Demo6 key={6}>6</Demo6>
        </Demo3>
    </Demo1>

在这个实验中,组件1和组件2有着相同的key值,且组件2和组件3的子组件也有着相同的key值,然而运行该代码,却并没有关于key值相同的警告。执行Dom变更后,日志输出也同之前的猜想没有出入。可见我们的猜想是正确的,key值并非需要绝对唯一,只是需要保证在同一个父节点下的兄弟节点中唯一便可以了。

key的更多用法

除了上面提到的这些之外,在了解了key的作用机制之后,还可以利用key值来实现一些其它的效果。比如可以利用key值来更新一个拥有自状态的组件,通过修改该组件的key值,便可以达到使该组件重新渲染到初始状态的效果。此外,key值除了在列表中使用之外,在任何会操作dom,比如新增,删除这种影响兄弟节点顺序的情况,都可以通过添加key值的方法来提高渲染的效率。

    原文作者:前端
    原文地址: https://segmentfault.com/a/1190000018907727
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞