从一个小Demo看React的diff算法

50次阅读

共计 5109 个字符,预计需要花费 13 分钟才能阅读完成。

前言
React 的虚拟 Dom 和其 diff 算法,是 React 渲染效率远远高于传统 dom 操作渲染效率的主要原因。一方面,虚拟 Dom 的存在,使得在操作 Dom 时,不再直接操作页面 Dom,而是对虚拟 Dom 进行相关操作运算。再通过运算结果,结合 diff 算法,得出变更过的部分 Dom,进行局部更新。另一方面,当存在十分频繁的操作时,会进行操作的合并。直接在运算出最终状态之后才进行 Dom 的更新。从而大大提高 Dom 的渲染效率。\ 对于 React 如何通过 diff 算法来对比出做出变动的 Dom,React 内部有着复杂的运算过程,此文不做具体代码层级的讨论。仅仅通过一个小小 Demo 来宏观上的探讨下 diff 的运算思路。
diff 的对比思路
React 的 diff 对比是采用深度遍历的规则进行遍历比对的。以下图的 Dom 结构为例:\<img src=”https://github.com/ISummerRai…; style=”width: 620px” />\ 对比过程为:对比组件 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 的删除 \<img src=”https://github.com/ISummerRai…; style=”width: 620px” />\ 按照前面的分析,比对过程为:对比组件 1(没有变化)-> 对比组件 2(没有变化)-> 对比组件 4(组件 4 被移除,记录一个移除操作)-> 对比组件 5(没有变化)-> 对比组件 6(没有变化)-> 对比组件 3(没有变化)。对比结束。按照这个分析,用代码进行测试后,控制台日志应该会输出:
销毁组件 4
这一条日志。然而,在实际测试后,会发现输出日志为:
加载组件 5
加载组件 6
销毁组件 4
销毁组件 5
销毁组件 6
可以发现,除了“销毁组件 4”这一个操作之外,还进行了组件 5 和组件 6 的销毁和加载操作。难道是我们之前的分析是错误的?别急,我们再来进行另外一个实验:<img src=”https://github.com/ISummerRai…; style=”width: 620px” />\ 同样只删除了一个组件,只是删除的组件位置不同,按照上次的实验结果,控制台输出日志应该为:
加载组件 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 变更:\<img src=”https://github.com/ISummerRai…; style=”width: 620px” />\ 这种变更,由于组件 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:\<img src=”https://github.com/ISummerRai…; style=”width: 620px” />
// 变化前
<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 值的方法来提高渲染的效率。

正文完
 0