React Diff算法实现

165 阅读6分钟

Diff算法

其实diff算法的本质就是找出两个dom对象中的不同的地方,生成补丁包,根据补丁包给元素一层层递归打补丁,实现最终的视图更新。

回忆下我们上次提到了react diff策略预设的三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。

再来回忆一下元素结构 :

{
  $$typeof: Symbol(react.element), // 防止XSS注入
  key: null, // 快速删除更新
  ref: null,
  type: "span", // html标签,如封装的组件此值是一个对象类型
  props: {
    children: ["Hello World"]
  },
}

Diff的入口函数

接下来我们看看Diff的具体实现。我们从Diff的入口函数reconcileChildFibers出发

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber, // 返回的Fiber节点
  currentFirstChild: Fiber | null, // 上次更新的元素
  newChild: any, // 本次更新的JSX, 对应ClassComponent的this.render方法返回值,或者FunctionComponent执行的返回值
): Fiber | null {
      
  // isUnkeyedTopLevelFragment && newChild = newChild.props.children

  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 REACT_PORTAL_TYPE:
      // 调用 reconcileSinglePortal 处理
    }
  }

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

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

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

  // 其余的情况都被视为空节点,删除
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

不同类型节点的Diff具体实现

我们可将Diff分为两大类处理

  1. 同级只有一个节点 singleNode
  2. 同级有多个节点 childrenArray

一、单节点Diff

newChild类型为objectnumberstring,代表同级只有一个节点

我们以类型object为例,会进入reconcileSingleElement函数

其中第二步判断DOM节点是否可以复用,让我们通过代码看看是如何判断的:

// 协调单一节点的子fiber 创建fiber
function reconcileSingleElement(returnFiber, currentFirstChild, element, expirationTime) {
let child = currentFirstChild; // 旧节点
const key = element.key; // 新节点
// 非首次渲染
while (child !== null) {
  // 首先比较key
  if (child.key === key) {
    if (child.elementType === element.type) {
      // child type未改变,当前节点需要保留
      // 父级下应该只有这一个子节点,将该子节点的兄弟节点删除
      deleteRemainingChildren(returnFiber, child.sibling);
      // 使用child节点
      const existing = useFiber(child, element.props);
      existing.return = returnFiber;
      return existing;
    } else {
      // 节点的type改变,同时是单一节点,需要将父fiber下所有child标记为删除
      // 重新走创建新workInProgress的流程
      deleteRemainingChildren(returnFiber, child);
      break;
    }
  } else {
    deleteChild(returnFiber, child);
  }
  child = child.sibling;
}
// 首次渲染,创建Fiber节点
const created = createFiberFromElement(element, expirationTime);
created.return = returnFiber;
return created;
}

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

二:同级有多个元素的Diff (重点)

newChild类型为Array,同级有多个节点。

同一次同级多个元素的Diff,一共会有几种变化情况呢?

  1. 新增/删除节点
  2. 更新节点
  3. 位置变换

如果让大家针对这种情况设计一个Diff算法,会怎么处理呢?

  1. 可能是直接暴力的根据index遍历比较,相同保留,不同就替换?

  2. 也可能是用动态规划计算新旧两个节点变换所有情况的最小DOM操作次数?Min(新增,删除,替换)

    等等,我相信还有很多种可能。 第一种非常粗暴,第二种是假设所有操作的优先级是相同的。第二种方案也就是我们传统的diff算法的核心方案,下篇文章讲述。

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

值得注意的是,这里不能使用双指针从数组头和尾同时遍历以提高效率。虽然本次更新的JSX对象newChildren为数组形式,
但是和newChildren中每个值进行比较的是上次更新的Fiber节点,Fiber节点的同级节点是由sibling指针链接形成的链表。

即 newChildren[0]与oldFiber比较,newChildren[1]与oldFiber.sibling比较。

单链表无法使用双指针,所以无法对算法使用双指针优化。

基于以上原因,Diff算法的整体逻辑会经历两轮遍历。

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

第二轮遍历:处理剩下的非更新的节点(移动,新增和删除)。
第一轮遍历

第一轮遍历流程图如下:

第二轮遍历

当我们最终完成第一轮遍历后,会有三种结果:

  if (newIdx === newChildren.length) {
    // 1. 遍历完所有newChildren,删除剩余的oldFiber
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  if (oldFiber === null) {
    // 2. 遍历完所有oldFiber, 插入剩余的newChildren
    for (; newIdx < newChildren.length; newIdx++) {
      createChild(returnFiber, newChildren[newIdx], lanes);
    }
    return resultingFirstChild;
  }
  1. newChildoldFiber都没遍历完,这意味着有节点在这次更新中改变了位置。
处理位移的节点

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

你一定想到了,我们需要用key属性了。

首先:将所有子元素添加到键映射以便快速查找。

  // 为了快速的找到key对应的oldFiber,mapRemainingChildren 将所有还没处理的oldFiber放进以key属性为key,Fiber为value的map。
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

接着:我们遍历剩余的newChild,寻找移动的节点并标记。

 // 按照newChild的节点顺序遍历,然后通过map快速更新,用移动节点替代删除节点
  for (; newIdx < newChildren.length; newIdx++) {
    // 通过newChild.key 找到existingChildren中旧节点, 执行比较更新
    const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
    // case1:key匹配,type匹配,newFiber复用oldFilber
    // case2:key匹配,且除type不匹配外都匹配,newFiber复用oldFilber,将type更新为newChildren[newIdx].type
    // case3: key匹配,type不匹配,表示key匹配,type不匹配且其它属性存在不匹配;不复用oldFilber,重新创建newFiber,
    // newFiber.alternate === null
    // case4:key不匹配, 找不到对应oldFiber,创建newFiber,newFiber.alternate === null 
      
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // newFiber在更新列表中,所以我们删除existingChildren中对应的oldFiber
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
    }
      
    // 其它逻辑
  }

最后:删除剩余没有使用到的节点

  if (shouldTrackSideEffects) {
    // 删除上面没有使用到的子节点并添加到删除列表
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }
lastPlacedIndex
由于本次更新中节点是按`newChildren`的顺序排列。在遍历`newChildren`过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用
节点中最靠右的那个,即一定在`lastPlacedIndex`对应的可复用的节点在本次更新中位置的后面。

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

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

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

练习

我们再通过两个例子来具体学习下移动过程

例1:acdb(new) 更新 abcd (old)

===第一轮遍历开始===
a(new)vs a(old)  
key不变,可复用,更新a
此时 a 对应的oldFiber(old的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(new)vs b(old)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===
    
===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(old).index;
即 oldIndex 代表当前可复用节点(c)在上一次更新时的位置索引
此时 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(old).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(old).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

例2:dabc(new) 更新 abcd(old)

===第一轮遍历开始===
d(new)vs a(old)  
key改变,不能复用,跳出遍历
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === dabc,没用完,不需要执行删除旧节点
oldFiber === abcd,没用完,不需要执行插入新节点

将剩余oldFiber(abcd)保存为map

继续遍历剩余newChildren

// 当前oldFiber:abcd
// 当前newChildren dabc

key === d 在 oldFiber中存在
const oldIndex = d(old).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(old).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(old).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(old).index; // 之前节点为 abcd,所以c.index === 2
此时 oldIndex === 2;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 2 < lastPlacedIndex 3
则 c节点需要向右移动

===第二轮遍历结束===

本文参考《React技术揭秘》和 React v16.8.6

最后跪求好看的markdown code高亮主题