React Diff 算法详解【源码解析+案例解读】

react diff算法是react框架的核心算法,它最大的作用就是在应用更新的时候,找出新旧虚拟节点树的差异,最大程度的复用旧的节点信息,来减少真实的dom渲染,以此来提高框架的性能。

根据react的框架设计,整个渲染流程可以分为两个大的阶段:

  • render阶段:这个阶段由scheduler调度程序和Reconciler调和程序两个模块构成,主要内容是更新任务的调度以及FiberTree的构建。
  • commit阶段:根据创建完成的FiberTree,构建出真实的DOM内容渲染到页面。

diff算法正是位于Reconciler调和流程中【创建fiberTree】,对于diff算法来说最核心的作用就是:复用 。在创建fiber节点的过程中,最大程度的复用旧节点信息,复用之后删除可能剩下的多余旧节点,最后创建新增的节点。

一,函数组件更新

本章节主要分析react diff算法的具体逻辑,这里我们直接跳转到Reconciler调和流程中,开始深入diff算法。

首先,我们观察一个函数组件在更新阶段都会执行到的一个函数updateFunctionComponent

js 复制代码
// 更新函数组件
function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
      
  let context;
  let nextChildren;
  let hasId;

   # 1.重新渲染函数组件
   nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
    );
    hasId = checkDidRenderIdHook();
  }

  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  # 2.进入diff算法更新子节点
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

从这个函数名称我们就可以确定:这个函数的作用就是更新函数组件,它的内容主要可以分为两个模块:

  • 调用renderWithHooks方法重新渲染一次函数。
  • 调用reconcileChildren方法生成新的子节点。

在本章我们主要是来分析reactdiff算法,所以这里我们继续分析第二个模块内容即可。

注意:reconcileChildren是专门生成子节点的方法,在很多类型组件中都会执行。

1,reconcileChildren

查看reconcileChildren内容:

js 复制代码
// 子节点创建流程
export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // 加载阶段
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // 更新阶段
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

reconcileChildren方法的内容很简单,通过判断旧节点current是否有值来区分当前为加载阶段还是更新阶段:

  • 加载阶段:就会直接根据nextChildren内容来创建新的fiber子节点。
  • 更新阶段:就会根据旧节点的内容以及nextChildren内容来生成新的子节点。
js 复制代码
// 子节点调和
function ChildReconciler(shouldTrackSideEffects) {
    ...
    // 单节点diff
	function reconcileSingleElement() {}
    // 多节点diff
    function reconcileChildrenArray() {}
    // 调和生成子节点
    function reconcileChildFibers() {}
    
    return reconcileChildFibers;
}

// 更新子节点
export const reconcileChildFibers = ChildReconciler(true);
// 加载子节点
export const mountChildFibers = ChildReconciler(false);

需要注意的是:这两个函数其实是同一个方法ChildReconciler,唯一的区别传入的参数不同,在更新阶段可以追踪副作用。而这两个函数实际的内容就是reconcileChildFibers方法的内容。

2,reconcileChildFibers

这里我们继续查看reconcileChildFibers

js 复制代码
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {

// 子节点处理
if (typeof newChild === 'object' && newChild !== null) {
  # 1,单子节点处理
  switch (newChild.$$typeof) {
    // 默认的react元素对象类型:单节点处理
    case REACT_ELEMENT_TYPE:
      return placeSingleChild(
        reconcileSingleElement(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        ),
      );
          
    // 其他类型省略
	...
  }

  # 2,多子节点的处理,比如div.App下面有三个子节点,它的children为数组
  if (isArray(newChild)) {
    return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes,
    );
  }
}

// newChild还可能为一个文本节点类型
if (
  (typeof newChild === 'string' && newChild !== '') ||
  typeof newChild === 'number'
) {
  return placeSingleChild(
    reconcileSingleTextNode(
      returnFiber,
      currentFirstChild,
      '' + newChild,
      lanes,
    ),
  );
}

// Remaining cases are all treated as empty.
return deleteRemainingChildren(returnFiber, currentFirstChild);
}

注意这里的newChild参数,它就是表示新的子节点对应的reactElement对象,它一般有两种情况:

  • 单节点就是一个reactElement对象。
  • 多节点就是一个由reactElement对象组成的数组。
js 复制代码
// 单节点
newChild = { type: 'div', key: null, ref: null, props: {} } // 一个reactElement对象
// 多节点
newChild = [
    { type: 'div', key: null, ref: null, props: {} },
    { type: 'div', key: null, ref: null, props: {} },
    { type: 'div', key: null, ref: null, props: {} }
]

reactElement对象就是组件render之后的返回值【虚拟dom】,每次渲染都会将reactElement对象进一步转换为fiber节点。

接下来我们就分别来解析react的单节点diff和多节点diff流程。

二,单节点diff

newChild为一个reactElement对象时,就会进入reconcileSingleElement方法,执行单节点diff流程。

1,reconcileSingleElement

这里我们查看reconcileSingleElement方法:

js 复制代码
// 单子节点diff
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  // 取出最新的react元素对象的key
  const key = element.key;
  let child = currentFirstChild; // 旧的节点
  /**
   * 【更新阶段】
   * 单节点diff,为什么做成一个循环:
   * 因为新的内容只有一个节点,不代表旧的内容只有一个节点,这里可以分为两种情况:
   * 1. 新旧都只有一个节点,只执行一次循环的比较
   * 2. 旧的有多个节点,新的只有一个节点,就会循环旧的节点,依次和这个新节点进行key的比较,
   *    如果key相等,再比较type类型,比如是否都是div类型,
   *
   */
  while (child !== null) {
    // 1.key相等的情况下
    if (child.key === key) {
      // 取出最新的节点type:比如组件的type或者DOM节点的type
      const elementType = element.type;
      // 2,组件type也相等的情况下
      if (child.elementType === elementType) {
        // 在相等的情况下,给剩下的旧节点打上删除标记
        deleteRemainingChildren(returnFiber, child.sibling);
        // 复用当前旧的节点,生成新的fiber节点
        const existing = useFiber(child, element.props);
        // 设置初始的ref
        existing.ref = coerceRef(returnFiber, child, element);
        // 设置父级节点
        existing.return = returnFiber;
        // 返回新的节点
        return existing;
      }
      // Didn't match.
      // key相等但是type不等的情况下,给所有旧节点打上删除标记【比如组件由div变成span了】
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 3,key不相等的情况下,给当前旧的节点打上删除标记
      deleteChild(returnFiber, child);
    }
    // 取出旧的节点的兄弟节点,继续与新的节点进行比较【旧节点可能存在多个】
    child = child.sibling;
  }

  /**
   * 1. 加载阶段:直接创建新的fiber节点
   * 2. 更新阶段:循环匹配之后,没有匹配到相等的key,则直接使用element对象创建新的fiber节点
   */
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  // 设置新节点父级指针
  created.return = returnFiber;
  return created;
}

reconcileSingleElement方法里面的内容并不多,主体是一个while循环结构,可能大家会疑惑单节点diff为什么还会有循环,其实单节点与多节点的区分主要是看之前的newChild变量【即新节点的数据结构】,只要新的子节点是一个单独的reactElement对象,那它就属于单节点diff,之所以会出现循环是因为(当前层级)旧的fiber节点可能会有多个,比如原来有多个div元素,本次更新修改后就只剩下一个子节点,那它就会进入单节点diff

我们继续查看循环体的逻辑,它的循环条件是旧的child不为null,这里要说明的是,在更新阶段一定会存在旧的fiber节点,从当前旧的fiber节点开始,与当前新的子节点进行比较:

  • 如果key相等,则继续判断新旧节点的type类型是否相等。
    • 如果type也相等【比如都是div类型】,则可以复用当前旧的fiber节点信息和新的props,来生成新的fiber节点。最后将其他可能存在的剩余旧节点打上删除的标记,等待commit阶段执行删除逻辑。
    • 如果key相等但是type不相等【比如由div变成了span类型】,说明旧节点都无法复用,则直接给所有旧节点打上删除标记,同时跳出循环。
  • 如果key不相等,则直接给当前旧的fiber节点打上删除标记,同时更新child变量,继续比较下一个旧节点。

如果循环执行完成之后,没有匹配到相同的key,则直接调用createFiberFromElement方法,使用当前的reactElement对象来创建新的fiber节点。

总结 :到此,单节点diff逻辑就执行完成了,整体的内容是比较简单的,主要就是判断新旧节点的keytype是否同时相等,只有同时相等时才能复用旧的fiber节点,否则就只能创建一个新的fiber节点。

三,多节点diff

newChild是一个由多个reactElement对象构成的数组时,就会进入reconcileChildrenArray方法,执行多节点diff流程。

js 复制代码
// 多节点diff
if (isArray(newChild)) {
   return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes,
   );
}

1,reconcileChildrenArray

这里我们查看reconcileChildrenArray方法:

js 复制代码
// 多子节点diff
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {

  // 第一个新的子节点
  let resultingFirstChild: Fiber | null = null;
  // 上一个新建的节点
  let previousNewFiber: Fiber | null = null;

  // 当前参与diff比较的oldFiber
  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  // 下一个参与diff比较的oldFiber
  let nextOldFiber = null;

  # 第一次循环【循环数据为新的子节点element数组】
  // 循环结束条件:循环到新数据最后一个,并且期间对应索引的旧节点一直有值
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // 位置不匹配时,设置oldFiber为null,退出循环
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      // 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
      nextOldFiber = oldFiber.sibling;
    }
    /***
     * 
     * 生成新的fiber,【有可能复用current节点信息,创建对应的新的Fiber】【更新的内容主要是props】、
     * 但是和Bailout策略不同的是:那边是直接用的原props对象。
     * 这里是用的react元素对象生成的新的props对象。
     * 
     */
    // 通过比对key值,生成新的fiber子节点,如果没有匹配到则返回null
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx], // 对应的react元素对象
      lanes,
    );

    // 如果新子节点为null,说明本次匹配失败
    if (newFiber === null) {
      // TODO: This breaks on empty slots like null children. That's
      // unfortunate because it triggers the slow path all the time. We need
      // a better way to communicate whether this was a miss or null,
      // boolean, undefined, etc.
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      // 只要出现不匹配的情况,则直接退出循环
      break;
    }
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        // We matched the slot, but we didn't reuse the existing fiber, so we
        // need to delete the existing child.
        deleteChild(returnFiber, oldFiber);
      }
    }
    // 打上插入的标记
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      // TODO: Move out of the loop. This only happens for the first run.
      resultingFirstChild = newFiber;
    } else {
      // TODO: Defer siblings if we're not at the right index for this slot.
      // I.e. if we had null values before, then we want to defer this
      // for each null value. However, we also don't want to call updateSlot
      // with the previous one.
      previousNewFiber.sibling = newFiber;
    }
    // 本次匹配成功的情况下:继续下一轮匹配
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  /**
   * 第一次循环匹配之后的多种情况:
   */
  // 1. 如果索引等于新子节点数组的长度,说明已经匹配完成,可以复用对应的旧的fiber节点,并且删除旧的多余的节点
  // 这种情况是完全匹配,或者是执行了尾部删除,可以复用前面全部内容。
  if (newIdx === newChildren.length) {
    // We've reached the end of the new children. We can delete the rest.
    // 删除从当前旧节点剩下的可能存在的节点:打上删除标记
    deleteRemainingChildren(returnFiber, oldFiber);
    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }
    return resultingFirstChild;
  }

  // 2. 旧的节点利用完成,还存在新节点时
  if (oldFiber === null) {
    // If we don't have any more existing children we can choose a fast path
    // since the rest will all be insertions.
    // 多子节点的 创建:创建第一个的child,完成之后退出创建,返回resultingFirstChild结果为第一个child
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // previousNewFiber === null,表示为第一次循环:
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        // 则设置第一个新创建的有效的Fiber节点, 为结果child内容
        resultingFirstChild = newFiber;
      } else {
        // 否则表示不是第一次循环,将新建的节点设置为上一个节点的兄弟节点
        previousNewFiber.sibling = newFiber;
      }
      // 更新上一个新的节点
      previousNewFiber = newFiber;
    }

    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }

    // 退出当前函数,返回创建完成的第一个child
    return resultingFirstChild;
  }

  // Add all children to a key map for quick lookups.
  // 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找 【oldFiber是当前索引位置的旧的子节点】
  // 生成一个map结构【fiber.key为键,fiber为值】
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // Keep scanning and use the map to restore deleted items as moves.
  // 第二次循环:使用map查找
  for (; newIdx < newChildren.length; newIdx++) {
    // 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    // 如果新fiber存在【基本都是有值的】
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        // 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
        // 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
        if (newFiber.alternate !== null) {
          // The new fiber is a work in progress, but if there exists a
          // current, that means that we reused the fiber. We need to delete
          // it from the child list so that we don't add it to the deletion
          // list.
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
      // 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      /**
       * 更新变量,继续下一个节点的匹配
       */
      if (previousNewFiber === null) {
        // 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
        // 即需要将第一个成功创建的fiber设置为firstChild
        resultingFirstChild = newFiber;
      } else {
        // 一般情况:更新新子节点sibling属性
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  if (shouldTrackSideEffects) {
    // Any existing children that weren't consumed above were deleted. We need
    // to add them to the deletion list.
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

多节点diff算法的内容要比单节点多,不过也并不复杂,接下来我们对这部分逻辑逐个解析。

2,第一轮循环

js 复制代码
// 第一次循环【循环数据为新的子节点element数组】
// 循环结束条件:循环到新数据最后一个,或者对应索引的旧节点为null
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 位置不匹配时,设置oldFiber为null,退出循环
  if (oldFiber.index > newIdx) {
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    // 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
    nextOldFiber = oldFiber.sibling;
  }
  /***
   * 
   * 生成新的fiber,【有可能复用current节点信息,创建对应的新的Fiber】【更新的内容主要是props】、
   * 但是和Bailout策略不同的是:那边是直接用的原props对象。
   * 这里是用的react元素对象生成的新的props对象。
   * 
   */
  // 通过比对key值,生成新的fiber子节点,如果没有匹配到则返回null
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx], // 对应的react元素对象
    lanes,
  );

  // 如果新子节点为null,说明本次匹配失败
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    // 只要出现不匹配的情况,则直接退出循环
    break;
  }
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      deleteChild(returnFiber, oldFiber);
    }
  }
  // 打上插入的标记
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  // 本次匹配成功的情况下:继续下一轮匹配
  previousNewFiber = newFiber;
  oldFiber = nextOldFiber;
}

首先,要说明的几个变量:

  • oldFiber代表旧节点树中的一个节点,oldFiber.index则是该旧节点在其父节点下的索引位置。
  • nextOldFiber存储的是下一个要比较的旧节点。
  • newIdx是当前正在参与比较的新节点的索引。

这里我们查看第一轮循环处理:newIdx默认为0,表示第一个参与的diff的新节点,执行循环的条件有两个:

  • oldFiber不为null【当前参与diff的旧节点】。
  • newIdx小于新的子节点数组的长度。

只有同时满足这两个条件时,才能执行循环。

循环体内首先会执行一个判断条件:

js 复制代码
// diff是同位置比较,位置不匹配时,设置oldFiber为null,退出循环
if (oldFiber.index > newIdx) {
  nextOldFiber = oldFiber;
  oldFiber = null;
} else {
  // 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
  nextOldFiber = oldFiber.sibling;
}

oldFiberindex属性表示当前旧节点的位置,与newIdx进行对比:

  • react diff必须是同位置的比较,如果大于newIdx,说明位置不匹配,设置oldFibernull,退出循环。
  • 在位置相同的情况下,更新下一个参与比较的旧节点变量nextOldFiber的值。

然后调用updateSlot方法,开始创建新的fiber节点。

updateSlot

查看updateSlot方法:

js 复制代码
// 更新Fiber
function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  // Update the fiber if the keys match, otherwise return null.
  // 如果key匹配,则返回fiber,否则返回null

  // 取出旧节点的key
  const key = oldFiber !== null ? oldFiber.key : null;

  // 文本节点处理,略过
  if (
    (typeof newChild === 'string' && newChild !== '') ||
    typeof newChild === 'number'
  ) {
    if (key !== null) {
      return null;
    }
    return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
  }

  # 默认的子节点处理
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      // react元素对象,对应的Fiber
      case REACT_ELEMENT_TYPE: {
        // 比较相同索引位置的子节点,key值是否相等
        if (newChild.key === key) {
          // 1.key相等,type相等复用生成新的节点
 		  // 2.key相等,但是type不相等,直接创建新的节点
          return updateElement(returnFiber, oldFiber, newChild, lanes);
        } else {
          // 否则返回null
          return null;
        }
      }
	  
      // 其他类型...
    }

    if (isArray(newChild) || getIteratorFn(newChild)) {
      if (key !== null) {
        return null;
      }

      return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
    }
  }

  return null;
}

首先在oldFiber不为null时,取出旧的fiber节点的key,如果旧节点不存在则设置keynull

js 复制代码
// 取出旧节点的key
const key = oldFiber !== null ? oldFiber.key : null;

比如出现旧节点与新节点位置不匹配的时候,oldFiber可能会被设置为null

然后就是根据当前新节点newChild的值进行不同的处理,这里我们主要关注为对象类型的处理,因为大部分情况下newChild都是一个reactElement对象:

js 复制代码
if (newChild.key === key) {
  // 1.key相等,type相等复用生成新的节点
  // 2.key相等,但是type不相等,直接创建新的节点
  return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
  // key不相等,返回null
  return null;
}

判断相同位置 的新旧节点的key是否相等:

  • 如果key相等,则调用updateElement方法创建新的子节点。
  • 如果key不相等,则直接返回null

updateElement

我们我们再查看一下updateElement方法:

js 复制代码
function updateElement(
  returnFiber: Fiber,
  current: Fiber | null, // 旧的fiber
  element: ReactElement, // 新的react元素对象
  lanes: Lanes,
): Fiber {

  const elementType = element.type;
  // 更新阶段
  if (current !== null) {
    // 在key相等的条件下,如果组件类型也相等,比如都是div,则可以复用信息
    if (current.elementType === elementType) {
      // Move based on index
      // 可以复用的情况下:根据旧的fiber,以及新的reactElement对的props,生成新的fiber节点
      const existing = useFiber(current, element.props);
      existing.ref = coerceRef(returnFiber, current, element);
      existing.return = returnFiber;
      return existing;
    }
  }
  // Insert
  // 加载阶段:直接创建新的fiber
  // 更新阶段:节点类型type不同时
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

key相等时,再判断新旧节点的类型type是否相等,

  • 如果两个条件同时满足,则可以复用当前旧的fiber节点信息和新的props,来生成新的fiber节点。
  • 如果key相等,但是节点类型不相等时,则直接使用reactElement对象创建新的fiber节点。
js 复制代码
const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes);

我们再回到第一轮的循环中,所以updateSlot方法调用完成最终会有两种情况:

  • 返回新的子节点。
  • 返回null
js 复制代码
// 第一次循环【循环数据为新的子节点element数组】
// 循环结束条件:循环到新数据最后一个,并且期间对应索引的旧节点一直有值
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {

  ...
  
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx], // 对应的react元素对象
    lanes,
  );

  # 如果新子节点为null,说明本次key匹配失败
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    // 只要出现key不匹配的情况,则直接退出循环
    break;
  }
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      deleteChild(returnFiber, oldFiber);
    }
  }
  # key匹配的情况下,创建了新的子节点
  // 打上插入的标记
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  // 本次匹配成功的情况下:继续下一轮匹配
  previousNewFiber = newFiber;
  // 设置下一个比较的旧节点
  oldFiber = nextOldFiber;
}

我们接着看第一轮循环剩下的逻辑:

  • 如果新的子节点newFibernull,说明本次匹配失败,在相同的位置没有匹配到相同的key,执行break,结束第一轮的循环diff逻辑。
  • 如果newFiber有值,说明生成了新的子节点,然后调用placeChild方法,根据当前的索引信息确定 newFiber 应该被插入到父 Fiber 的子列表中的哪个位置【即会给当前的新节点打上一个插入标记】

然后更新previousNewFiberoldFiber变量的值,继续循环执行下一个节点的diff逻辑。

到此第一轮循环的逻辑就基本结束,我们继续查看剩下的逻辑。

3,第二轮处理

在第一轮循环比较完成之后,会存在以下几个情况,我们逐个分析。

情况一
js 复制代码
# 1. 如果索引等于新子节点数组的长度,说明已经匹配完成,可以复用对应的旧的fiber节点,并且删除旧的多余的节点
// 这种情况是完全匹配,或者是执行了尾部删除,可以复用前面全部内容。
if (newIdx === newChildren.length) {
  // We've reached the end of the new children. We can delete the rest.
  // 删除从当前旧节点剩下的可能存在的节点:打上删除标记
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

如果newIdx等于新子节点数组newChildren的长度,说明新的数据已经匹配完成,这时就可以从当前旧节点oldFiber开始,删除剩下的可能存在的旧节点,即给每个节点打上删除标记,最后返回第一个新的子节点,多节点diff执行完成。

业务场景:

  • 正常数据更新,子节点数量和位置都无变化。
  • 子节点执行了尾部删除操作。
情况二
js 复制代码
# 2. 旧的节点利用完成,还存在新节点时
if (oldFiber === null) {
  // If we don't have any more existing children we can choose a fast path
  // since the rest will all be insertions.
  // 多子节点的 创建:创建第一个的child,完成之后退出创建,返回resultingFirstChild结果为第一个child
  for (; newIdx < newChildren.length; newIdx++) {
    # 直接创建剩下的新节点
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    if (newFiber === null) {
      continue;
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // previousNewFiber === null,表示为第一次循环:
    if (previousNewFiber === null) {
      // TODO: Move out of the loop. This only happens for the first run.
      // 则设置第一个新创建的有效的Fiber节点, 为结果child内容
      resultingFirstChild = newFiber;
    } else {
      // 否则表示不是第一次循环,将新建的节点设置为上一个节点的兄弟节点
      previousNewFiber.sibling = newFiber;
    }
    // 更新上一个新的节点
    previousNewFiber = newFiber;
  }
  // 退出当前函数,返回创建完成的第一个child
  return resultingFirstChild;
}

出现oldFibernull的情况,并且没有满足情况一:说明旧的节点比新的子节点少,说明新的子节点出现了新增【一定是尾部新增,而不是中间插入】,此时旧的节点已经利用完成,而新的子节点还没有创建完成,所以开启一个循环,调用createChild方法来创建剩下的新节点,多节点diff执行完成。

业务场景:

  • 新子节点出现了尾部新增。
  • 渲染全新的列表数据。
情况三
js 复制代码
# 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找 
// 生成一个map结构【fiber.key为键,fiber为值】
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// Keep scanning and use the map to restore deleted items as moves.
// 第二次循环:使用map查找
for (; newIdx < newChildren.length; newIdx++) {
  // 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  // 如果新fiber存在【基本都是有值的】
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      // 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
      # 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
      if (newFiber.alternate !== null) {
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    /**
     * 更新变量,继续下一个节点的匹配
     */
    if (previousNewFiber === null) {
      // 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
      // 即需要将第一个成功创建的fiber设置为firstChild
      resultingFirstChild = newFiber;
    } else {
      // 一般情况:更新新子节点sibling属性
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

这种情况表示在diff过程中,出现了同位置节点的key不匹配的情况【位置移动】,旧节点和新节点都还存在未参与比较的数据。

这里会调用mapRemainingChildren方法,将剩下的旧节点添加到一个map结构中【key为键,fiber为值】,最后返回这个map结构赋值给变量existingChildren,然后再次开启一个循环结构,从当前的newIdx索引开始,处理剩下的新节点。

注意:如果旧节点不存在key,就会使用它的index索引值作为键。

updateFromMap

这里主要是调用一个updateFromMap方法,我们可以查看一下这个方法具体的逻辑:

js 复制代码
function updateFromMap(
  existingChildren: Map<string | number, Fiber>,
  returnFiber: Fiber,
  newIdx: number,
  newChild: any,
  lanes: Lanes,
): Fiber | null {

  // 文本节点处理
  if (
    (typeof newChild === 'string' && newChild !== '') ||
    typeof newChild === 'number'
  ) {
    const matchedFiber = existingChildren.get(newIdx) || null;
    return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
  }

  # 常规fiber节点处理
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {

      // react-element元素
      case REACT_ELEMENT_TYPE: {
        // 传递新子节点的key来查找,存在则返回旧的fiber,不存在则返回null
        const matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key,) || null;
        // 根据matchedFiber,来生成新的fiber节点
        return updateElement(returnFiber, matchedFiber, newChild, lanes);
      }
    }
  }

  return null;
}

这个方法的内容也比较简单,主要就是根据新节点的key,从当前这个map结构中找到匹配的旧节点【如果新节点没有key,则会使用当前的索引值newIdx】,然后将匹配到的旧节点传递到updateElement方法中来生成新的子节点。

这里会存在以下几种情况:

  • 没有找到匹配的旧节点,matchedFibernull,调用updateElement方法,直接使用newChild创建新的fiber节点。
  • 找到匹配的旧节点,matchedFiber为旧的fiber节点,调用updateElement方法,当前current有值,继续执行判断逻辑:
    • 如果新旧节点类型type也相等,则可以复用当前旧的fiber节点信息和新的props,来生成新的fiber节点。
    • 如果key相等,但是节点类型不相等时,则直接使用newChild对象创建新的fiber节点。
js 复制代码
# 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找
// 生成一个map结构【fiber.key为键,fiber为值】
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// Keep scanning and use the map to restore deleted items as moves.
// 第二次循环:使用map查找
for (; newIdx < newChildren.length; newIdx++) {
  // 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  // 如果新fiber存在【基本都是有值的】
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      // 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
      # 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
      if (newFiber.alternate !== null) {
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    /**
     * 更新变量,继续下一个节点的匹配
     */
    if (previousNewFiber === null) {
      // 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
      // 即需要将第一个成功创建的fiber设置为firstChild
      resultingFirstChild = newFiber;
    } else {
      // 一般情况:更新新子节点sibling属性
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

我们再回到之前的循环结构中,所以updateFromMap这个方法最终都会返回一个新节点【可能是复用生成的,也有是直接创建的】,

在创建完新节点之后,如果是通过复用生成的新节点:

js 复制代码
# 根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
if (newFiber.alternate !== null) {
    existingChildren.delete(
      newFiber.key === null ? newIdx : newFiber.key,
    );
 }

则需要从根据新节点的key或者index,从当前的map结构中删除已经使用过���键值对。

最后同样是更新变量,继续执行执行循环结构,创建剩下的新节点。

所以情况三的逻辑就是:在第一轮循环中出现了key不匹配的情况后,将剩下的旧节点添加到一个map结构中,进行map查找,查找到则复用旧节点来生成新节点,没有查找到则直接创建新的节点,直接循环结束,创建完成所有的新节点。

业务场景:

  • 中间插入新节点,出现key不匹配的情况。
  • 中间删除节点,出现key不匹配的情况。
  • 新节点数据发生了排序变化【位置移动】,出现key不匹配的情况。

出现这几种情况,就会存在剩下的新旧节点,来执行map查找,最后多节点diff执行完成。

四,案例

1,情况一

正常更新
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 正常数据更新
  function handleClick() {
    list.forEach(item => {
      if (item.name === '小明') {
        item.score = '85'
      }
    })
    setList([...list])
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

这里我们修改小明的成绩,会触发一次正常的更新和diff逻辑:因为本次数据无数量变化,无位置变化,所以第一轮循环会完美的匹配所有key,通过复用旧节点生成新的节点,然后满足情况一的判断条件,最后结束本轮多节点diff的逻辑。

尾部删除
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 尾部删除
  function handleClick() {
    list.pop()
    setList([...list])
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

这里我们删除小刚的成绩,会触发一次正常的更新和diff逻辑:第一轮循环会完美的匹配所有key,通过复用旧节点生成新的节点,然后满足情况一的判断条件,同时还存在剩下的旧节点【小刚】,所以需要对剩下的旧节点做一个删除操作【打上删除标记】,最后结束本轮多节点diff的逻辑。

2,情况二

尾部新增
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 尾部添加
  function handleClick() {
    list.push({name: '小强', score: '60'})
    setList([...list])
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

这里我们新增了小强的成绩,会触发一次正常的更新和diff逻辑:第一轮循环匹配时,因为新的节点数据变多了,旧的节点不够用,会出现oldFibernull的情况,这时候在完成第三次匹配之后,因为oldFibernull就会退出循环,此时只完成了三个新节点的创建,就会满足情况二的条件:

在这里重新安排一个循环,调用createChild方法直接创建剩下的新节点,最后结束本轮多节点diff的逻辑。

全新列表
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([])

  // 请求数据,渲染全新列表
  function handleClick() {
    setTimeout(() => {
      setList([
        {name: '小红', score: '90'},
        {name: '小明', score: '80'},
        {name: '小刚', score: '70'},
      ])
    }, 1000);
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

这里刚开始没有成绩单数据,模拟发起一个请求获取到数据后,渲染新的列表数据。在第一轮循环匹配时,不存在旧的节点数据【即oldFibernull】,所以第一轮循环不会执行。这里会直接来到情况二,安排一个新循环,,调用createChild方法直接创建全新的的子节点数据,最后结束本轮多节点diff的逻辑。

3,情况三

情况三的场景都可以归属为【位置移动】的变化,可以大致分为以下几类。

中间插入
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 中间插入
  function handleClick() {
    setList([
      {name: '小红', score: '90'},
      {name: '小明', score: '80'},
      {name: '小兰', score: '75'},
      {name: '小刚', score: '70'},
    ])
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

注意: 非尾部添加都属于中间插入【比如头部插入】,这里我们在中间新增一条小兰的成绩。在第一轮循环匹配时,循环到第三次出现了key不匹配的情况,因为在当前位置旧的节点key等于小刚,新的节点key等于小兰,当前newFiber会为null,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:

将剩下的旧节点【即小刚的数据】添加到一个map结构中,循环剩下的新节点数据,调用updateFromMap方法,根据新节点的keymap结构中进行查找,如果查找到相同的key则复用旧节点生成新节点,如果没有查找到则直接创建新的节点。

  • 第一次循环没有查找到key为小兰的旧节点,则会直接创建新的节点。
  • 第二次循环查找到了key为小刚的旧节点,则会复用旧节点来生成新的节点。

同时,如果是通过复用生成的新节点,在新节点创建完成之后,还会从当前map结构中删除已经使用过的键值对。

最后结束本轮多节点diff的逻辑。

中间删除
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 中间删除
  function handleClick() {
    const arr = list.filter(item => {
      return item.name !== '小明'
    })
    setList(arr)
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

注意: 非尾部删除都属于中间删除【比如头部删除】,这里我们在中间删除一条小明的成绩。在第一轮循环匹配时,循环到第二次出现了key不匹配的情况,因为在当前位置旧的节点key等于小明,新的节点key等于小刚,当前newFiber会为null,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:

将剩下的旧节点【即小明和小刚的数据】添加到一个map结构中,循环剩下的新节点数据【小刚的数据】,调用updateFromMap方法,根据新节点的keymap结构中进行查找,如果查找到相同的key则复用生成新节点,如果没有查找到则直接创建新的节点。

当前可以查找到小刚的旧节点数据,就可以直接复用生成新的节点,最后结束本轮多节点diff的逻辑。

位置移动
js 复制代码
export default function Index() {
  // 成绩单
  const [list, setList] = useState([
    {name: '小红', score: '90'},
    {name: '小明', score: '80'},
    {name: '小刚', score: '70'},
  ])

  // 位置移动:小明考了100分,变成了第一名
  function handleClick() {
    setList([
      {name: '小明', score: '100'},
      {name: '小红', score: '90'},
      {name: '小刚', score: '70'},
    ])
  }

  // 渲染成绩单
  const renderList = list.map(item => {
    return <div key={item.name}>{item.name + ': '+ item.score}</div>
  })

  return (
    <div className='Index'>
      <div>成绩单:</div>
      <div className='itemBox'>{renderList}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

小明考了100分,变成了第一名。在第一轮循环匹配时,循环在第一次就出现了key不匹配的情况,因为在当前位置旧的节点key等于小红,新的节点key等于小明,当前newFiber会为null,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:

当前的三个新节点都可以在map结构中找到对应的key,所以都可以进行复用,根据对应的旧fiber节点信息和新的props,来生成新的fiber节点:

最后结束本轮多节点diff的逻辑。

五,总结

react的diff算法可以分为两种情况:

  • 单节点diff
  • 多节点diff

单节点diff比较简单,主要就是判断新旧节点的keytype是否同时相等,只有同时相等时才能复用旧的fiber节点,否则就会直接创建一个新的fiber节点。

多节点diff可以分为两个阶段:

  • 第一轮循环进行同位置的比较,key相同则会复用生成新的节点,在循环中只要出现不匹配的key就会退出循环。
  • 第一轮循环执行完成后,会根据结果进行第二阶段的处理,可以分为三种情况:
    • 完全匹配:即第一轮循环已经处理完所有的新节点,此时删除可能存在的剩余旧节点,然后结束本轮多节点diff的逻辑。
    • 新增数据:即第一轮循环完成后,还存在未处理的新节点,则直接进行正常的创建,然后结束本轮多节点diff的逻辑。
    • 位置移动:即第一轮循环完成后,旧节点和新节点都还剩有未处理的数据,则将剩下的旧节点添加到一个map结构中【【key为键,fiber为值】】,根据新节点的key进行map查找【没有key,则使用索引值】,查找到则复用旧节点信息和新的props来生成新节点,没有查找到则直接创建新的节点,循环执行直到创建完成所有的新节点,然后结束本轮多节点diff的逻辑。

如果旧节点不存在key,就会使用它的index索引值作为键。

相关推荐
Martin -Tang20 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发21 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录1 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
爱吃生蚝的于勒1 小时前
C语言内存函数
c语言·开发语言·数据结构·c++·学习·算法
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css