1. 前置知识
2. 什么是 Diff 算法
在 React 中,真实的 DOM 构造会被模拟成一个虚拟 DOM 树结构,每当数据变化时都会重新构建整个虚拟 DOM 树。也就是说,React 在内存中会同时维护着两棵虚拟 DOM 树:一棵表示当前的 DOM 结构,另一棵在 React 状态变更将要重新渲染时生成,表示即将要刷新到屏幕的未来状态。因此,数据状态发生转移时,我们有新旧两份虚拟 DOM 结构,因此我们可以有两种方式来实现将虚拟 DOM 映射为真实的 DOM:
- 暴力的直接渲染新的虚拟 DOM,依次创建整棵 DOM 树;
- 通过某种策略找到新旧 DOM 直接的差异部分,只去更新差异的部分;
在思考算法时,往往都需要考虑时间和空间复杂度这两个非常重要的性能评估指标,切勿盲目暴力求解,因为暴力破解法的执行效率往往都是非常低下的。如果采用第一种方式,那么会重新渲染整个 DOM 结构,这个过程开销是很大的,很明显这种方式不可取。因此,React 选取的是第二种的方式,部分更新,只更新变化的地方,第二种方式中所谓的某种策略就是我们要提到的 Diff 算法:对当前整个虚拟 DOM 树和上一次的虚拟 DOM 树进行对比,计算出 Virtual DOM 中改变的部分,最后仅仅将需要变化的部分进行实际的 DOM 操作。
3. 传统的 Diff 算法
Diff 算法需要解决的问题就是计算一颗树转化为另一颗树有哪些改变。这个问题其实是一个非常复杂的算法问题,在算法领域叫做计算树的编辑距离( Tree Edit Distance 算法),即计算从一棵树转换为另一棵树所需要树的编辑操作的最少次数。我们学习 React Diff 算法的时候,经常看到的传统或标准的 Diff 算法其实指的就是 Tree Edit Distance 算法。我们用一个例子来简单介绍一下:
上图中,最小操作步数(编辑距离)为 3:
- 删除 ul 节点
- 添加 span 节点
- 添加 text 节点
Tree Edit Distance 算法从 1979 年到 2011年,经过了 30 多年的发展演变,其时间复杂度逐渐被优化到 O(n^3),其发展历程大致如下(n 是树中节点的总数):
- 1979年,Tai 提出了次个非幂级复杂度算法,时间复杂度为 O(m3 * n3)
- 1989年,Zhang and Shasha 将 Tai 的算法进行优化,时间复杂度为 O(m2 * n2)
- 1998年,Klein 将 Zhang and Shasha 的算法再次优化,时间复杂度为 O(n^2 * m * log(m))
- 2009年,Demiane 提出最坏情况下的计算公式,将时间复杂度控制在 O(n^2 * m * (1+log(m/n)))
- 2011年,Pawlik and N.Augsten 提出适用于所有形状的树的算法,并将时间复杂度控制在 O(n^3)
O(n^3) 这一时间复杂度是怎么来的,如果没有在算法领域积累深厚的底蕴并且阅读相关文献是很难解释清楚的,并且 Tree Edit Distance 算法的具体实现和原理并不是我们要讨论的重点,我们只需要知道这一结论就 ok,有兴趣的可以直接看这篇论文:A Robust Algorithm for the Tree Edit Distance。
在网上看到的猜测:传统的 Diff 算法需要找到两个树的最小更新方式,所以需要两两对比每个叶子节点是否相同,对比就需要 O(n^2) 次了,对比完差异后还要计算最小转换方式,实现后复杂度来到了O(n^3)。
⚠️ 这只是猜测,未经考证,仅供参考
4. React Diff 原理
从传统的 Diff 算法发展演变历程来看,即使在最前沿的算法中,该算法的时间复杂程度也高达 O(n^3),其中 n 是树中元素的数量。时间复杂度 O(n^3) 是什么概念?这在算法中叫做指数复杂度,随着问题规模 n 的增加,运行时间的增长趋势将呈现为指数级增长。数学中的指数增长是非常恐怖的,在刚开始的时候,增长不明显,它和线性增长差不多,但随着时间的推移,指数增长的威力会开始惊人的显现出来。
时间复杂度 O(n^3) 指数增长的威力到底有多可怕呢?React 文档中提到,如果在 React 中直接使用传统的 Diff 算法,那么展示1000 个元素所需要执行的计算量将在十亿的量级范围。由于 Diff 操作本身也会带来性能损耗,这种指数型的性能消耗对于前端渲染场景来说代价太过高昂。
因此,React 需要想办法解决传统 Diff 算法所带来的性能限制。但对于计算从一棵树转换为另一棵树所需要树的编辑操作的最少次数这一问题本质的研究来说,传统 Diff 算法 O(n^3) 时间复杂度已经是最理想的了,是否还能继续优化改进我们不得而知,而且就算可以,其研究难度可想而知是非常之大的。那 React 是怎么做的呢?由于传统的 Diff 算法本身是没有问题的,所以需要对计算从一棵树转换为另一棵树所需要树的编辑操作的最少次数这一问题本身做出限制以便生成更高效的解决方案。于是,React 在这一问题基础之上做出了两个大胆的假设:
1. 两个不同类型的元素会产生出不同的树;
2. 开发者可以通过 key 属性来标识哪些元素在不同的渲染下能保持稳定。
幸运的是,经过无数 Web 开发实践验证,React 官方团队发现上述两个假设在几乎所有实用的场景下都是成立的,证明了假设的合理性。除了以上两个假设之外,Web 实践开发还有一个规律为 React 实现高效的 Diff 提供了灵感:DOM 节点之间的跨层级操作并不多,同层级操作是主流。React 团队基于这 3 点提出了一套 O(n) 的启发式算法,这套启发式算法被大家称为 React Diff 算法。O(n^3) 到 O(n) 的提升有多大,我们通过一张图来看一下:
指数增长 🆚 线性增长
从上面这张图来看,React 的 Diff 算法所带来的提升无疑是巨大无比的。
那么,React 是如何基于这两个假设将 O(n^ 3) 转化为 O(n) 的呢?实质上,对 React Diff 逻辑的拆分和解读主要在于把握以下三个核心要点:
1. Diff 算法性能突破的关键点在于“分层对比”;
2. 类型一致的节点才有继续 Diff 的必要性;
3. key 属性的设置,可以帮我们尽可能重用同一层级内的节点。
这 3 个要点各自呼应着上文的 1 个规律 和 2 个假设,我们逐个来看。
分层对比
结合「 DOM 节点之间的跨层级操作并不多,同层级操作是主流 」这一规律,React 的 Diff 过程直接放弃了跨层级的节点比较,它只针对相同层级的节点做对比。
相同层级的节点对比如下图所示,比较的时候会一层一层的对相同颜色方框内的节点进行比较。当发现节点发生了变化时,只会处理该层的变化(比如将该节点及其子节点完全删除或重建),而不会去进一步的比较检查更深层的节点。这种策略大大减少了需要比较的节点数量,这是降低复杂度量级方面的一个最重要的设计,也是 React Diff 算法性能突破的关键点。
虽然说 Web UI 中 DOM 节点跨层级的移动操作少到可以忽略不计,但这并不是意味着 DOM 节点跨层级的操作的就不存在,那么当遇到这种操作时,React 是如何处理的呢?我们以一个简单的栗子 🌰 来说明:
对于这样的 DOM 结构转换来说,最高效的算法应该是直接将 A 子树移动到 D 节点。但基于分层比较这一原则,React 并不会出现我们想象中的通过移动操作来复用 A 节点及其子节点,它只能机械地认为 R 节点下的 A 子树消失了,因此会直接删除销毁对应子树,然后再在 D 节点下创建新的 A 节点及其子节点。销毁 + 重建的代价是昂贵的,因此 React 官方也建议开发者不要做跨层级的操作,尽量保持 DOM 结构的稳定性。
类型一致的节点才有继续 Diff 的必要性
React 认为,只有同类型的元素节点才有进一步对比的必要性;若参与 Diff 的两个节点类型不同,那么直接放弃比较,原地替换掉旧的节点,即销毁原节点,创建新节点。如下图所示:
当 D 节点改为 G 节点时,整棵 D 子树会被删掉,G、E、F 节点会重新创建。
只有确认节点类型相同后,React 才会在保留节点对应子树的基础上,尝试向更深层次去 Diff。这样一来,便能够从很大程度上减少 Diff 过程中冗余的操作。
key 属性的设置,可以帮我们尽可能重用同一层级内的节点
key 属性是用来解决同一层级下节点重用问题的。在展开分析之前,我们先结合到现在为止对 Diff 过程的理解,来思考这样一种情况,如下图所示:
这两排节点在新旧两个树中都位于同一层级,第一排是老树中的节点,第二排是新树中的节点
按照删除旧节点,创建新节点的策略,React 在进行新老同一层级的子节点对比过程中,发现新集合中的 B 不等于老集合中的 A,于是删除 A,创建 B,依此类推,直到删除 D,创建 C。
我们在举一个插入节点的 🌰 栗子:
两棵树的根节点保持类型和其他属性均不变的情况下,在根节点的 B 和 C 两个子节点之间插入了一个新的节点。按照已知的 Diff 原则,两棵树之间的 Diff 过程应该是这样的:
- 首先对比位于第 1 层的节点,发现两棵树的节点类型是一致的,于是进一步 Diff;
- 开始对比位于第 2 层的节点,前两个节点(A 和 B)比较下来发现没有任何变化,因此直接复用;
- 接着比较 C 这个位置,对比 C 和 X,发现前后的类型不一致,直接删掉 C 重建 X;
- 然后比较 D 这个位置,对比 D 和 C,发现前后的类型不一致,直接删掉 D 重建 C;
- 最后接受“比较”的是树 2 的 D 节点这个位置,这个位置在树 1 里是空的,也就是说树 2 的 D 是一个新增节点,所以新增一个 D。
你会发现,在这两个例子中其实很多节点都是相同的,但是由于位置发生变化,导致需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动就可以实现节点的高效复用。尤其是第二个例子,原本移动一下 C 和 D 的位置就可以轻松的把新节点插入 B 和 C 中,现在却又是删除又是重建地搞了半天,操作十分繁琐冗余。而且这个蠢操作和跨层级移动节点还不太一样,后者本来就属于低频操作,加以合理的最佳实践约束一下基本上可以完全规避掉;但例 2 这种插入节点的形式,可是实打实的高频操作,无论怎么躲也躲不过的。频繁增删节点必定拖垮性能,针对这一现象,React 提出了优化策略,对于处于同一层的一组子节点,开发者可通过设置 key 属性来作为节点的唯一标识,React 会使用这个 key 属性来维护各个节点在不同渲染过程中的稳定性,实现相同节点的重用。
key 属性的形式,我们肯定都不陌生。在基于数组动态生成节点时,我们一般都会给每个节点加装一个 key 属性,下面是一段代码示例:
const todoItems = todos.map((todo) =>
<li key={todo.id}>
{todo.text}
</li>
)
如果你忘记写 key,React 虽然不至于因此报错,但控制台标红是难免的,它会给我们抛出一个"请给列表元素补齐 key 属性"的 warning,这个常见的 warning 也从侧面反映出了 key 的重要性。事实上,当我们没有设定 key 值的时候,Diff 的过程就正如上文所描述的一样惨烈。但只要我们按照规范加装一个合适的 key,这个 key 就会像一个记号一样,帮助 React "记住"某一个节点,从而在后续的更新中实现对这个节点的追踪。
这是一个怎样的过程呢?当同一层级的某个节点添加了相对于其他同级节点唯一的 key 属性,当它在当前层级的位置发生了变化后。react diff 算法通过新旧节点比较后,如果发现了 key 值相同的新旧节点,就会执行移动操作,而不会执行原策略的删除旧节点,创建新节点的操作。我们还是以上述的例 2 来说明,如果我们给位于第 2 层的每一个子节点一个唯一的key 值,那么除了第三个节点是新创建外,第四和第五个节点都是通过移动实现的,这无疑大大提高了渲染效率。
5. 总结
为什么传统 Diff 算法的时间复杂度是 O(n^3),而 React 的 Diff 算法只需要 O(n) 呢?
这是因为 React 对树节点的比较做了很大的前提假设,限定死了很多东西,不做过于复杂的计算操作,所以降低了时间复杂度。而传统的树节点要做非常完整的检查,比如说比较不同级别的树状结构,在传统算法里是需要考虑的,而 React 假定所有的比较都在同级进行,这样当然就会使得计算复杂度大大降低。
Diff 逻辑三个要点:
- Diff 算法性能突破的关键点在于"分层对比"
- 类型一致的节点才有继续 Diff 的必要性
- key 属性的设置,可以帮我们尽可能重用同一层级内的节点