从源码的角度来看react的diff算法

Diff算法

一、设计动机

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

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

如果在 React 中使用该算法,那么展示 1000 个元素则需要 10 亿次的比较。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:

  1. 两个不同类型的元素会产生出不同的树;
  2. 开发者可以通过设置 key 属性,来告知渲染哪些子元素在不同的渲染下可以保存不变;

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

为了降低算法复杂度,Reactdiff会预设三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。

  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。

    // 更新前
    <div>
      <p key="a">A</p>
      <h3 key="b">B</h3>
    </div>
    
    // 更新后
    <div>
      <h3 key="b">B</h3>
      <p key="a">A</p>
    </div>
    

    如果没有keyReact会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。这符合限制2的设定,会销毁并新建。

    但是当我们用key指明了节点前后对应关系后,React知道key === "a"p在更新后还存在,所以DOM节点可以复用,只是需要交换下顺序。

    这就是React为了应对算法性能瓶颈做出的三条限制。

二、Diff算法

为了防止概念混淆,这里再强调下

一个DOM节点在某一时刻最多会有4个节点和他相关。

  1. current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点
  2. workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点
  3. DOM节点本身。
  4. JSX对象。即ClassComponentrender方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。

Diff算法的本质是对比1和4,生成2。

我们从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // // ...省略其他case
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // ...省略
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

  // 一些其他情况调用处理函数
  // ...省略

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

我们可以从同级的节点数量将Diff分为两类:

  1. newChild类型为objectnumberstring,代表同级只有一个节点
  2. newChild类型为Array,同级有多个节点

1.单节点diff

对于单个节点,我们以类型object为例,会进入reconcileSingleElement

const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // ...其他case
    }
  }

这个函数会做如下的事情

在这里插入图片描述

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

从代码可以看出,React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。

这里有个细节需要关注下:

  • child !== nullkey相同type不同时执行deleteRemainingChildrenchild及其兄弟fiber都标记删除。
  • child !== nullkey不同时仅将child标记删除。

考虑如下例子:

当前页面有3个li,我们要全部删除,再插入一个p

// 当前页面显示的
ul > li * 3

// 这次需要更新的
ul > p

由于本次更新时只有一个p,属于单一节点的Diff,会走上面介绍的代码逻辑。

reconcileSingleElement中遍历之前的3个fiber(对应的DOM为3个li),寻找本次更新的p是否可以复用之前的3个fiber中某个的DOM

key相同type不同时,代表我们已经找到本次更新的p对应的上次的fiber,但是pli type不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber都没有机会了,所以都需要标记删除。

key不同时只代表遍历到的该fiber不能被p复用,后面还有兄弟fiber还没有遍历到。所以仅仅标记该fiber删除。

由此我们可以想到为什么react在同一层次下一定要保持key值的唯一性

2.多节点diff

在日常开发中,相较于新增删除更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新

Diff算法的整体逻辑会经历两轮遍历:

  • 第一轮遍历:处理更新的节点。

  • 第二轮遍历:处理剩下的不属于更新的节点。

第一轮遍历

第一轮遍历步骤如下:

  1. let i = 0,遍历newChildren,将newChildren[i]oldFiber比较,判断DOM节点是否可复用。
  2. 如果可复用,i++,继续比较newChildren[i]oldFiber.sibling,可以复用则继续遍历。
  3. 如果不可复用,分两种情况:
  • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。
  • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历
  1. 如果newChildren遍历完(即i === newChildren.length - 1)或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。

从这里我们可以看出保持同一层次下key值的稳定性是有多重要

第二轮遍历

第一轮遍历完我们有以下四种情况

  • newChildrenoldFiber同时遍历完

    那就是最理想的情况:只需在第一轮遍历进行组件更新 。此时Diff结束

  • newChildren没遍历完,oldFiber遍历完

    已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement

  • newChildren遍历完,oldFiber没遍历完

    意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion

  • newChildrenoldFiber都没遍历完

    这意味着有节点在这次更新中改变了位置。

    这是Diff算法最精髓也是最难懂的部分。接下来会重点讲解。

    处理移动的节点

    由于有节点改变了位置,所以不能再用位置索引i对比前后的节点,那么如何才能将同一个节点在两次更新中对应上呢?

    我们需要使用key

    为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。

    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
    

    接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber

    标记节点是否移动

    既然我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动是以什么为参照物?

    我们的参照物是:最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)。

    由于本次更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点最靠右的那个,即一定在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面。

    那么我们只需要比较遍历到的可复用节点在上次更新时是否也在lastPlacedIndex对应的oldFiber后面,就能知道两次更新中这两个节点的相对位置改变没有。

    我们用变量oldIndex表示遍历到的可复用节点oldFiber中的位置索引。如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。

    lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex

    单纯文字表达比较晦涩,这里我们提供两个Demo,你可以对照着理解。

    Demo1

    在Demo中我们简化下书写,每个字母代表一个节点,字母的值代表节点的key

    // 之前
    abcd
    
    // 之后
    acdb
    
    ===第一轮遍历开始===
    a(之后)vs a(之前)  
    key不变,可复用
    此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
    所以 lastPlacedIndex = 0;
    
    继续第一轮遍历...
    
    c(之后)vs b(之前)  
    key改变,不能复用,跳出第一轮遍历
    此时 lastPlacedIndex === 0;
    ===第一轮遍历结束===
    
    ===第二轮遍历开始===
    newChildren === cdb,没用完,不需要执行删除旧节点
    oldFiber === bcd,没用完,不需要执行插入新节点
    
    将剩余oldFiber(bcd)保存为map
    
    // 当前oldFiber:bcd
    // 当前newChildren:cdb
    
    继续遍历剩余newChildren
    
    key === c 在 oldFiber中存在
    const oldIndex = c(之前).index;
    此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
    比较 oldIndex 与 lastPlacedIndex;
    
    如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
    并将 lastPlacedIndex = oldIndex;
    如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动
    
    在例子中,oldIndex 2 > lastPlacedIndex 0,
    则 lastPlacedIndex = 2;
    c节点位置不变
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:bd
    // 当前newChildren:db
    
    key === d 在 oldFiber中存在
    const oldIndex = d(之前).index;
    oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
    则 lastPlacedIndex = 3;
    d节点位置不变
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:b
    // 当前newChildren:b
    
    key === b 在 oldFiber中存在
    const oldIndex = b(之前).index;
    oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
    则 b节点需要向右移动
    ===第二轮遍历结束===
    
    最终acd 3个节点都没有移动,b节点被标记为移动
    

    Demo2

    // 之前
    abcd
    
    // 之后
    dabc
    
    ===第一轮遍历开始===
    d(之后)vs a(之前)  
    key改变,不能复用,跳出遍历
    ===第一轮遍历结束===
    
    ===第二轮遍历开始===
    newChildren === dabc,没用完,不需要执行删除旧节点
    oldFiber === abcd,没用完,不需要执行插入新节点
    
    将剩余oldFiber(abcd)保存为map
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:abcd
    // 当前newChildren dabc
    
    key === d 在 oldFiber中存在
    const oldIndex = d(之前).index;
    此时 oldIndex === 3; // 之前节点为 abcd,所以d.index === 3
    比较 oldIndex 与 lastPlacedIndex;
    oldIndex 3 > lastPlacedIndex 0
    则 lastPlacedIndex = 3;
    d节点位置不变
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:abc
    // 当前newChildren abc
    
    key === a 在 oldFiber中存在
    const oldIndex = a(之前).index; // 之前节点为 abcd,所以a.index === 0
    此时 oldIndex === 0;
    比较 oldIndex 与 lastPlacedIndex;
    oldIndex 0 < lastPlacedIndex 3
    则 a节点需要向右移动
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:bc
    // 当前newChildren bc
    
    key === b 在 oldFiber中存在
    const oldIndex = b(之前).index; // 之前节点为 abcd,所以b.index === 1
    此时 oldIndex === 1;
    比较 oldIndex 与 lastPlacedIndex;
    oldIndex 1 < lastPlacedIndex 3
    则 b节点需要向右移动
    
    继续遍历剩余newChildren
    
    // 当前oldFiber:c
    // 当前newChildren c
    
    key === c 在 oldFiber中存在
    const oldIndex = c(之前).index; // 之前节点为 abcd,所以c.index === 2
    此时 oldIndex === 2;
    比较 oldIndex 与 lastPlacedIndex;
    oldIndex 2 < lastPlacedIndex 3
    则 c节点需要向右移动
    
    ===第二轮遍历结束===
    

    可以看到,我们以为从 abcd 变为 dabc,只需要将d移动到前面。

    但实际上React保持d不变,将abc分别移动到了d的后面。

    从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值