详细讲讲 React Diff 吧!!

334 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 4 天,点击查看活动详情

Diff

diff 算法 是为了更高效批量处理 增、删、改。

源码分析

React 17 DOM DIFF 中,React 通过对比 老的Fiber链表新的JSX数组,生成新的Fiber链表的过程。其中分为单节点多节点中的 diff是不一样的。

优化原则

  • 只对同级节点进行对比,如果DOM节点跨层级移动,则React不会复用。
  • 不同类型的元素会产出不同的结构,会销毁老结构,创建新的结构。
  • 可以通过key「自定义或系统自动分配 null 」标识移动元素。

源码的起点在render阶段beginWork 创建节点的时候将会调用下面的方法。

// workInProgress.child  得到新创建fiber节点
// current 正在渲染的列表
// workInProgress 正在工作的fiber节点
// nextChildren: 虚拟dom
export function reconcileChildren( current: Fiber | null, workInProgress: Fiber,nextChildren: any,renderLanes: Lanes) {
  if (current === null) {
     // 若 current 为 null,则进入挂载的逻辑
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren,renderLanes );
  } else {
    // 若 current 不为 null,则进入调和的逻辑
    workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderLanes );
  }
}
​
​
// 它们两个其实非常相似,只是传入的参数不同 
// ChildReconciler 的返回值是一个名为 reconcileChildFibers 的函数,
// 这个函数是一个逻辑分发器,它将根据入参的不同,执行不同的 Fiber 节点操作,最终返回不同的目标 Fiber 节点。
// 更新阶段 reconcileChildFibers 需要调和 fiber 节点
// 挂载阶段 mountChildFibers,不需要调和
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
​
// shouldTrackSideEffects 由上得知,更新阶段为true,挂载阶段为false
function ChildReconciler(shouldTrackSideEffects) {
  ....
  // 这个方法是一个闭包,暴露出去给外面用的,所有的流程在这里开启
  function reconcileChildFibers(
    returnFiber: Fiber, // currentFirstChild的父级fiber节点 
    currentFirstChild: Fiber | null, // returnFiber 的第一个自己节点
    newChild: any, // 对应的虚拟dom元素
    lanes: Lanes, // 
  ): Fiber | null {
​
    // Handle object types,true
    const isObject = typeof newChild === 'object' && newChild !== null;
  
    // 处理单个节点
    if (isObject) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: // symbolFor('react.element')
        // reconcileSingleElement返回一个fiber节点,给fiber节点打上 Placement 标签 代表新增
          return placeSingleChild(reconcileSingleElement( returnFiber, currentFirstChild, newChild, lanes ));
      }
    }
    // 处理文本节点
    if (typeof newChild === 'string' || typeof newChild === 'number') {
      return placeSingleChild(reconcileSingleTextNode(returnFiber,currentFirstChild,'' + newChild,lanes));
    }
    // 处理多个节点
    if (isArray(newChild)) {
      return reconcileChildrenArray(returnFiber,currentFirstChild,newChild,lanes);
    }
    
    // 对于这种情况之一是,所以的节点新节点是空标签,所以需要删除剩余的子元素
    return deleteRemainingChildren(returnFiber, currentFirstChild);
  }
​
  return reconcileChildFibers;
}

单节点

新的子节点只有一个元素,这种情况称为单节点。

当 type 和 key 都相同 才能复用,其他情况都不能复用。

流程图

image.png

源码解析

function reconcileSingleElement( returnFiber: Fiber,  currentFirstChild: Fiber | null, element: ReactElement, lanes: Lanes, ): Fiber {
  // returnFiber: currentFirstChild 的父节点
  // currentFirstChild:returnFiber 子节点
  // element: dom 元素
  // 元素的key
  const key = element.key; 
  let child = currentFirstChild;
  while (child !== null) {
    // TODO: If key === null and child.key === null, then this only applies to the first item in the list.
    if (child.key === key) {
      switch (child.tag) {
        ...
        default: {
          if (child.elementType === element.type) {
            // 删除旧fiber
            deleteRemainingChildren(returnFiber, child.sibling);
            // 利用旧fiber信息,和虚拟dom的 props信息创建新的fiber节点
            const existing = useFiber(child, element.props);
            existing.ref = coerceRef(returnFiber, child, element);
            existing.return = returnFiber;
            return existing;
          }
          break;
        }
      }
      // Didn't match.
      // 这是没有匹配到删除其余节点
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 给节点打上 Deletion 标记
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  // 到了这里证明 没有匹配到节点,那就只能新增了,因为上面已经做了删除的操作
  // 得到createFiberFromElement里面创建的fiber节点
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  // ref
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  // 新创建的子节点指向父亲
  created.return = returnFiber;
  // 将新创建出来的元素,返回出去。
  return created;
}

多节点

新的元素拥有多个节点,比如列表这种情况被称为多节点。

多节点比较复杂,所以需要2轮遍历,是 O(n),拼接起来继续执行的

第一轮遍历主要是处理节点的更新「属性,类型的更新」,一旦出现删除、移动,break

第二轮遍历主要处理节点的新增、删除、移动

注意:

  • 第一轮这么设计的原因是因为场景出现比较多,第二轮场景相比较少,是一种策略优化

  • 移动元素时为了尽量少的移动,当 oldIndex < lastPlacedIndex 该复用老元素需要移动,它是变动的

    • 旧:A — B ,新: B — A,这种清空React采用的是 移动 A,此时 lastPlacedIndex 为 0,B 自身 index 为 1,所以移动A

流程图

image.png

源码分析

// 多个节点的diff
// returnFiber:currentFirstChild 的父级fiber节点
// currentFirstChild:returnFiber 的第一个子节点
// newChildren:虚拟dom
// lanes:优先级相关  
function reconcileChildrenArray(returnFiber: Fiber,currentFirstChild: Fiber | null,newChildren: Array<*>,lanes: Lanes): Fiber | null {
  if (__DEV__) {
    // key 如果没有设置的话,默认就是null,通过 Set api来判断是否重复
    let knownKeys = null;
    for (let i = 0; i < newChildren.length; i++) {
      const child = newChildren[i];
      knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
    }
  }
  // diff 后的新 fiber 链表
  let resultingFirstChild: Fiber | null = null;
  // 用来将后续的新fiber拼接到第一个fiber之后
  let previousNewFiber: Fiber | null = null;
​
  // oldFiber节点,新的child节点会和它进行比较
  let oldFiber = currentFirstChild;
  // 存储固定节点的位置,用于移动元素
  let lastPlacedIndex = 0;
  // 存储遍历到新节点虚拟dom的位置,
  let newIdx = 0;
  // 记录目前遍历到的oldFiber的下一个节点
  let nextOldFiber = null;
  // 第一轮循环:处理节点的更新,判读节点是否可以复用,当出现不能复用的情况就中断循环
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // oldFiber.index:兄弟排行第几,如果排行大于 newIdx,这种情况说顺序变了,那么就会中断循环。
    if (oldFiber.index > newIdx) { 
      // 更新 nextOldFiber,
      nextOldFiber = oldFiber;
      // 置空oldFiber
      oldFiber = null;
    } else {
      // 赋值给下一个fiber 节点
      nextOldFiber = oldFiber.sibling;
    }
    // 生成新的节点,判断key与tag是否相同就在updateSlot中
    // 对DOM类型的元素来说,key 和 tag都相同才会复用oldFiber
    // 并返回出去,否则返回null
    const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes);
    // newFiber为 null说明 key 或 tag 不同,节点不可复用,中断遍历
    if (newFiber === null) {
      if (oldFiber === null) {
        // 重新设置oldFiber,这样的,就可以呼应前面 oldFiber 设置为null,重新将 oldFiber 的值修改回去。
        // 因为这样的话 updateSlot 创建出来的 newFiber 一定为null
        oldFiber = nextOldFiber;
      }
      break;
    }
    // shouldTrackSideEffects 为true表示是更新过程
    if (shouldTrackSideEffects) {
      // newFiber.alternate === null 说明newFiber是第一次创建的,还没有alternate
      // 但是这里有oldFiber,所以需要删除,我觉得这个无关紧要不影响我们理解整个过程
      if (oldFiber && newFiber.alternate === null) {
        deleteChild(returnFiber, oldFiber);
      }
    }
    // 记录固定节点的位置,为需要移动的元素打上 Placement 标记,不需要移动返回新的lastPlacedIndex
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // 更新previousNewFiber,这些已经处理过的 fiber节点,第二轮循环从这里开始
    if (previousNewFiber === null) {
      // 第一个节点,用户返回
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }
  // 至此第一轮循环结束
  
​
  // 如果新的虚拟dom已经遍历完了,剩下的就是删除的逻辑
  if (newIdx === newChildren.length) {
    // We've reached the end of the new children. We can delete the rest.
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }
​
  // 旧的fiber 节点没有了,循环新的虚拟dom,做插入操作
  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.
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }
​
  // 为第二轮循环做准备,将第一轮循环没有处理老的节点,添加到 Map 中,这样可以更加方便使用
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
​
  for (; newIdx < newChildren.length; newIdx++) {
    // 根据 老fiber节点 和 新的虚拟dom 判断Map是否存在元素,创建新的fiber节点,
    // 这个创建可能是复用的,也可能是新建的
    const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        // newFiber 如果是走复用逻辑的话,newFiber的alternate不为空,则说明newFiber不是新增的。
        // 也就说明着它是基于map中的oldFiber节点新建的,意味着oldFiber已经被复用了,所以需
        // 要从map中删去oldFiber
        if (newFiber.alternate !== null) {
          existingChildren.delete( newFiber.key === null ? newIdx : newFiber.key );
        }
      }
      // 更新移动的位置
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 更新fiber链表
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        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;
}

实例

基于上面的源码理解仍然存在不是很清晰,通过实例来演示。

老:
<ul>
  <li key="A">A</li >
  <li key="B">B</li> 
  <li key="C">c</li>
  <li key="D">D</li>
  <li key="E">E</li>
  <li key="F">F</li>
</ul>
​
新:
<ul>
  <li key="A">A-new</ li>
  <li key="C">C-new</ li>
  <li key="E">E-new</li>
  <li key="B">B-new</li> 
  <li key="G">G</li>
</ul>
​
第一轮循环:A 是可以复用的,更新, 接着到C的时候发现不匹配,此时 lastPlaceIndex为0,跳出循环。
第二轮循环: 建立一个 map = { B,C,D,E,F },key就是元素的key,值是老的fiber节点
继续遍历新的节点,
  C节点去map里找,找到了,则表示位置变了,元素可以复用,通过lastPlaceIndex = 0,oldIndex < lastPlacedIndex,旧C 的index > lastPlaceIndex「2 > 0」,所以C不用动,直接更新就可以了,然后 lastPlaceIndex 更新为2。
  到E同样的操作,通过 lastPlaceIndex更新为4。
  到了B,发现 1 < 4,则B是需要移动的,移动到新的JSX的最后面
  到了G,标记为新增
  到这里 map = { D,F },表示D,F没有用到标记为删除
​
到这里后,新的 fiber链表,已经生成完毕。
​
最后在commit阶段将新fiber,同步更新。
​
插入 = Placement = 2 0b0000000010「0b 182进制数」
更新 = Update = 4 
新增 = PlacementAndUpdate = 6
删除 = Deletion = 8

image.png

commit 阶段 到底如何处理这些 flag?

mutation 阶段, commitMutationEffects 方法有如下逻辑,commitPlacementcommitWorkcommitDeletion 这些方法都是操作dom的方法即是增、删、更新。

switch (primaryFlags) {
  case Placement: {
    // 插入节点
    // getHostSibling是一个费时的操作,原因是因为,workInProgress树和dom树层级有可能不一致的,所以就会有跨级的操作,就会有指数        级别的费时操作。
    commitPlacement(nextEffect);
    nextEffect.flags &= ~Placement;
    break;
  }
  case PlacementAndUpdate: {
    commitPlacement(nextEffect);
    nextEffect.flags &= ~Placement;
    const current = nextEffect.alternate;
    commitWork(current, nextEffect);
    break;
  }
  case Update: {
    const current = nextEffect.alternate;
    commitWork(current, nextEffect);
    break;
  }
  case Deletion: {
    commitDeletion(root, nextEffect, renderPriorityLevel);
    break;
  }
}

各位看官如遇上不理解的地方,或者我文章有不足、错误的地方,欢迎在评论区指出,感谢阅读。