深入了解React之Reconciliation

动机

在使用React时,在单个时间点钟,可以将render函数视为创建React元素树。在下一个state或props更新时,render函数将返回一个不同的React元素树。然后,React需要弄清楚如何有效地更新UI以匹配最新的树。

这个算法问题有一些通用的解决方案,既生成将一棵树转换成另一棵树的最小操作次数。然而,现有技术的算法具有O(n^3)的复杂度,其中n是书中元素的数量。简单来说,对比两棵树需要的时间复杂度是O(n^2),操作一棵树(移动、创建、删除)时需要遍历一次树,因此时间复杂度是O(n^3)。

如果在React中使用它,显示1000个元素将需要10亿次对比。这消耗太大了。
因此,React基于两个假设实现了一个启发式O(n)算法:

  1. 不同类型的两个元素将产生不同的树
  2. 可以通过key来指定哪些子元素可以在不同渲染中保持稳定

diff算法

在区分两棵树时,React首先会比较两个根元素。根据根元素的类型,行为会有所不同。

不同类型的元素

每当根元素具有不同的类型时,React将拆除旧树并从头开始构建新树。从<a><img>,或从<Article><Comment>,都将导致完全重建。

在拆除树时,旧dom节点将被销毁。组件实例接收componentWillUnMount()。
构建新树时,会将新dom节点插入到dom中。组件实例接收componentWillMount(),然后接收componentDidMount()。与旧树相关联的任何状态都将丢失。

root下面的任何组件也将被卸载并且其状态被破坏。例如

1
2
3
4
5
6
7
<div>
<Counter />
</div>

<span>
<Counter />
</span>

这会导致旧的<Counter>被破坏,并新建一个。

相同类型的dom元素

当比较两个相同类型的React DOM元素时,React查看两者的属性,保持相同的底层dom节点,并仅更新更改的属性。例如:

1
2
3
<div className="before" title="stuff" />

<div className="after" title="stuff" />

通过比较这两个元素,React知道只修改底层dom节点上的className。

更新样式时,React也知道只更新已更改的属性。例如:

1
2
3
<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

在这两个元素之间进行转换时,React知道只修改颜色样式,而不是fontWeight。
在处理dom节点之后,React对子节点进行递归。

相同类型的组件元素

当组件更新时,实例保持不变,以便在渲染之间保持状态。React更新底层组件实例的props以匹配新元素,并在底层实例上调用componentWillReceiveProps()和componentWillUpdate()。

接下来,调用render()方法,diff算法开始递归计算前一个结果和新结果

子节点的递归

默认情况下,当对dom节点的子节点进行递归时,React会同时迭代两个子节点列表,并在出现差异时生成突变。

例如,在子节点末尾添加元素时,在两个树之间进行转换效果很好

1
2
3
4
5
6
7
8
9
10
<ul>
<li>first</li>
<li>second</li>
</ul>

<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>

React将对比两个<li>first</li>和两个<li>second</li>,然后插入<li>third</li>

如果你在开头插入一个元素,那会有糟糕的表现。例如

1
2
3
4
5
6
7
8
9
10
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>

<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>

React将改变每个children,而不是意识到它可以保持<li>Duke</li><li>Villanova</li>不变。这种低效率可能是一个问题。

keys

为了解决这个问题,React支持一个key属性。当children有key时,React使用key来匹配旧树和新树中的children。例如,在上面的低效示例中添加一个key可以使树转换有效:

1
2
3
4
5
6
7
8
9
10
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

现在React知道具有key2014的元素时新元素,并且2015和2016元素刚刚移动。

在实践中,找到key通常不难。它可能来自你的数据:

1
<li key={item.id}>{item.name}</li>

如果不是这种情况,你可以向模型添加新的id属性,或者对内容的某些部分进行hash编码以生存密钥。关键只需要在其兄弟姐妹中独一无二,而不是全树独一无二。

实在不行,可以将数组中的项目索引作为key。如果项目从不重新排序,这可以很好的工作,但重新排序将很慢。

当索引用作key时,重新排序也会导致组件状态出现问题。组件实例根据key进行更新和重用。如果key是索引,则移动项目会更改它。因此,诸如非受控组件的状态可能会以意想不到的方式混淆和更新。

权衡

牢记协调算法的实现细节非常重要。React可以在每个动作上重新呈现整个应用程序,而最终结果是一样的。为了清楚可见,在这种情况下重新渲染意味着为所有组件调用渲染,但这并不意味着React将卸载并重新安装它们。它只会按照前面章节中规定的规则应用差异。

在当前的实现中,可以表明一个事实,即子树在其兄弟节点中移动,但你无法告知其移动到哪。该算法会重新渲染整个子树。

因为React依赖于启发式方法,如果不满足它们背后的假设,性能将受到影响。

  1. 该算法不会尝试匹配不同组件类型的子树。如果你发现自己在具有非常相似输出的两种组件类型之间交替,则可能需要使其成为相同类型。
  2. key应该是稳定的,可预测的和独特的。不稳定的key将导致许多组件实例和dom节点被不必要地重建,这可能导致性能下降和并丢失子组件的状态。
坚持原创技术分享,您的支持将鼓励我继续创作!