React Virtual DOM 与 DOM Diff 算法

142 阅读14分钟

什么是 DOM ?

DOM 全称是 Document Object Model,作用是将 html/xml 文档组织成对象模型。

DOM 不是一种编程语言,但是它提供了编程接口可由 JS 等编程语言操控。

以浏览器的角度来看 DOM

浏览器渲染中一个很重要的工作就是将文档形成页面,大概分为下面三个流程:

image.png

浏览器接收到响应文档后,会进行文档解析,并构建DOM:

image.png

最后解析 DOM,形成页面:

image.png

举个例子,假设现有以下 HTML

<html>
    <head>
        <title>dom</title
    </head>
    <body>
        <p id="id">react dom</p>
    </body>
</html>

解析成 DOM 后结构如下:

image.png

什么是 JSX ?

JSX 是 React 提出的一个概念,它本质是一种 JS 的语法糖。长得像 HTML,但本质上还是 JS。

经过 Babel 编译后会转换成 React.createElement

React 官方对 Element 是这样介绍的:

Elements are the smallest building blocks og React apps

可以理解为 Element 就是 React 中元素的最小单位。如果递归调用 React.createElement 就可以生成 Elements Tree

举个例子, JSX ===〉 Elements Tree :

// jsx
return (
    <div className='demo-div'>
        {/* 组件 */}
        <Header>hello</Header>
        <p>world</p >
        footer
   </div>
)
// Babel 编译后
return (
    React.createElement(
        // type
        'div',
        // props
        {
            className: 'demo-div',
        },
        // children
        React.createElement(
            Header,
            null,
            'hello'
        ),
        React.createElement(
            'p',
            null,
            'world',
        ),
        'footer',
    )
)
// Elements Tree
{
    type: 'div',
    props: {
        className: 'demo-div',
        children: [
            {
                type: function Header,
                props: {
                    children: 'hello',
                },
            },
            {
                type: 'p',
                props: {
                    children: 'world',
                },
            },
            'footer',
        ],
    },
}

什么是 Virtual DOM ?

由 elements 组成的树就是 Virtual DOM。

它的本质就是用 JS 对象来模拟 DOM 树,在渲染更新时,先对 JS 对象进行操作,再将操作后的 JS 对象(Virtual DOM)渲染成真实 DOM。减少对 DOM 的操作,提升性能。

什么是 DOM Diff ?

DOM Diff 其实就是比较你本次 UI 更新前后两棵 Elements Tree 的差异。

在 React 中,DOM Diff 是基于两个假设实现的:

  1. 两个不同类型的元素会产生不同的树
  2. 对于同一层级的一组子元素,它们之间可以通过唯一的 key 进行区分

React 的 Reconcilation 机制是这样描述的:

在 UI 更新时:第一次调用 render() 方法时会生成第一颗 elements 树,

在下一次 state 或 props 变化时,相同的 render() 方法会生成一棵不同的 elements 树。

再通过 diff 算法对比前后两个 elements 树的差异,从而实现真实 DOM 的增量更新渲染

由此可知,当对比两棵树时,React 会从两棵树的根节点进行比较,比较的过程可以分为下面几种类型:

对比不同类型的元素节点

对于不同类型的节点,React 会基于第一条假设,直接删除旧节点,然后创建新节点。

举个例子,从 before 更新到 after

// before
<root>
    <A>
        <B />
        <C />
    </A>
    <D />
</root>
// after
<root>
    <D>
        <A>
            <B />
            <C />
        </A>
    </D>
</root>

抽象成更好理解的 DOM Tree 模型:

image.png

在上述例子中,React 会执行下列操作:

  1. 移除 root 节点的左子树,即 A 节点及其子节点 B 和 C
  2. 重新创建 A 节点以及其子节点 B 和 C
  3. 插入到 root 节点右子树 D 节点的子节点中

对比相同类型的元素节点

对比相同类型的元素节点时,React 会保留 DOM 节点,仅对比及更新有变更的属性。

【同为 DOM 元素类型】

// before
<div className="before" title="demo" />

//after
<div className="after" title="demo" />

上面这个例子,由于 type 都是 div,属于相同类型的节点,React 只会修改 DOM 元素上的 classname 属性

【同为自定义组件类型】

此时 React 并不知道如何去更新 DOM,因为这些逻辑是在组件里去实现的。

当组件更新时,其实例是保持不变的,所以此时 React 做的就是根据新节点的 props 来更新旧节点的组件实例,从而触发一个更新过程,最后再对所有的 child 节点进行 diff 递归比较更新。

用生命周期来表示的话就是下面这样的:

image.png

递归对比子节点

默认情况下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素列表,按顺序比对每个子节点。

【场景一:插入节点】

// before
<root>
    <A />
    <B />
    <C />
</root>
// after
<root>
    <A />
    <B />
    <D />
    <C />
</root>

上述例子,我们在 B 和 C 节点中间,插入了 D 节点。

当比较到第三个节点时,发现 C 和 D 不相同,React 会删除 C,然后创建 D 放在 C 的位置;

最后再创建一个 C 放到尾部。会有两次插入 DOM 操作。

那么有没有办法只操作一次 DOM 呢?

如果要做到只进行一次操作,则需要保持 DOM 结构的稳定性,比如再插入一个 null 节点,这样就能避免 C 被卸载后又重建,减少一次 DOM 操作。

// before
<root>
    <A />
    <B />
    {nul}
    <C />
</root>

但是每次都要提前插入一个 null 节点,对开发而言绝不是一个好办法。

因此,React 提出了 Key 这个概念。

当元素拥有 key 属性时,React 会通过 key 来匹配新旧两棵树上的子元素。

key 在列表中需要保持唯一,不建议使用数组下标来做 key,因为当重新排序的操作时,key就会变化,从而造成预期之外的变化。

// before
<root>
    <A key="A" />
    <B key="B" />
    <C key="C" />
</root>
// after
<root>
    <A key="A" />
    <B key="B" />
    <D key="D" />
    <C key="C" />
</root>

添加了唯一的 key 后,React 就可以定位到要插入的位置,只需要进行一次插入 DOM 操作。

【场景二:删除节点】

// before
<root>
    <A key="A" />
    <B key="B" />
    <D key="D" />
    <C key="C" />
</root>
// after
<root>
    <A key="A" />
    <B key="B" />
    <C key="C" />
</root>

删除节点与添加节点类似: 如果没有 key,那么当比较到第三个节点时,D 和 C 不同,React 会删除 D,然后创建一个 C 并放到 D 的位置;最后再删除末尾的 C 节点。

如果有 key:React 就可以确定要移除的位置,只需要进行一次删除DOM操作。

【场景三:交换节点位置】

// before
<root>
    <A key="A" />
    <B key="B" />
    <C key="C" />
</root>
// after
<root>
    <A key="A" />
    <C key="C" />
    <B key="B" />
</root>

如果没有 key,React 会认为 B 更新成了 C,C 更新成了 B,因此会执行两次卸载更新的操作。

如果有 key:React 就知道 key B 和 key C 在更新后都还存在,DOM 节点可以复用,只需要简单交换一下位置即可。

React DOM Diff 算法详解

前面介绍完 DOM Diff 是什么之后,接下来我们来仔细研究下 React 中的 DOM Diff 算法是如何实现的。

Fiber 与 双缓存机制

Fiber:React 中用 Fiber 表示 Virtual DOM

Fiber 包含了 DOM 结构相关的 type、key 等静态属性,同时还包含了 return、child、sibling 等连接形成树的属性,以及 alternate 属性用于指向下一次更新对应的 Fiber。

image.png

双缓存机制:在内存中构建并直接替换

在 React 中最多会同时存在两棵 Fiber 树,一个是当前 DOM 节点对应的 current Fiber,一个是内存中构建的 workInProgress Fiber,它们之间通过 alternate 属性连接。

当 workInProgress Fiber 构建完成交给 Renderer 去渲染后,应用根节点的 current 就会从指向 current Fiber 改为指向 workInProgress Fiber,从而完成 DOM 更新。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

DOM Diff 流程概览

假设一个函数组件发生了更新,那么 React 会执行以下流程:

image.png

  1. performSyncWorkOnRoot:入口,同步更新,将 workInProgress 传入 performUnitOfWork 并遍历

  2. performUnitOfWork:创建下一个 Fiber 节点,赋值给 workInProgress,并将 workInProgress 和已创建的 Fiber 节点连接构成 Fiber 树

  3. beginWork:从 rootFiber 开始深度优先遍历,为遍历到的每个节点执行 beginWork。该方法根据传入的 Fiber 节点创建 子 Fiber 节点,并将两个节点连接。当遍历到叶子结点时,就会进入 completeWork 阶段

  4. completeWork:当某个节点执行完 completeWork,如果存在兄弟 Fiber 节点,那么会进入兄弟 Fiber 节点的 beginWork 阶段;否则进入 父节点 的 beginWork 阶段

  5. 直到回到 rootFiber,render 完成,提交 commitWork,执行 DOM 操作

下面来看下每个流程具体都做了什么

performSyncWorkOnRoot

DOM Diff 的入口方法,将 workInProgress 传入 performUnitOfWork 并遍历

// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

performUnitOfWork

该方法主要作用是创建更新后的 Fiber 节点,并将 workInProgress 树与新创建的 Fiber 节点连接构成新的 Fiber 树。

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;

  let next;
  next = beginWork(current, unitOfWork, subtreeRenderLanes)

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

}

beginWork

beginWork 的作用是:根据传入的 Fiber 节点,创建其子 Fiber 节点。

该方法会接收 currentFiber 和 workInProgressFiber:

  • 如果存在 current,表示存在上一次更新时的 Fiber 节点,走 update 逻辑
  • 如果不存在 current,表示不存在对应的 Fiber 节点,走 mount 逻辑
function beginWork(
  // 当前组件对应的 Fiber节点在上一次更新时的 Fiber节点,即 workInProgress.alternate
  current: Fiber | null,
  // 当前组件对应的Fiber节点
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // update
  // 满足条件 didReceiveUpdate === false 时可以复用 current.child 作为 workInProgress.child
  if (current !== null) {
    if (current.props !== workInProgress.props) {
      didReceiveUpdate = true;
    }
    // ...其他判断逻辑
  } else {
    didReceiveUpdate = false;
  }

  // mount
  // 根据 tag 创建不同类型的 Fiber 子节点
  switch (workInProgress.tag) {
    case FunctionComponent: {
      return updateFunctionComponent(
        current,
        workInProgress,
        renderLanes,
        ...,
      );
    }
  }
}

如果是走 mount 逻辑,那么会根据 tag 创建不同类型的 Fiber 子节点。

举个例子,tag 类型是函数组件,那么会执行 updateFunctionComponent 方法,如果需要来创建 Fiber 子节点,那么将会走到 reconcileChildren 逻辑。

function updateFunctionComponent(
  current,
  workInProgress,
  renderLanes,
) {
  if (current !== null && !didReceiveUpdate) {
    // 复用 current 子节点
    return ...;
  }

  // 创建新的子节点 or diff 生成新的子节点
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

reconcileChildren

对于 mount 的节点,该方法会创建新的子节点;对于 update 的节点,它会进行 DOM Diff,再将 diff 的结果生成新的子节点。

function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  // current 为 null,mount
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // 存在 current,dom diff
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}
function ChildReconciler(shouldTrackSideEffects) {...}

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

其中,mountChildFibersreconcileChildFibers 逻辑几乎一致,区别在于reconcileChildFibers 会为生成的 Fiber 节点带上 flags 属性,而 mount 不会。

flags 的作用是什么呢?

前面提到,render 阶段是在内存中进行的,当完成后会通知 Renderer 我们需要进行的 DOM 操作类型,而这个操作类型就保存在 flags 中,通过二进制表示。

image.png

如果 fiber.flags &= Placement !== 0,则表示该 fiber 节点被标记为 Placement,需要插入。

如果是 mount,那么只会在 rootFiber 赋值一次 flags 为 Placement。

reconcileChildFibers

该方法会根据 newChild 的类型选择不同的 diff 函数进行处理:

  • newChild 类型为 object / number / string,表示同级只有一个节点
  • newChild 类型为 Array,表示同级有多个节点
  • 如果都不是,则表示要删除该节点
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {
  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,比如 REACT_ELEMENT_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // ...
    }
  }

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

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

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

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

从这里开始就是 DOM Diff 算法的核心逻辑了,请牢记前面提到的两个假设:

  1. 两个不同类型的元素会产生不同的树
  2. 对于同一层级的一组子元素,它们之间可以通过唯一的 key 进行区分

同级只有一个节点 —— reconcileSingleElement

以 Object 类型为例,同级只有一个节点时会执行 reconcileSingleElement

该方法大致流程如下: image.png

首先会判断当前 Fiber 节点是否存在对应的 DOM 节点:

  • 如果不存在,则直接创建一个新的 Fiber节点;

  • 如果存在,则进行以下判断:

  1. key相同,type不同:执行 deleteRemainingChildren,会删除 child
  2. key相同,type相同:删除其兄弟节点
  3. key不相同:执行 deleteChild ,删除 child
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement, // newChild
  lanes: Lanes,
): Fiber {
  let child = currentFirstChild;
  const key = element.key;
  // 是否存在对应的 DOM 节点
  while (child !== null) {
    // key 相同
    if (child.key === key) {
      // type 相同,表示当前节点需要保留
      if (child.type === element.type) {
        // 父级下应该只有这一个子节点,将该子节点的兄弟节点删除
        deleteRemainingChildren(returnFiber, child.sibling);
        // 复用 Fiber 节点
        const existing = useFiber(child, element.props);
        existing.return = returnFiber;
        return existing;
      } else {
        // type 不同,需要删除
        deleteRemainingChildren(returnFiber, child);
        break;
      }
    } else {
      // key 不同,直接删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  // 创建新的节点
  const created = createFiberFromElement(element);
  return created;
}

其中 deleteRemainingChildrendeleteChild 逻辑如下:

function deleteRemainingChildren(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
): null {
  if (!shouldTrackSideEffects) {
    // Noop.
    return null;
  }

  let childToDelete = currentFirstChild;
  while (childToDelete !== null) {
    deleteChild(returnFiber, childToDelete);
    // 删除兄弟节点
    childToDelete = childToDelete.sibling;
  }
  return null;
}

function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
  if (!shouldTrackSideEffects) {
    // Noop.
    return;
  }
  const deletions = returnFiber.deletions;
  if (deletions === null) {
    returnFiber.deletions = [childToDelete];
    // 标记为 Deletion
    returnFiber.flags |= ChildDeletion;
  } else {
    deletions.push(childToDelete);
  }
}

同级存在多个节点 —— reconcileChildrenArray

这种场景下,React 会进行两轮遍历

【第一轮遍历】

第一轮遍历的目标是处理需要更新的节点(即可复用的节点):

  1. 遍历 newChildren,将 newChildren[i]oldFiber(currentFirstChild) 比较,判断DOM节点是否可复用。
  2. 如果可复用,继续比较 newChildren[i]oldFiber.sibling,可以复用则继续遍历。
  3. 如果不可复用,立即跳出整个遍历,第一轮遍历结束。
  4. 如果 newChildren 遍历完(即i === newChildren.length - 1)或者 oldFiber 遍历完(即 oldFiber.sibling === null),跳出遍历,第一轮遍历结束。

(oldFiber 是单向链表,因此 react 官方不建议使用双指针进行遍历)

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  let resultingFirstChild: Fiber | null = null;
  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;

  // ========== 第一轮遍历开始 =============
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      // fiber.index 是固定的,如果出现 fiber.index > newIndex
      // 表示此时 newIndex 对比的 oldFiber 其实是 null
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // 尝试复用节点
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    if (shouldTrackSideEffects) {
      // newFiber.alternate 为 null 表示 newFiber 是新创建的
      if (oldFiber && newFiber.alternate === null) {
        // 删除 oldFiber
        deleteChild(returnFiber, oldFiber);
      }
    }
    // 更新 lastPlacedIndex
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    if (!previousNewFiber) {
      // 这是第一个插入的新fiber
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }
  // ========== 第一轮遍历结束 =============

  // ======= 第二轮遍历 ========
  // ...
}

【第二轮遍历】

第二轮遍历的目标是处理非更新的节点,根据第一轮遍历的不同结果有不同的处理。

【结果1】newChildren 没遍历结束, oldFiber 遍历结束:

表示已存在的 DOM 节点都已复用,剩下的 newChildren 节点为需要插入的节点,则只需要遍历剩下的 newChildren 节点,依次生成 workInProgress fiber 节点

// 结果1: newChildren 遍历结束
if (newIdx === newChildren.length) {
  // 表示第一轮所有的节点都可复用,剩下的 oldFiber 节点删除即可
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

【结果2】newChildren 遍历结束, oldFiber 没遍历结束

表示剩下的节点需要删除,则只需要遍历剩下的 oldFiber, 执行 deleteRemainingChildren 操作

// 结果2: oldFiber 遍历结束
if (oldFiber === null) {
  // oldFiber 均已被复用,剩下的 newChildren 遍历后插入
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    if (newFiber === null) {
      continue;
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
  return resultingFirstChild;
}

【结果3】newChildren 和 oldFiber 都没遍历结束

表示有节点位置发生了变更,需要定位并处理移动的节点

React 是用 key 来匹配前后两次更新的相同节点。

为了方便找到 key 对应的 oldFiber,首先会先生成一个 Map,其中 key 为 oldFiber 的 key,value 为 oldFiber

function mapRemainingChildren(returnFiber, currentFirstChild) {
    const existingChildren = new Map();
    let existingChild = currentFirstChild;
    while (existingChild) {
      if (existingChild.key !== null) {
        existingChildren.set(existingChild.key, existingChild);
      } else {
        existingChildren.set(existingChild.index, existingChild);
      }
      existingChild = existingChild.sibling;
    }
    return existingChildren;
}

接着需要判断节点是否需要移动,以及怎么移动:

  1. 找到节点是否移动的参照物:最后一个可复用节点在 oldFiber 中的位置,记为 lastPlacedIndex
  2. 用 oldIndex 表示遍历到的可复用节点在 oldFiber 中的位置
  3. oldIndex < lastPlacedIndex,则表示节点需要向右移动
  4. oldIndex >= lastPlacedIndex,则不需要移动节点
function placeChild(
  newFiber: Fiber, // 更新的 fiber
  lastPlacedIndex: number, // 最后一个可复用节点的 index
  newIndex: number, // newFiber 在数组中的 index
): number {
  newFiber.index = newIndex;
  if (!shouldTrackSideEffects) {
    // Noop.
    return lastPlacedIndex;
  }
  const current = newFiber.alternate;
  if (current !== null) {
    // 存在 current
    // 类似插入排序:将要排序的部分分为已排序区和未排序区,每次遍历都将未排序区的一个元素与已排序区元素比较
    // 正常来讲,lastPlacedIndex 对应的 newFiber 需要在本次 newFiber 的左边
    // oldIndex < lastPlacedIndex:表示本次 newFiber 在 lastPlacedIndex 的左边,需要移动
    // 否则不需要移动
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // 标记未需要移动
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    } else {
      // 不需要移动
      return oldIndex;
    }
  } else {
    // 不存在 current
    // 标记为需要插入
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

结果 3 的完整代码如下:

// 生成 oldFiber Map
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        // 存在与之对应的 current,可以复用 current
        // 则需要从 existingChildren 中移除对应的节点避免在后续操作中被删除
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

if (shouldTrackSideEffects) {
  // 剩余的 oldFiber 表示未被复用,需要删除
  existingChildren.forEach(child => deleteChild(returnFiber, child));
}

return resultingFirstChild;

举个例子

光看前面的文字和代码可能有点不太好理解,我们来看个具体的例子:

// before
<root>
    <A key="A" />
    <B key="B" />
    <C key="C" />
</root>

// after
<root>
    <A key="A" />
    <C key="C" />
    <B key="B" />
</root>

在这个例子中,我们把 B 和 C 的节点位置做了交换,那么这个 diff 的流程是什么样的呢?

before: ABC  ===>  after: ACB

// ==== 第一轮遍历 start ====

// i = 0
A(after)对比 A(before):key不变,可复用
此时 A(before) 在 oldFiber 中索引为,所以 lastPlacedIndex = 0;

// i = 1
C(after)对比 B(before):key 改变,不可复用,跳出第一轮遍历

// ==== 第一轮遍历 end ====


// ==== 第二轮遍历 start ====
此时 newChildren = CB,oldFiber = BC;
创建 existingChildren,然后遍历 newChildren

// i = 0
key C 在 oldFiber 中存在,且 index 为 2,则 oldIndex = 2
oldIndex > lastPlacedIndex (2 > 0): 则 lastPlacedIndex = 2;
因此 C 的位置不需要改变

// i = 1
key B 在 oldFiber 中存在,index 为 1,则 oldIndex = 1
此时 oldIndex < lastPlacedIndex (1 < 2)
因此 B 需要向右移动

// ==== 第二轮遍历 end ====

以上便是 React DOM diff 算法的核心算法详解。

参考文章

kasong.gitee.io/just-react/…