React在虚拟DOM和DOM的协调

原文:Reconciliation - React Docs

概述

React提供了一个声明式的API,你无需担心每次更新具体都发生了些什么。这使得写应用变得简单多了,但是React内部到底怎么实现的可能不是很明显。本文解释了我们在React的diff算法中所做的选择,从而使得组件的更新是可预测的的同时,且更新对于高性能的应用也表现得足够快。

动机

当你使用React,在一个特定的时间点,你可以把render()当做创建一个React元素树的函数。在下次状态或者属性更新的时候,render()函数会返回一个不同的React元素树。然后React要负责搞定怎样高效地更新UI以匹配最相似的树。

对这个算法问题,有一些一般的生成最少操作的来转换一个树到另一个树的解决方案。然而,这些算法的时间复杂度高达$O(n^3)$,这里的$n$代表一个树中元素的个数。

如果我们在React中用这些算法,展示1000元素则需要十亿次比较。这个实在是太昂贵了。相反,React实现了一种基于以下两种假设的启发式的$O(n)$算法。

  1. 两个不同的元素会生成不同的树;
  2. 开发者可以通过key属性知道在诸多渲染(render)函数和控制那个子元素可能是稳定的。

译者注:
第一个假设意思是,一个元素下属的树,如果该元素(根节点)改变了,则其子树不一样;

在实践中,这些假设几乎在所有的实践用例中都是正确的。

Diff算法

做两棵树的diff时,React首先比较两个根节点元素。然后根据根节点元素的类型决定后续行为。

不同类型的元素

只要根节点是不同类型的元素,React会拆除原来的树然后构建一棵新的树。从a标签到img标签,或者从ArticleCommnet,后者从Buttondiv——任何这种情况都会引发整棵树的重新构建。

当拆除一棵树的时候,老的DOM节点会被销毁。组件实例处理componentWillUnmount()。当创建一棵新的树的时候,新的DOM节点会被插入到DOM中。组件实例处理componentWillMount()之后处理componentDidMount()。任何与原有的树相关的状态都会丢失。

根节点元素下的任何组件也会被卸载,并且其状态也将被删除。如下所示,当做diff时:

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

<span>
<Counter />
</span>

这种情况会销毁Counter然后然后重新挂载一个新的Counter

相同类型的DOM元素

当比较两个相同的React DOM元素时,React会查看两者的属性,然后保留DOM节点下相同的属性,而只更新修改过的属性。例如:

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

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

通过对比这两个元素,React知道只修改DOM节点下的className属性。

当更新style时,React也会知道只更新改变过了的属性。例如:

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

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

当转换两个元素的时候,React知道只修改color样式,而不会去改fontWeight

在处理了当前DOM节点之后,React会递归处理所有子节点。

相同类型的组件元素

组件更新时,React会保留同一个实例,所以在渲染期间,状态都被保持。React更新组件实例的属性来匹配新的元素,并且在实例上调用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会修改每一个子节点,而并不能认识到其实可以完好无损地保存<li>Duke</li><li>Villanova</li>两颗子树。这个低效率的工作方式是有问题的。

Keys

为了解决这个问题,React提供了key属性。当子元素有key时,React用key来匹配原树中的子元素和新树中的子元素。举个例子,添加一个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知道key‘2014’的元素是个新元素了,且知道key为’2015’和’2016’的元素只是被移动了而已。

在实践中,找一个key属性一般都不难。你要展示的元素已经有一个唯一的ID,所以key可以直接从数据中找:

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

如果不是这种情况(找不到key),你可以添加一个新的ID属性到你的模型中或者离散化你的部分内容以生成一个key。这个key在兄弟节点中必须是唯一的,不要求全局唯一。

此外还有最后一招,你可以传递数组中的当前项的索引作为一个key。如果永远不重新排序的话基本就没问题,但是重新排序会变得很慢。

折中做法

React在虚拟DOM和DOM的协调算法是一个实现细节,这点很重要。React可以对每个动作重新渲染整个APP,最后的结果都会是一样的。我们会经常改进启发方法以让一般用例更快。

在当前实现中,可以注意到子树会移动到其兄弟节点间,但是它不会被移动到了其他地方。算法会重新渲染整个子树。

由于React基于的启发方法,如果不满足这些启发背后的假定,性能就很糟糕了。

  1. 算法不会尝试对比不同组件类型的子树。如果遇到在两个不同组件类型的组件中输出了很相似的结果,你应该想想怎么让它们变成同一类型。我们发现在实践中这是一个问题。
  2. key应该是稳定的,可预测的,并且是唯一的。不稳定的key(如使用Math.random()生成的key)会引发大量组件实例和DOM节点和其他不必要更新,这个会恶化性能,并且丢失子组件的状态。

参考

  1. Reconciliation - React Docs