原文:Reconciliation - React Docs
概述
React提供了一个声明式的API,你无需担心每次更新具体都发生了些什么。这使得写应用变得简单多了,但是React内部到底怎么实现的可能不是很明显。本文解释了我们在React的diff算法中所做的选择,从而使得组件的更新是可预测的的同时,且更新对于高性能的应用也表现得足够快。
动机
当你使用React,在一个特定的时间点,你可以把render()当做创建一个React元素树的函数。在下次状态或者属性更新的时候,render()函数会返回一个不同的React元素树。然后React要负责搞定怎样高效地更新UI以匹配最相似的树。
对这个算法问题,有一些一般的生成最少操作的来转换一个树到另一个树的解决方案。然而,这些算法的时间复杂度高达$O(n^3)$,这里的$n$代表一个树中元素的个数。
如果我们在React中用这些算法,展示1000元素则需要十亿次比较。这个实在是太昂贵了。相反,React实现了一种基于以下两种假设的启发式的$O(n)$算法。
- 两个不同的元素会生成不同的树;
- 开发者可以通过key属性知道在诸多渲染(render)函数和控制那个子元素可能是稳定的。
译者注:
第一个假设意思是,一个元素下属的树,如果该元素(根节点)改变了,则其子树不一样;
在实践中,这些假设几乎在所有的实践用例中都是正确的。
Diff算法
做两棵树的diff时,React首先比较两个根节点元素。然后根据根节点元素的类型决定后续行为。
不同类型的元素
只要根节点是不同类型的元素,React会拆除原来的树然后构建一棵新的树。从a
标签到img
标签,或者从Article
到Commnet
,后者从Button
到div
——任何这种情况都会引发整棵树的重新构建。
当拆除一棵树的时候,老的DOM节点会被销毁。组件实例处理componentWillUnmount()。当创建一棵新的树的时候,新的DOM节点会被插入到DOM中。组件实例处理componentWillMount()之后处理componentDidMount()。任何与原有的树相关的状态都会丢失。
根节点元素下的任何组件也会被卸载,并且其状态也将被删除。如下所示,当做diff时:
1 | <div> |
这种情况会销毁Counter
然后然后重新挂载一个新的Counter
。
相同类型的DOM元素
当比较两个相同的React DOM元素时,React会查看两者的属性,然后保留DOM节点下相同的属性,而只更新修改过的属性。例如:
1 | <div className="before" title="stuff" /> |
通过对比这两个元素,React知道只修改DOM节点下的className属性。
当更新style时,React也会知道只更新改变过了的属性。例如:
1 | <div style={{color: 'red', fontWeight: 'bold'}} /> |
当转换两个元素的时候,React知道只修改color样式,而不会去改fontWeight。
在处理了当前DOM节点之后,React会递归处理所有子节点。
相同类型的组件元素
组件更新时,React会保留同一个实例,所以在渲染期间,状态都被保持。React更新组件实例的属性来匹配新的元素,并且在实例上调用componentWillReceiveProps()方法和componentWillUpdate()方法。
之后调用render()方法,然后在上一次渲染的结果和新的渲染结果上递归做diff。
子元素上的递归
默认情况下,当对一个DOM节点上的子元素进行递归时,React只是同时迭代了修改前和修改后的两个列表的子元素,并且只要发现了不同就生成一个修改。
举个例子,当在列表的尾部添加一个元素时候,两个数之间的转换就顺利地进行:
1 | <ul> |
React会对比两个<li>first</li>,两个<li>second</li>,然后插入<li>third</li>。
如果你很直白的实现这个,在列表的头部插入就性能很差了。举个例子,两棵树的转换就表现不佳:
1 | <ul> |
React会修改每一个子节点,而并不能认识到其实可以完好无损地保存<li>Duke</li>和<li>Villanova</li>两颗子树。这个低效率的工作方式是有问题的。
Keys
为了解决这个问题,React提供了key属性。当子元素有key时,React用key来匹配原树中的子元素和新树中的子元素。举个例子,添加一个key到上面的示例中去,树的转换就变得有效了:
1 | <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基于的启发方法,如果不满足这些启发背后的假定,性能就很糟糕了。
- 算法不会尝试对比不同组件类型的子树。如果遇到在两个不同组件类型的组件中输出了很相似的结果,你应该想想怎么让它们变成同一类型。我们发现在实践中这是一个问题。
- key应该是稳定的,可预测的,并且是唯一的。不稳定的key(如使用Math.random()生成的key)会引发大量组件实例和DOM节点和其他不必要更新,这个会恶化性能,并且丢失子组件的状态。