本文为意译和整理,如有误导,请放弃阅读。原文
前言
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为2015和2016的两个节点只是挪动了位置而已。
在实际开发中,选定一个东西作为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算法是基于启发式的,所以,如果在实际开发中,启发式算法背后的那两个假设没有被匹配上的,那么性能就会有问题了。再次强调这两个假设:
- 当前算法不会尝试对比不同类型组件的subtree。如果你尝试在两个具有十分相似output的组件类型之间切换的话,那么我建议你将这两个不同类型组件统一为同一种类型。在实际开发中,我们还没有发现这么做会有什么问题。
- key应该是稳定的,可预测的且具备唯一性的。不稳定的key(比如那些通过
Math.random()来产生的值)将会导致大量的组件实例和DOM节点被不必要地重新创建。这就会导致性能下降和子组件状态的丢失。