剖析React系列八-同级节点diff

2,789 阅读5分钟

本系列是讲述从0开始实现一个react18的基本版本。通过实现一个基本版本,让大家深入了解React内部机制。

由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

仓库地址

具体章节代码commit

系列文章:

  1. React实现系列一 - jsx
  2. 剖析React系列二-reconciler
  3. 剖析React系列三-打标记
  4. 剖析React系列四-commit
  5. 剖析React系列五-update流程
  6. 剖析React系列六-dispatch update流程
  7. 剖析React系列七-事件系统
  8. 剖析React系列八-同级节点diff

之前的章节我们只实现了对单一节点的增/删操作,即单节点diff算法,例如A1 -> B1。 本节主要讲述多个节点之间的diff以及是如何渲染到界面的。注意

「单/多节点」是指「更新后是单/多节点」

调和child

同级单一节点

我们从之前的章节中得知,ChildReconciler在调和的过程中,主要是对比当前的fiberNodeReactElement

单一节点是针对更新后的节点,例如如下操作:

  • A1 -> B1
  • A1 -> A2

上面的情况我们之前已经实现过了。现在需要拓展支持如下的情况。之前是多节点,更新之后是单节点。主要也是通过keytype的情况,看看能不能复用,对于不能复用的节点我们需要标记删除操作。

  • ABC -> A (a ? [A, B, C] : A

currentFiber为多个的时候,我们就需要遍历(while),通过sibling指向,找到能复用的fiberNode,如果都不能复用就标记删除,并新建。 我们以reconcileSingleElement为例:

  • 如果key相同的情况下,如果type不相同的话,由于key的唯一性,所以之后的元素都不能复用,都标记删除。
  • 如果key不相同的话,标记本节点删除,继续标记下一个节点。
// react-reconciler childFiber.ts
function reconcileSingleElement(
  returnFiber: FiberNode,
  currentFiber: FiberNode | null,
  element: ReactElementType
) {
  const key = element.key;
  while (currentFiber !== null) {
    // key相同
    if (currentFiber.key === key) {
      // 是react元素
      if (element.$$typeof === REACT_ELEMENT_TYPE) {
        // type相同
        if (currentFiber.type === element.type) {
          const existing = useFiber(currentFiber, element.props);
          existing.return = returnFiber;
          // 当前节点可以复用,需要标记剩下节点
          deleteRemainingChildren(returnFiber, currentFiber.sibling);
          return existing;
        }
        // 删除旧的 (key相同,type不同) 删除所有旧的
        deleteRemainingChildren(returnFiber, currentFiber);
        break;
      }
    } else {
      // key 不同
      deleteChild(returnFiber, currentFiber);
      currentFiber = currentFiber.sibling;
    }
  }

  // 根据element 创建fiber
  const fiber = createFiberFromElement(element);
  fiber.return = returnFiber;
  return fiber;
}

其中deleteRemainingChildren就是用来标记需要删除的fiberNode以及相邻的节点删除操作。

// react-reconciler childFiber.ts
function deleteRemainingChildren(
  returnFiber: FiberNode,
  currentFirstChild: FiberNode | null
) {
  if (!shouldTrackEffects) {
    return;
  }

  let childToDelete = currentFirstChild;
  while (childToDelete !== null) {
    deleteChild(returnFiber, childToDelete);
    childToDelete = childToDelete.sibling;
  }
}

无标题-2023-01-09-1029.png

对于文本节点的单节点对比reconcileSingleTextNode也是类似的道理。我们这里就不在重复。

同级多节点对比

同级多节点表示更新后是多个节点,即是数组形式。由于我们知道fiber的结构,所以主要是通过同级的第一个child元素,通过sibling遍历,对比数组包裹的多个ReactElement

例如:

  • ABC -> CAB
  • A1 -> B1A1C1

reconcileChildFibers中,我们前几节中只针对了单个节点的判断 (如果是对象,并且是REACT_ELEMENT_TYPE)。现在我们需要新增,如果newChild是对象的情况,主要逻辑在reconcileChildrenArray中。

// 多节点的情况 ul > li * 3
if (Array.isArray(newChild)) {
  return reconcileChildrenArray(returnFiber, currentFiber, newChild);
}
reconcileChildrenArray

执行reconcileChildrenArray需要返回更新后的第一个子元素fiberNode,用于wip.child的指向。它的逻辑主要分这部分:

  1. 如果currentFiber存在的话,需要将currentFiber的链表转换成map,方便之后查找是否可以复用。
  2. 遍历newChild, 寻找是否可复用
  3. 标记移动还是插入
  4. 将第一步剩下无用的fiberNode标记删除
第一步转换currentFirstChild

我们知道,当在更新阶段的时候,我们能够获取到第一个子元素,我们通过key转换成map。

const existingChildren: ExistingChildren = new Map();
let current = currentFirstChild;
while (current !== null) {
  const keyToUse = current.key !== null ? current.key : current.index;
  existingChildren.set(keyToUse, current);
  current = current.sibling;
}

22.png

第二步遍历newChild

第二步主要是遍历newChild看看有没有可以复用的节点。主要是通过ReactElementkeytype判断是否存在第一步生成的map中。

主要是分为是根据新的element的类型进行不同的区分。例如stringnumber对应的HostTextobject对应的REACT_ELEMENT_TYPE。 分别针对keytype的情况进行判断。

主要是逻辑在updateFromMap中:

// existingChildren 第一步生成的map
function updateFromMap(
  returnFiber: FiberNode,
  existingChildren: ExistingChildren,
  index: number,
  element: any
): FiberNode | null {
  const keyToUse = element.key !== null ? element.key : index;
  const before = existingChildren.get(keyToUse);

  if (typeof element === "string" || typeof element === "number") {
    // hostText类型
    if (before) {
      if (before.tag === HostText) {
        // 证明可以复用
        existingChildren.delete(keyToUse);
        return useFiber(before, { content: element + "" });
      }
    }
    return new FiberNode(HostText, { content: element + "" }, null);
  }

  // ReactElement 类型
  if (typeof element === "object" && element !== null) {
    switch (element.$$typeof) {
      case REACT_ELEMENT_TYPE:
        if (before) {
          if (before.type === element.type) {
            // key相同, type相同可以服用
            existingChildren.delete(keyToUse);
            return useFiber(before, element.props);
          }
        }
        return createFiberFromElement(element);
    }

    // TODO: 数组类型 / fragment
    if (Array.isArray(element) && __DEV__) {
      console.warn("还未实现的数组类型的Child");
    }
  }
  return null;
}
第三步标记移动或者插入

我们要知道新的elemenet相比于之前的位置是否有移动,如果有移动就需要标记placement

newChild遍历的过程中,当前遍历到的element 一定是 所有已遍历的element最右边的一个。

所以只需要记录最大的一个可复用fiber 在currentFibers中的index(lastPlacedIndex),lastPlacedIndex的初始值为0

在遍历的过程中:

  • 如果接下来遍历到的可复用的fiber 的index < lastPlacedIndex, 则表示需要移动,标记Placement
  • 否则,不标记。

如下面的例子:

  1. 第一次element对应的li-3, 找到currentfiberNodes中的li-3,可复用,对应的index记录为2lastPlacedIndex(初始为0) < index,不移动,标记lastPlacedIndex = 2
  2. 继续遍历,到element对应的li-1, 此时对应的index < lastPlacedIndex,标记placement
  3. 继续遍历,到element对应的li-2, 此时对应的index < lastPlacedIndex,标记placement

lastPlacedIndex标记.png

第四步将Map中剩下fiber的标记为删除

在遍历完element之后,如果currentFibers对应的map还有剩下的元素,就需要标记。

因为这是新的elment遍历后,发现没有可复用的多余节点,标记删除后,执行一些删除的钩子。

整体代码

整体主要是reconcileChildrenArray中的逻辑:

function reconcileChildrenArray(
  returnFiber: FiberNode,
  currentFirstChild: FiberNode | null,
  newChild: any[]
) {
  // 最后一个可复用fiber在current中的index
  let lastPlacedIndex = 0;
  // 创建的最后一个fiber
  let lastNewFiber: FiberNode | null = null;
  // 创建的第一个fiber
  let firstNewFiber: FiberNode | null = null;

  // 1. 将current保存在map中
  const existingChildren: ExistingChildren = new Map();
  let current = currentFirstChild;
  while (current !== null) {
    const keyToUse = current.key !== null ? current.key : current.index;
    existingChildren.set(keyToUse, current);
    current = current.sibling;
  }

  for (let i = 0; i < newChild.length; i++) {
    // 2. 遍历newChild, 寻找是否可复用
    const after = newChild[i];
    const newFiber = updateFromMap(returnFiber, existingChildren, i, after);

    // 更新后节点删除 newFiber就是null, 此时就不用处理下面逻辑了
    if (newFiber === null) {
      continue;
    }

    // 3. 标记移动还是插入
    newFiber.index = i;
    newFiber.return = returnFiber;
    if (lastNewFiber === null) {
      lastNewFiber = newFiber;
      firstNewFiber = newFiber;
    } else {
      lastNewFiber.sibling = newFiber;
      lastNewFiber = lastNewFiber.sibling;
    }

    if (!shouldTrackEffects) {
      continue;
    }

    const current = newFiber.alternate;
    if (current !== null) {
      // update
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 移动
        newFiber.flags |= Placement;
        continue;
      } else {
        //不移动
        lastPlacedIndex = oldIndex;
      }
    } else {
      // mount
      newFiber.flags |= Placement;
    }
  }

  // 4. 将Map中剩下的标记为删除
  existingChildren.forEach((fiber) => {
    deleteChild(returnFiber, fiber);
  });
  return firstNewFiber;
}

最后返回第一个child元素,用于wip.child的指向绑定。

其中updateFromMap的逻辑如下:

/**
 *  是否可复用(reconcileChildrenArray中的第二步
 * @param returnFiber
 * @param existingChildren
 * @param index
 * @param element
 * @return  FiberNode就是可以复用,null 就是不能复用
 */
function updateFromMap(
  returnFiber: FiberNode,
  existingChildren: ExistingChildren,
  index: number,
  element: any
): FiberNode | null {
  const keyToUse = element.key !== null ? element.key : index;
  const before = existingChildren.get(keyToUse);

  if (typeof element === "string" || typeof element === "number") {
    // hostText类型
    if (before) {
      if (before.tag === HostText) {
        // 证明可以复用
        existingChildren.delete(keyToUse);
        return useFiber(before, { content: element + "" });
      }
    }
    return new FiberNode(HostText, { content: element + "" }, null);
  }

  // ReactElement 类型
  if (typeof element === "object" && element !== null) {
    switch (element.$$typeof) {
      case REACT_ELEMENT_TYPE:
        if (before) {
          if (before.type === element.type) {
            // key相同, type相同可以服用
            existingChildren.delete(keyToUse);
            return useFiber(before, element.props);
          }
        }
        return createFiberFromElement(element);
    }

    // TODO: 数组类型 / fragment
    if (Array.isArray(element) && __DEV__) {
      console.warn("还未实现的数组类型的Child");
    }
  }
  return null;
}

commitWork提交

在调和阶段后,我们得到了新的fiberNode的链以及相应的PlacementChildDeletion

接下来就是根据这些数据渲染或者更新视图(插入相应的Dom数据)。

Placement处理

在之前的章节中。我们只是针对了单个父子节点的操作。例如:

<ul>
  <li>hcc</li>
</ul>

现在我们会出现如下情况:就说明我们可能会出现在一个节点的后方插入某一个节点。所以我们需要获取到真正的相邻的节点。

<ul>
  <li>hcc1</li>
  <li>hcc2</li>
  <li>hcc3</li>
</ul>
commitPlacement处理

Placement的主要逻辑都在commitPlacement中,主要是获取parentDom或者获取siblingDom,相比之前的单节点处理,新增了获取siblingDom,根据是否存在执行对应的操作。

const commitPlacement = (finishWork: FiberNode) => {
  if (__DEV__) {
    console.warn("执行commitPlacement操作", finishWork);
  }
  // parentDom 插入 finishWork对应的dom

  // 1. 找到parentDom
  const hostParent = getHostParent(finishWork);

  // host sibling
  const sibling = getHostSibling(finishWork);

  if (hostParent !== null) {
    insertOrAppendPlacementNodeIntoContainer(finishWork, hostParent, sibling);
  }
};

insertOrAppendPlacementNodeIntoContainer中主要是根据sibling是否存在。

如果存在的话,就执行parent.insertBefore(child, siblingDom)

不存在的话还是执行parent.appendChild(child)

getHostSibling

主要是如何获取对应的siblingDom。主要难点是可能fiberNode相邻的元素,并不是真正的相邻的dom节点。

主要考虑一下2点:

  1. 可能并不是目标fiber的直接兄弟节点。分2种情况:当我们处理</A>的时候:
    • 可能真正的兄弟节点是当前fiber对应的<B/>的子节点。
    • 也可能是父节点<App/>对应的兄弟节点。
    // 第一种情况:需要向下遍历
    <A/></B>
    function B() {
        return <div/>
    }
    
    // 第二种情况:需要向上遍历
    <App/><div/>
    function App() {
      return </A>
    }
    
  2. 不稳定的Host节点不能作为目标兄弟Host节点
/**
 * 获取相邻的真正的dom节点
 */
function getHostSibling(fiber: FiberNode) {
  let node: FiberNode = fiber;

  findSibling: while (true) {
    // 向上遍历
    while (node.sibling === null) {
      const parent = node.return;
      if (
        parent === null ||
        parent.tag === HostComponent ||
        parent.tag === HostRoot
      ) {
        return null;
      }
      node = parent;
    }

    node.sibling.return = node.return;
    node = node.sibling;

    while (node.tag !== HostText && node.tag !== HostComponent) {
      // 向下遍历,找到稳定(noFlags)的div或文本节点
      if ((node.flags & Placement) !== NoFlags) {
        // 节点不稳定
        continue findSibling;
      }

      if (node.child === null) {
        continue findSibling;
      } else {
        // 向下遍历
        node.child.return = node;
        node = node.child;
      }
    }

    if ((node.flags & Placement) === NoFlags) {
      return node.stateNode;
    }
  }
}
  1. 首先判断节点是否存在sibling,如果存在的话,就需要向下遍历到稳定的Dom类型节点。
  2. 如果sibling遍历完后没有找到,就需要向上遍历,如果父节点不是组件类型的节点类型,就可以终止遍历,返回null
获取相邻节点例子
  1. 第一种情况,相邻节点的dom需要向下遍历: 获取相邻节点.png
  • 当前处理li-fiberNode的时候,获取sibling为组件类型
  • 由于不是HostTextHostComponent,所以会向下遍历。找到<B/>的子节点li-fiberNode
  • 然后返回li-fiberNode对应的stateNode真正的dom节点
  1. 第二种情况,父级Dom查找,向上遍历: 获取相邻节点2.png
  • 当前处理li-fiberNode的时候,sibling为空。进入向上遍历。
  • 找到parent对应的组件类型。
  • 然后继续找到siblingHostComponent类型,就直接返回。

下一节预告

下一节我们基于这一节的内容,实现Fragment<>

<>
  <div/>
  <div/>
</>

<ul>
  <li/>
  <li/>
  {[<li/><li/>]}
</ul>