Reconciliation

345 阅读10分钟

本文为意译和整理,如有误导,请放弃阅读。原文

前言

React向开发者提供了一些声明式API。使用者在使用这些ApI的时候,根本不用操心每一个更新的背后,底层到底发生了什么(译者注:这就是数据驱动开发模式下的开发体验)。这使得应用开发变成更加容易了。不过,这种封装使得React的内部实现原理对我们而言,就不那么的显而易见了。这篇文章描述的是我们在实现“diffing”算法时候所做的决策。我们的目的是使得组件的更新流程是可以预测的。同时,更新速度也足够快,能够满足高性能application的需求。

动机

当你在使用React的时候,在某种程度下,你可以把(组件的)render()函数理解为一个创建react element tree的函数。在下一次state或者props更新的时候,同样,render()函数会返回一颗新的react element树。而React负责的就是,如何根据最新老react element树来高效地更新用户界面。

其实,这就是一个【通过生成最少数量的操作来将一棵树转化为另外一棵树的】算法问题。针对这个算法问题,目前有不少的通用解决方案,比如:ART算法。但是,根据该算法论文的描述,它有着O(n3)的时间复杂度(n是代表着整棵树的节点数量)。

如果我们在React中使用了这个算法实现的话,那么显示1000个节点的话,我们就需要10亿次的比对。这个时间代价太昂贵了。相反,我们作出了两个大胆的假设,并基于这两个假设上去实现了一个启发式算法(heuristic algorithm)。这个启发式算法的时间复杂度O(n)。

啥是启发式算法呢?百度百科如是说:启发式算法(heuristic algorithm)是相对于最优化算法提出的。一个问题的最优算法求得该问题每个实例的最优解。启发式算法可以这样定义:一个基于直观或经验构造的算法,在可接受的花费(指计算时间和空间)下给出待解决组合优化问题每一个实例的一个可行解,该可行解与最优解的偏离程度一般不能被预计。现阶段,启发式算法以仿自然体算法为主,主要有蚁群算法、模拟退火法、神经网络等。

哪两个假设呢?如下:

  • 我们认定两个不同类型的react element,它们所对应的树也不相同。
  • 我们认为开发者能够准确地使用key属性来告知React在不同的渲染流程中,哪些child element是稳定不变的。

在实际应用中,这两个假设能够命中几乎所有的开发用例。

diffing算法

当diffing两棵树的时候,React会首先对比这两棵树的root节点。不同类型(这里的类型指的是react element的type字段值)的root节点会有不同的对比算法。

两个节点的类型不同

无论在任何时候,如果对比的root节点是不同的类型的话,那么React就会把老树给拆解掉,然后从头开始组建一颗新的react element tree。举个例子,root节点从<a>变成<img>,或者是从<Article>变成<Comment>,又或者从<Button>变成<div>,所有的这些情况都会导致一次彻底的重建。

注意,上面列举了类型不同的三种情况:

  • 同样是host component,但是不是同一个HTML标签。
  • 同样是自定义类型(class component或者functional component),但是自定义类型名不同。
  • 分属不同的大类型。比如:【class component】 VS 【host component】。

当拆解老树的时候,它所对应的老DOM节点也会被摧毁掉,所对应的组件实例也会在摧毁前执行它的componentWillUnmount()生命周期方法。当组建一个新的react element树的时候,新的DOM节点会被生成并插入到真实的文档流中。组件实例相继执行componentWillMount()componentDidMount()生命周期方法。因为组件实例也会随之被摧毁,所以,任何与老树相关联的state都会丢失掉。

任何在root节点下面的子组件,子子组件都会在卸载掉同时它们的状态也会随之丢失了。举个例子,当diffing下面这种情况的时候:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

这会导致老Counter会被卸载,然后重新挂载一个新的Counter

在这里,我们不妨用Reactv15.0.0写个demo,来验证一下这个结论。这个demo分别演示把Counter的父组件从“div切换到div”,“div切换到span”的结果。我录了个小视频:

可以看到,官方文档的结论你是正确的。

同一个类型的DOM element

需要指出的是,DOM element就是指type为host component的react element。

当对比两个相同类型的React DOM element的时候,React将会去对比两者的attribute,保留底层的DOM node,只是(在老的节点上)更新那些已经发生改变的attribute。举个例子:

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

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

通过对比这两个DOM element,React能够知道只是需要修改老DOM node的classNameattribute。

当更新style的时候(属性值为object类型的情况),React也能够做到只更新发生改变的property值。举个例子:

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

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

当对比这两个React DOM element的时候,React知道只需要去修改color的属性值,而不是去修改fontWeight的属性值。

在处理完作为父节点的DOM element之后,React会递归处理它的子节点。

同一个类型的component element

在这种情况下,当组件更新的时候,它对应的组件实例不变。组件实例的state和props会在当前的渲染中得到更新,使得组件实例所携带的数据能够跟新的react element保持一致,然后React会调用组件实例的生命周期函数componentWillReceiveProps()componentWillUpdate()

接下来,React会调用组件实例的render方法,所返回的结果就是最新的react element tree。React使用diffing算法,拿它跟之前的结果对比。因为component element类型父节点的component element类型子节点也会有component element类型子节点,所以,这个操作是递归进行的。

在children上递归

当递归对比的节点是DOM node时候,React会仅仅同时迭代这两者的children list,然后生成对应的mutation。

举个例子,当在children的末尾增加一个元素的时候,下面这两棵树的转化性能会挺好的:

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

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

React会match上两个<li>first</li>树和match上<li>second</li>,然后在ul里面插入一颗<li>third</li>

如果,你的代码写得比较随意的话,你可能会在children的开头插入一个元素,这时候,React的性能就会比较差了。举个例子,下面两棵树的转化性能是很差的:

<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>子树。这样的低性能就会是一个问题。

key属性

为了解决上面这个问题,React引入了key属性。当children都有key属性的时候,React会使用这个新树上的key属性值去从老树中查找相匹配的子节点。举个例子说,通过向上面那个性能较差的例子中添加key属性的话,那么我们就能让其中的树比对的性能更高一点。

<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为20152016的两个节点只是挪动了位置而已。

在实际开发中,选定一个东西作为key属性也不是一件难事。往往是你将要显示的节点可能已经有一个唯一标识的ID了,所以,key值可以从你的数据中选取:

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

当你不能从你的数据中选取一个值作为你的key的时候,你可以往你的model里面手动添加一个ID性质的属性又或者计算出你一部分数据的hash值,把这个值当做key属性的值。key属性只需要在children list中唯一标识即可,它不需要在全局环境下唯一标识。

针对重排的情况,你可以使用数组元素中的索引值(index)作为key。在新的children list相对旧的的children list没有发生重排的话,这么干是没什么大问题的。假如发生了重排的话,那么重排的速度就会变慢了。

把数组元素的索引值作为key属性,在重排的情况下,还会导致一个跟组件状态相关的问题。组件实例的更新和复用都是基于它们的key属性的。如果将index作为key属性的话,那么当你(在下一次更新的时候)切换彼此的index值得时候,组件实例的状态也会被随着切换。作为一个坏结果,一些比如非控制input的组件状态就会以一种意料之外的方式被混淆和更新掉。

这里是CodePen上一个使用index作为key属性导致问题的例子,这里也有一个演示如何通过不使用index作为key值来修复这些重排情况下会出现的问题的例子

Tradeoffs(权衡)

我们需要时刻记住的是,reconciliation算法是一个实现细节。React有可能针对每一次的更新请求都会rerender整个application。注意,这里所说的rerender是指调用所有组件的render方法,而不是说React会卸载然后重新挂载组件。React只会根据上面所提到的diffing规则来将具体结果转化到用户界面上。

随着我们所认定的common use case的调整,我们也会定期地改善启发式算法。在当前的算法中,你可以这么说“a subtree has been moved amongst its siblings, but you cannot tell that it has moved somewhere else. The algorithm will rerender that full subtree.”

因为React的Reconciliation算法是基于启发式的,所以,如果在实际开发中,启发式算法背后的那两个假设没有被匹配上的,那么性能就会有问题了。再次强调这两个假设:

  1. 当前算法不会尝试对比不同类型组件的subtree。如果你尝试在两个具有十分相似output的组件类型之间切换的话,那么我建议你将这两个不同类型组件统一为同一种类型。在实际开发中,我们还没有发现这么做会有什么问题。
  2. key应该是稳定的,可预测的且具备唯一性的。不稳定的key(比如那些通过Math.random()来产生的值)将会导致大量的组件实例和DOM节点被不必要地重新创建。这就会导致性能下降和子组件状态的丢失。