Alex Chueng's blogs
©2022 Alex Chueng's blogs
BACK
React Diffing 算法
12 min read
# 记录
View

有很多讲解 React Diff 算法的文章,都很努力试图把这个事情讲清楚,但是都没有官方解释来的透彻。

以下为 React 官方对 Diffing 算法的设计和策略的描述

官方文档


协调

React 提供的声明式 API 让开发者可以在对 React 的底层实现没有具体了解的情况下编写应用。在开发者编写应用时虽然保持相对简单的心智,但开发者无法了解内部的实现机制。本文描述了在实现 React 的 “diffing” 算法中我们做出的设计决策以保证组件满足更新具有可预测性,以及在繁杂业务下依然保持应用的高性能性。

设计动力

在某一时间节点调用 React 的 render() 方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的 render() 方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。

这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。

如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:

  1. 两个不同类型的元素会产生出不同的树;
  2. 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;

在实践中,我们发现以上假设在几乎所有实用的场景下都成立。

Diffing 算法

当对比两颗树时,React 首先比较两棵树的根节点。不同类型的根节点元素会有不同的形态。

比对不同类型的元素

当根节点为不同类型的元素时,React 会拆卸原有的树并且建立起新的树。举个例子,当一个元素从 <a> 变成 <img>,从 <Article> 变成 <Comment>,或从 <Button> 变成 <div> 都会触发一个完整的重建流程。

当拆卸一颗树时,对应的 DOM 节点也会被销毁。组件实例将执行 componentWillUnmount() 方法。当建立一颗新的树时,对应的 DOM 节点会被创建以及插入到 DOM 中。组件实例将执行 componentWillMount() 方法,紧接着 componentDidMount() 方法。所有跟之前的树所关联的 state 也会被销毁。

在根节点以下的组件也会被卸载,它们的状态会被销毁。比如,当比对以下更变时:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

React 会销毁 Counter 组件并且重新装载一个新的组件。

比对同一类型的元素

当比对两个相同类型的 React 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性。比如:

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

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

通过比对这两个元素,React 知道只需要修改 DOM 元素上的 className 属性。

当更新 style 属性时,React 仅更新有所更变的属性。比如:

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

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

通过比对这两个元素,React 知道只需要修改 DOM 元素上的 color 样式,无需修改 fontWeight

在处理完当前节点之后,React 继续对子节点进行递归。

比对同类型的组件元素

当一个组件更新时,组件实例保持不变,这样 state 在跨越不同的渲染时保持一致。React 将更新该组件实例的 props 以跟最新的元素保持一致,并且调用该实例的 componentWillReceiveProps()componentWillUpdate() 方法。

下一步,调用 render() 方法,diff 算法将在之前的结果以及新的结果中进行递归。

对子节点进行递归

在默认条件下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表;当产生差异时,生成一个 mutation。

在子元素列表末尾新增元素时,更变开销比较小。比如:

<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> 树。

如果简单实现的话,那么在列表头部插入会很影响性能,那么更变开销会比较大。比如:

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

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

React 会针对每个子元素 mutate 而不是保持相同的 <li>Duke</li><li>Villanova</li> 子树完成。这种情况下的低效可能会带来性能问题。

Keys

为了解决以上问题,React 支持 key 属性。当子元素拥有 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 知道只有带着 '2014' key 的元素是新元素,带着 '2015' 以及 '2016' key 的元素仅仅移动了。

现实场景中,产生一个 key 并不困难。你要展现的元素可能已经有了一个唯一 ID,于是 key 可以直接从你的数据中提取:

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

当以上情况不成立时,你可以新增一个 ID 字段到你的模型中,或者利用一部分内容作为哈希值来生成一个 key。这个 key 不需要全局唯一,但在列表中需要保持唯一。

最后,你也可以使用元素在数组中的下标作为 key。这个策略在元素不进行重新排序时比较合适,但一旦有顺序修改,diff 就会变得慢。

当基于下标的组件进行重新排序时,组件 state 可能会遇到一些问题。由于组件实例是基于它们的 key 来决定是否更新以及复用,如果 key 是一个下标,那么修改顺序时会修改当前的 key,导致非受控组件的 state(比如输入框)可能相互篡改导致无法预期的变动。

这是 在 Codepen 上的例子,展示使用下标作为 key 时导致的问题,以及 这里 是一个不使用下标作为 key 的例子的版本,修复了重新排列,排序,以及在列表头插入的问题。

权衡

请谨记协调算法是一个实现细节。React 可以在每个 action 之后对整个应用进行重新渲染,得到的最终结果也会是一样的。在这个 context 下,重新渲染表示在所有组件内调用 render 方法,这不代表 React 会卸载或装载它们。React 只会基于以上提到的规则来决定如何进行差异的合并。

我们定期探索优化算法,让常见用例更高效地执行。在当前的实现中,可以理解为一棵子树能在其兄弟之间移动,但不能移动到其他位置。在这种情况下,算法会重新渲染整棵子树。

由于 React 依赖探索的算法,因此当以下假设没有得到满足,性能会有所损耗。

  1. 该算法不会尝试匹配不同组件类型的子树。如果你发现你在两种不同类型的组件中切换,但输出非常相似的内容,建议把它们改成同一类型。在实践中,我们没有遇到这类问题。
  2. Key 应该具有稳定,可预测,以及列表内唯一的特质。不稳定的 key(比如通过 Math.random() 生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。