React 实现多个节点 diff

今天带大家实现多个节点 diff。

先看个例子。

js 复制代码
function FunctionComponent() {
    const [count1, setCount1] = useReducer((x) => x + 1, 1);
    const arr = count1 % 2 === 0 ? [0, 1, 2, 3, 4] : [3, 2, 0, 1, 4]

    return (
        <div>
            <h3>函数组件</h3>
            <button onClick={() => {setCount1()}}>{count1}</button>
            <ul>
                {arr.map((item) => {
                    <li key={"li" + item}>{item}</li>
                })}
            </ul>
        </div>
    )
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render((<FunctionComponent />) as any);

整体流程:

  1. 从左边往右遍历,比较新老节点,如果节点可以复用,继续往右,否则就停止
  2. 有三种互斥情况
  • 新节点没了,老节点还有,则删除剩余的老节点即可
  • 新节点还有,老节点没了,新增新节点
  • 新老节点都还有节点,但是因为老 fiber 是链表,不方便快速 get 与 delete,因此把老 fiber 链表中的节点放入 Map 中,后续操作这个 Map 的 get 与 delete
  1. 如果是组件更新阶段,此时新节点已经遍历完了,能复用的老节点都用完了,则最后查找 Map 里是否还有元素,如果有,则证明是新节点里不能复用的,也就是要被删除的元素,此时删除这些元素就可以了。

对比 Vue3:

  • 算法:React 没有复杂算法,Vue3 使用了最长递增子序列
  • Map:构建的 map 的 value 不同:React 是 fiber,Vue3 是 index
  • 数据结构:Vue3 有右边按序查找,React 没有,因为 Vue3 使用了数组,React 使用了单向链表

Render 阶段

BeginWork 阶段

入口函数是 reconcileChildrenArray,增加了两次遍历。

js 复制代码
function updateTextNode(
    returnFiber: Fiber,
    current: Fiber | null,
    textContent: string
  ) {
    if (current === null || current.tag !== HostText) {
      // 老节点不是文本
      const created = createFiberFromText(textContent);
      created.return = returnFiber;
      return created;
    } else {
      // 老节点是文本
      const existing = useFiber(current, textContent);
      existing.return = returnFiber;
      return existing;
    }
  }

  function updateElement(
    returnFiber: Fiber,
    current: Fiber | null,
    element: ReactElement
  ) {
    const elementType = element.type;
    if (current !== null) {
      if (current.elementType === elementType) {
        // 类型相同
        const existing = useFiber(current, element.props);
        existing.return = returnFiber;
        return existing;
      }
    }

    const created = createFiberFromElement(element);
    created.return = returnFiber;
    return created;
  }
  
function updateSlot(
    returnFiber: Fiber,
    oldFiber: Fiber | null,
    newChild: any
  ) {
    // 判断节点是否可以复用
    const key = oldFiber !== null ? oldFiber.key : null;
    if (isText(newChild)) {
      if (key !== null) {
        // 新节点是文本,老节点不是文本
        return null;
      }
      // 有可能可以复用
      return updateTextNode(returnFiber, oldFiber, newChild + "");
    }

    // 新节点不是文本
    if (typeof newChild === "object" && newChild !== null) {
      if (newChild.key === key) {
        return updateElement(returnFiber, oldFiber, newChild);
      } else {
        return null;
      }
    }

    // 兼容 null、undefined 的情况
    return null;
  }

  function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number, // 记录的是新 fiber 在老 fiber 上的位置
    newIndex: number
  ) {
    newFiber.index = newIndex;

    // 初次渲染
    if (!shouldTrackSideEffects) {
      return lastPlacedIndex;
    }

    // 判断节点位置是否发生相对位置变化,是否需要移动
    const current = newFiber.alternate;
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 0 1 2
        // 0 2 1
        // 节点需要移动位置
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        return oldIndex;
      }
    } else {
      // 节点是新增
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }
  
  // 把剩下的节点构建成 map
  function mapRemainingChildren(oldFiber: Fiber): Map<string | number, Fiber> {
    const existingChildren: Map<string | number, Fiber> = new Map();
    let existingChild: Fiber | null = oldFiber;
    while (existingChild !== null) {
      if (existingChild.key !== null) {
        existingChildren.set(existingChild.key, existingChild);
      } else {
        existingChildren.set(existingChild.index, existingChild);
      }
      existingChild = existingChild.sibling;
    }

    return existingChildren;
  }

  function updateFromMap(
    existingChildren: Map<string | number, Fiber>,
    returnFiber: Fiber,
    newIdx: number,
    newChild: any
  ): Fiber | null {
    if (isText(newChild)) {
      // 文本节点没有 key 值,只能用 nexIdx
      const matchedFiber = existingChildren.get(newIdx) || null;
      return updateTextNode(returnFiber, matchedFiber, newChild + "");
    } else if (typeof newChild === "object" && newChild !== null) {
      const matchedFiber =
        existingChildren.get(newChild.key === null ? newIdx : newChild.key) ||
        null;
      return updateElement(returnFiber, matchedFiber, newChild);
    }

    // 兼容 null、undefined 的情况
    return null;
  }

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<any>
  ) {
    let resultFirstChild: Fiber | null = null; // 头结点
    let previousNewFiber: Fiber | null = null;
    let oldFiber = currentFirstChild;
    let nextOldFiber = null; // oldFiber.sibling
    let newIdx = 0;
    let lastPlacedIndex = 0;

    // * 大多数实际场景下,节点相对位置不变
    // 1. 从左往右遍历,按位置比较,如果可以复用,那就复用。不能复用,退出本轮
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      // 新旧节点位置不同
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
      if (newFiber === null) {
        // oldFiber.index > newIdx
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }

      // 更新
      if (shouldTrackSideEffects) {
        if (oldFiber && newFiber?.alternate === null) {
          deleteChild(returnFiber, oldFiber);
        }
      }

      // 判断节点在 DOM 的相对位置是否发生变化
      // 组件更新阶段,判断在更新前后的位置是否一致,如果不一致,需要移动
      lastPlacedIndex = placeChild(newFiber as Fiber, lastPlacedIndex, newIdx);

      if (previousNewFiber === null) {
        // 第一个节点,不要用 newIdx 判断,因为有可能有 null,而 null 不是有效 fiber
        resultFirstChild = newFiber as Fiber;
      } else {
        previousNewFiber.sibling = newFiber as Fiber;
      }
      previousNewFiber = newFiber as Fiber;

      oldFiber = nextOldFiber;
    }

    // * Vue 1.2 从右往左遍历,按位置比较,如果可以复用,那就复用。不能复用,退出本轮

    // 2.1 老节点还有,新节点没了。删除剩余的老节点
    // 新节点遍历完了
    if (newIdx === newChildren.length) {
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultFirstChild;
    }

    // 2.2 新节点还有,老节点没了。剩下的新节点新增就可以了
    // 包括页面初次渲染
    if (oldFiber === null) {
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(returnFiber, newChildren[newIdx]);
        // null、undefined
        if (newFiber === null) {
          continue;
        }

        //  组件更新阶段,判断在更新前后的位置是否一致,如果不一致,需要移动
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // 第一个节点,不要用 newIdx 判断,因为有可能有 null,而 null 不是有效 fiber
          resultFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultFirstChild;
    }

    // 2.3 新老节点都还有
    // 构建 map
    const existingChildren = mapRemainingChildren(oldFiber);
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx]
      );
      if (newFiber !== null) {
        // 更新
        if (shouldTrackSideEffects) {
          // 已经复用过了,删除
          // 每个节点只能被复用一次
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key
          );
        }
        lastPlacedIndex = placeChild(
          newFiber as Fiber,
          lastPlacedIndex,
          newIdx
        );
        if (previousNewFiber === null) {
          // 第一个节点,不要用 newIdx 判断,因为有可能有 null,而 null 不是有效 fiber
          resultFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    // 3. 如果新节点已经构建完了,但是老节点还有
    if (shouldTrackSideEffects) {
      existingChildren.forEach((child) => deleteChild(returnFiber, child));
    }
    return resultFirstChild;
  }
  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any
  ) {
    if (isText(newChild)) {
      return placeSingleChild(
        reconcileSingleTextNode(returnFiber, currentFirstChild, newChild + "")
      );
    }

    // 检查newChild类型,单个节点、文本、数组
    if (typeof newChild === "object" && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE: {
          return placeSingleChild(
            reconcileSingleElement(returnFiber, currentFirstChild, newChild)
          );
        }
      }
    }

    // 子节点是数组
    if (isArray(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild);
    }
    // todo
    return null;
  }

  return reconcileChildFibers;
}

Commit 阶段

commitPlacement 不能单纯的 appendChild 了,要区分 insertBeforeappendChild,便于实现移动。

js 复制代码
// fiber.stateNode 是 DOM 节点
// 有 dom 节点且能兄弟节点
export function isHost(fiber: Fiber): boolean {
  return fiber.tag === HostComponent || fiber.tag === HostText;
}

function getHostSibling(fiber: Fiber) {
  let node = fiber;
  // label 函数
  sibling: while (1) {
    while (node.sibling === null) {
      if (node.return === null || isHostParent(node.return)) {
        return null;
      }
      node = node.return;
    }
    // todo
    node = node.sibling;
    while (!isHost(node)) {
      // 新增插入|移动位置
      // 兄弟节点不能移动,必须稳定
      if (node.flags & Placement) {
        continue sibling;
      }
      // 比如函数组件,往里继续查找
      if (node.child === null) {
        continue sibling;
      } else {
        node = node.child;
      }
    }

    // HostComponent|HostText
    if (!(node.flags & Placement)) {
      return node.stateNode;
    }
  }
}

// 新增插入 | 位置移动
// insertBefore | appendChild
function insertOrAppendPlacementNode(
  node: Fiber,
  before: Element,
  parent: Element
) {
  if (before) {
    parent.insertBefore(getStateNode(node), before);
  } else {
    parent.appendChild(getStateNode(node));
  }
}

function commitPlacement(finishedWork: Fiber) {
  if (finishedWork.stateNode && isHost(finishedWork)) {
    // finishedWork 是有 dom 节点
    const domNode = finishedWork.stateNode
    // 找 domNode 的父 DOM 节点对应的 fiber
    const parentFiber = getHostParentFiber(finishedWork);

    let parentDom = parentFiber.stateNode;

    if (parentDom.containerInfo) {
      // HostRoot
      parentDom = parentDom.containerInfo;
    }

    // parentDom.appendChild(domNode)
    // 遍历 fiber,寻找 finishedWork 的兄弟节点,并且这个 sibling 有 dom 节点,且是更新的节点(上一轮)。在本轮不发生移动
    const before = getHostSibling(finishedWork);
    insertOrAppendPlacementNode(finishedWork, before, parentDom);
  } else {
    // Fragment
    let kid = finishedWork.child;
    while (kid !== null) {
      commitPlacement(kid);
      kid = kid.sibling;
    }
  }
}
相关推荐
凌辰揽月1 小时前
AJAX 学习
java·前端·javascript·学习·ajax·okhttp
然我2 小时前
防抖与节流:如何让频繁触发的函数 “慢下来”?
前端·javascript·html
鱼樱前端2 小时前
2025前端人一文看懂 Broadcast Channel API 通信指南
前端·vue.js
烛阴3 小时前
非空断言完全指南:解锁TypeScript/JavaScript的安全导航黑科技
前端·javascript
鱼樱前端3 小时前
2025前端人一文看懂 window.postMessage 通信
前端·vue.js
快乐点吧3 小时前
【前端】异步任务风控验证与轮询机制技术方案(通用笔记版)
前端·笔记
pe7er4 小时前
nuxtjs+git submodule的微前端有没有搞头
前端·设计模式·前端框架
七月的冰红茶4 小时前
【threejs】第一人称视角之八叉树碰撞检测
前端·threejs
爱掉发的小李4 小时前
前端开发中的输出问题
开发语言·前端·javascript
祝余呀5 小时前
HTML初学者第四天
前端·html