如何让你的 React 『变慢』?探析 Array Diff 的一些边角特性

本文首发于个人 Blogreact

当你看到这个标题的时候,必定很好奇,React 不是很快么?为啥会变慢呢?在写这篇文章以前,我也是这么认为的,可是当我去看了一下 React 有关 Array 的 Diff 以后,我才认识到其实 React 若是你用的不正确,那么是会变慢的。算法

React Diff 算法

React Diff 算法相信你们不是很陌生吧,这里就不具体展开讲了。不过有一点要补充下,Diff 算法针对的是整个 React 组件树,而不只仅是 DOM 树,虽然这样性能会比较低一些,可是实现起来却很方便。typescript

而在 Diff 算法中,针对数组的 diff 实际上是比较有意思的一个地方。在开始讲解方面,我但愿你能对 React 有必定的了解和使用。数组

试一试有什么区别?

首先咱们建立 3 个组件,分别渲染 10000 个 DOM 元素,从 [1...10000] ,渲染成以下。app

const e10000 = new Array(10000).fill(0).map((_, i) => i + 1)
element10000.map(i => <div key={`${i}`}>{i}</div>)
复制代码

每一个组件有两个状态,会切换数据的顺序编辑器

  • 组件 A 在 [1...10000][2,1,3...10000] 之间切换。
  • 组件 B 在 [1...10000][10000,1...9999] 之间切换
  • 组件 C 在 [1...10000][10000...1] 之间切换,也就是正序和倒序之间切换。

咱们简单命名下,默认的初始状态为 S1 而切换以后的状态为 S2 。你们能够思考一下,同一个组件状态切换的时候,所耗费的时间是否是都是同样的?能够直接使用这个 DEMO函数

能够直接点击上方的 toggle 来切换二者之间的状态,并在控制台中查看渲染的时间。由于每次时间都不是绝对准确的,因此取了屡次平均值,直接揭晓答案:性能

组件 S2 => S1 S1 => S2
A 102ms 103ms
B 129ms 546ms
C 556ms 585ms

有么有以为很奇怪,为何一样是 S1 ⇒ S2 ,一样是只改变了一个元素的位置,为何 A 和 B 的时间差距有这么多的差距。这个具体原理就要从 Diff 算法开始讲起了。优化

Array Diff 的原理

在讲 React 的实现以前,咱们先来抛开 React 的实现独立思考一下。可是若是直接从 React 的组件角度下手会比较麻烦,首先简化一下问题。spa

存在两个数组 A 和 B,数组中每个值必需要保证在对应数组内是惟一的,类型能够是字符串或者数字。那么这个问题就转变成了如何从数组 A 经过最少的变换步骤到数组 B。

其实每一个元素的值对应的就是 React 当中的 key。若是一个元素没有 key 的话,index 就是那个元素默认的 key。为何要强调最少?由于咱们但愿的是可以用最少的步数完成,可是实际上这会形成计算量的加大,而 React 的实现并无计算出最优解,而是一个较快解。

顺便定义一下操做的类型有:删除元素插入元素移动元素

这里又要引伸一个特殊点,React 充分利用了 DOM 的特性,在 DOM 操做中,你是能够不使用 index 来索引数据的。简单来说,若是用数组表示,删除须要指定删除元素的索引,插入须要指定插入的位置,而移动元素须要指定从哪一个索引移动到另外一个索引。而利用 DOM,咱们就能够简化这些操做,能够直接删除某个元素的实例,在某个元素前插入或者移动到这里(利用 insertBefore API,若是是要在添加或者移动到最后,能够利用 append )。这样最大的好处是咱们不须要记录下移动到的位置,只须要记录下那些元素移动了便可,并且这部分操做正好能够由 Fiber 来承担。

举个例子说,从 A=[1,2,3] 变化到 B=[2,3,4,1],那么只须要记录以下操做便可:

有人好奇,不须要记录移动插入到那个元素前面么?其实不须要的,这是由于你有了操做列表和 B 数组以后,就能够知道目标元素在哪里了。并且采用这种方式就根本不须要关心每次操做以后索引的变化。

回到上面的简化后的问题,首先经过对比 A、B 数组,能够获得哪些元素是删除的,哪些元素是添加的,而无论采用什么样子的策略,添加删除元素的操做次数是没法减小的。由于你不能凭空产生或者消失一个元素。那么咱们问题就能够再简化一下,把全部的添加删除的元素剔除后分别获得数组 A' 和 B',也就是 A' 中不包含被删除的元素,B' 中不包含被添加的元素,此时 A' 和 B' 的长度必定是同样长的。也就是求解出最少移动次数使得数组 A' 可以转化成数组 B'。

若是只是简单的求解一下最少移动步数的话,答案很简单,就是最长上升子序列(LIS,Longest Increasing Subsequence)。关于如何证实为何是最长不降低子序列这个算法,能够经过简单的反证法获得。关于这个算法的内容我就不具体讲解了,有兴趣的能够自行 Google。在这里咱们只须要知道这个算法的时间复杂度是 O(n^2)

可是如今咱们还没法直接应用这个算法,由于每一个元素的类型多是字符串或者数字,没法比较大小。定义数组 T 为 B' 内元素在 A' 的位置。举个例子,若是 A' = ['a', 'b', 'c'] B' = ['b', 'c', 'a'],那么 T = [2, 3, 1]。本文约定位置是从 1 开始,索引从 0 开始。

此时即可以对 T 求解 LIS,能够获得 [2, 3],咱们将剩下不在 LIS 中的元素标记为移动元素,在这里就是 1,最后补上被剔除的删除和插入的元素的操做动做。这样 Diff 算法就能够结束了。

上面讲解的是一个我的认为完整的 Array Diff 算法,可是仍是能够在保证正确性上继续优化。可是无论优化,这个复杂度对于 React 来说仍是偏高的,而如何平衡效率和最优解成为了最头疼的问题,好在 React 采用了一个混合算法,在牺牲掉必定正确性的前提下,将复杂度下降为 O(n)。下面咱们来说解下。

React 简化以后的 Array Diff

你们有过 React 开发经验的人很清楚,大部分状况下,咱们一般是这样使用的:

  • 情形1:一个标签的的直接子子标签数量类型顺序不变,一般用于静态内容或者对子组件的更新

    // 好比每次渲染都是这样的,里面的直接子元素的类型和数量是不变的,在这种状况下,实际上是能够省略 key
          <div>
          	<div key="header">header</div>
          	<div key="content">content</div>
          	<div key="footer">footer</div>
          	<SubCmp time={Date.now()}/>
          </div>
    复制代码
  • 情形2:一个标签有多个子标签,可是通常只改变其中的少数几个子标签。最多见的场景就是规则编辑器,每次只在最后添加新规则,或者删除其中某个规则。固然了,滚动加载也算是这种。

  • 情形3:交换某几个子标签之间的顺序

  • 情形4:翻页操做,几乎重置了整个子元素

上面只是简单举了几个常见的例子,你们能够发现,大部分状况下子标签变更的其实并很少,React 利用了这个,因此将 LIS 简化成以第一个元素开始,找到最近上升子序列。简单来来说就是从头开始遍历,只要这个元素不小于前的元素,那么就加入队列。

Q = [4, 1, 5, 2, 3]
    // 标准算法
    LIS = [1, 2, 3]
    // 简化后的算法,从第一个开始,找到最近的不降低子序列便可。
    LIS_React = [4, 5]
复制代码

咱们乍一看,这个算法不对呀,随便就能举出一个例子让这个算法错成狗,可是咱们要结合实际状况来看。若是咱们套回前面说的几种状况,能够看到对于状况 1,2,3 来说,几乎和简化前效果是同样。而这样作以后,时间复杂度下降为 O(n) ,空间复杂度下降为 O(1)。咱们给简化后的算法叫作 LIS' 方便后面区分。

咱们将 LIS 算法简化后,配合上以前同样的流程就能够得出 React 的 Array Diff 算法的核心流程了。(为何叫核心流程,由于还有不少优化的地方没有讲)

变慢的缘由?

当咱们在了解了 React 的实现以后,咱们再回过来头来看看前面给出的三个例子为啥会有这么大的时间差距?

  • 组件 A 从 [1...10000] 变化到 [2,1,3...10000] 。此时咱们先求解一下 LIS' 能够获得 [2,3,4...10000],那么咱们只须要移动 1 这个元素就能够了,将移动到元素 3 前面。同理反过来也是如此,也就是说 S1 ⇒ S2 和 S2 ⇒ S1 的所须要移动的次数是一致的,理论上时间上也就是相同的。
  • 组件 B 从 [1...10000] 变化到 [10000,1,2...9999] 。同理,先计算 LIS' 能够获得 [10000],没错,你没看错,就是只有一次元素,那么我须要将剩下的全部元素全都移动到 10000 的后面去,换句话要进行 9999 次移动。这也就是为啥 S1 => S2 的时间会这么慢。可是反过来却不须要这个样子,将状态反过来,并从新计算索引,那么也就是从 [1...10000][2,3....10000,1],在计算一次 LIS' 获得 [2,3...10000] ,此时只须要移动一次便可,S2 ⇒ S1 的时间也就天然恢复的和组件 A 一致。
  • 组件 C 是彻底倒序操做,因此只分析其中一个过程便可。首先计算 LIS' 能够获得,[10000] ,也就是说要移动 9999 次,反过来也是要 9999 次,因此时间状态是一致的。

通过这样的分析你们是否是就明白为啥会变慢了吧?

优化细节

下降 Map 的生成操做次数

上面有一点没有讲到,不知道你们有没有思考到,我怎么知道某个元素是该添加函数删除呢?你们第一反应就是构建一个 Set,将数组元素全放进去,而后进行判断就能够了。可是在 React 中,其实用的是 Map,由于要存储对应的 Fiber,具体细节你们能够不用关注,只须要知道这里用 Map 实现了这个功能。

无论怎么样,根据算法,一开始确定要构建一遍 Map,可是咱们来看下上面的 情形1。发现内容是根本不会发生变化的,并且对于 情形2 来说,有很大的几率前面的大部分是相同的。

因而 React 一开始不构建 Map,而是假设前面的内容都是一致的,对这些元素直接执行普通的更新 Fiber 操做,直到碰到第一个 key 不相同的元素才开始构建 Map 走正常的 Diff 流程。按照这个方式,情形1根本不会建立 Map,并且对于情形二、3来说也会减小不少 Map 元素的操做(set、get、has)。

下降循环次数

按照上面的算法,咱们须要至少 3 遍循环:第一遍构建 Map,第二遍剔除添加删除的元素生成 A' 和 B',第三遍计算 LIS 并获得哪些元素须要移动或者删除。而咱们发现第二遍和第三遍是能够合并在一块儿的。也便是说咱们在有了 Map 的状况下,不须要剔除元素,当遍历发现这个元素是新增的时候,直接记录下来。

总结

关于 Diff 算法其实还有不少的细节,我这边没有过多讲解,由于比较简单,比较符合直觉。你们有兴趣的能够本身去看下。另外有人应该会注意到,上面的例子中,为何切换一样的次数,有的时间长,有的时间短了。往后有时间再分析下补充了。

相关文章
相关标签/搜索