react diff算法

前言

经常我们在各类文章中都看到react将diff的复杂度从O(n^3)优化为了O(n),那么传统的O(n^3)到底是如何得到的呢?react又是如何将其优化为了O(n)呢?

传统diff算法

传统的diff算法计算一棵树变成另一棵所需要的最少步骤的复杂度是O(n^3),如何得到?

  • 旧数上的一个节点,它要跟新树上的所有节点对比(复杂度为O(n))
  • 如果这个节点在新树上没有找到,那么这个节点将被删除,同时会从新树中遍历找几个节点去填补(复杂度增加到O(n^2))
  • 旧树上的所有节点都会走这个过程(复杂度增加到O(n^3))
// 伪代码
const oldNodes = ['a', 'b', 'c', 'd', 'e'];
const newNodes = ['a', 'c', 'f', 'd', 'e'];
for (let i = 0; i< oldNodes.length; i++) {             // 复杂度O(n)
    for (let j = 0; j< newNodes.length; j++) {         // 复杂度O(n^2)
        // 旧节点在新节点中比对
        if(未找到){
            for (let k = 0; k< newNodes.length; k++) { // 复杂度O(n^3)
                // 寻找填补项
            }
        } 
    }
}

有一篇论文专门讲述diff算法:diff论文


React diff

react diff 制定的三个策略

  • Web UI中的DOM节点跨层级的移动特别少,可以忽略不计(tree diff);
  • 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构(component diff);
  • 对于同一层级的一组子节点,他们可以通过唯一的key进行区分(element diff);

tree diff

基于策略一,react对树的算法进行分层比较优化,两颗树只会对处在同一层级的节点进行比较。

image.png

按照策略一忽略了跨层级之间的移动操作,react tree diff只会对相同颜色内的DOM节点进行比对,当发现某一层级的节点已经不存在了,则该节点及其所有子节点都会被完全删除掉,不会进行进一步的比对,这样只需要对树遍历一次便能完成整个DOM树的比较,复杂度变为了O(n)。

根据上面的规则,在开发组件时,保持稳定的DOM结构会有助于性能的提升。例如,可以通过visibility属性控制元素的显示与隐藏,而不是display。

component diff

  • 组件类型没有发生变化,继续比较Virtual DOM tree;
  • 组件类型发生变化,则将该组件视为dirty component,并重新创建新的组件,包括所有的子节点,替换该组件;
  • 对于同一类型的组件,有可能Virtual DOM并不会有任何变化,那么可以通过shouldComponentUpdate来判断组件是否需要进行diff;

image.png

如上图中组件D变更为组件G时,虽然他们的结构很相似,但是因为组件类型不同,react diff会重新创建一个新的组件G来替换整个组件D,并且E、F也会重新创建,不会复用组件D的。

element diff

当节点处于同一层级时,react提供了三种节点操作方式:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)、REMOVE_NODE(删除)。

  • INSERT_MARKUP,新节点在不原集合中,需要对新节点执行插入操作;
  • MOVE_EXISTING,新老集合中都有某一节点,但是所处位置不同,可以复用老节点,需要对节点进行移动操作;
  • REMOVE_NODE,老集合中有节点在新集合中不存在,或者某一属性不一致,则需要执行删除操作;
针对element diff react提供了一个优化策略,同一层级的同组子节点,添加唯一key进行区分,这也是节点能否复用的前提。

无key示例

image.png

老集合中包含节点:A、B、C、D,更新后的新集合中包含节点:B、A、D、C,此时新老集合进行 diff 差异化对比,发现 B != A,则创建并插入 B 至新集合,删除老集合 A;以此类推,创建并插入 A、D 和 C,删除 B、C 和 D。

有key示例

image.png

下面我们看看具体的diff流程
lastIndex表示访问过的节点在老集合中的最大索引位置,这是一个初始化为0的动态值。
  • 遍历到B,B不做移动,lastindex = 1
  • 遍历到E,发现旧集合中不存在,则创建E并放在新集合对应的位置,lastindex = 1
  • 遍历到C,不满足index < lastindex,C不动,lastindex = 2
  • 遍历到A,满足index < lastindex,A移动到对应位置,lastindex = 2
  • 当完成新集合中所有节点 diff 时,最后还需要对老集合进行循环遍历,判断是否存在新集合中没有但老集合中仍存在的节点,发现存在这样的节点 D,因此删除节点 D,到此 diff 全部完成

diff算法的不足

image.png

因为D节点在老集合里面的index 是最大的,使得A、B、C三个节点都会 index < lastindex,从而导致A、B、C都会去做移动操作。另外这只是极端情况,现实的场景中包含很多类似的情况。可以考虑倒着循环。

react diff算法的复杂度为什么是O(n)

原因在于React采用了三个策略,限制了很多情况,不会做过于复杂的计算。所有的比较都在同级进行,只需要一次循环就能完成所有的操作。

参考文章

react diff的key值用数组的索引会有什么问题?

react 官方解释

我们强烈推荐,每次只要你构建动态列表的时候,都要指定一个合适的 key。如果你没有找到一个合适的 key,那么你就需要考虑重新整理你的数据结构了,这样才能有合适的 key。

如果你没有指定任何 key,React 会发出警告,并且会把数组的索引当作默认的 key。但是如果想要对列表进行重新排序、新增、删除操作时,把数组索引作为 key 是有问题的。显式地使用 key={i} 来指定 key 确实会消除警告,但是仍然和数组索引存在同样的问题,所以大多数情况下最好不要这么做。

会带来什么问题?

当用数组的下标作为key值时,会导致显示错位问题。在对数组进行删除,插入时,由于数组的下标是动态的,删除、插入最终都会体现在末尾元素。

2 声望
2 粉丝
0 条评论
推荐阅读
正则原理剖析
回溯法也称试探法,它的基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继...

闲人阅读 560

ESlint + Stylelint + VSCode自动格式化代码(2023)
安装插件 ESLint,然后 File -&gt; Preference-&gt; Settings(如果装了中文插件包应该是 文件 -&gt; 选项 -&gt; 设置),搜索 eslint,点击 Edit in setting.json

谭光志34阅读 20.7k评论 9

涨姿势了,有意思的气泡 Loading 效果
今日,群友提问,如何实现这么一个 Loading 效果:这个确实有点意思,但是这是 CSS 能够完成的?没错,这个效果中的核心气泡效果,其实借助 CSS 中的滤镜,能够比较轻松的实现,就是所需的元素可能多点。参考我们...

chokcoco24阅读 2.2k评论 3

你可能不需要JS!CSS实现一个计时器
CSS现在可不仅仅只是改一个颜色这么简单,还可以做很多交互,比如做一个功能齐全的计时器?样式上并不复杂,主要是几个交互的地方数字时钟的变化开始、暂停操作重置操作如何仅使用 CSS 来实现这样的功能呢?一起...

XboxYan25阅读 1.7k评论 1

封面图
在前端使用 JS 进行分类汇总
最近遇到一些同学在问 JS 中进行数据统计的问题。虽然数据统计一般会在数据库中进行,但是后端遇到需要使用程序来进行统计的情况也非常多。.NET 就为了对内存数据和数据库数据进行统一地数据处理,发明了 LINQ (L...

边城17阅读 2k

封面图
【代码鉴赏】简单优雅的JavaScript代码片段(一):异步控制
Promise.race不满足需求,因为如果有一个Promise率先reject,结果Promise也会立即reject;Promise.all也不满足需求,因为它会等待所有Promise,并且要求所有Promise都成功resolve。

csRyan26阅读 3.3k评论 1

「彻底弄懂」this全面解析
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在 哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在 函数执行的过程中用到...

wuwhs17阅读 2.4k

封面图
2 声望
2 粉丝
宣传栏