实现一个简易的 React 的 diff 算法以及了解 React 的更新流程

747 阅读20分钟

1 前言

在聊 React 的 diff 算法之前,我们先要考虑一个问题:diff 算法操作了真实的 dom 节点吗?

答案是否定的,diff 算法仅仅是给不同的 fiber node 标记上了不同的标签,而真实的 dom 节点增删改是在 React 更新完毕后,在最终的 commitRoot 函数阶段根据 fiber node 的不同标签执行的

所以,要切实的了解 diff 算法的原理,要了解 diff 算法为什么要给 fiber node 标记上这些特定的标签,首先需要了解 React 更新的大致流程

基于此,本文将会分为两个部分:

  1. 简要叙述 React 更新流程,其中涉及真实 dom 节点的操作
  2. diff 算法的源码分析以及简单的示例

2 简述 React 更新流程

对于 React 更新流程,整体来说是比较复杂的,其中涉及同步异步,scheduler,各种优先级等概念,这里我们不讨论这些,仅仅探讨一下与 diff 算法相关的部分,以及最后 comitRoot 时做的事情

首先我们来看一段简单的代码,如下:

const App = () => {
    const [count, setCount] = React.useState(0);
    return (
        <div key='container'>
          <div onClick={() => {
            setCount(count + 1);
          }} key='a'>a</div>
          {count % 2 === 0 ? <div key='b'>b</div> : null}
          <div key='c'>c</div>
          <div key='d'>d</div>
        </div>
    )
}

我们有 1 个 container, 他有 4 个 div 儿子,儿子们的 key 分别为 abcd。在点击 a 的时候,会设置 count + 1count会在 a 后面实时渲染,而 b 会在 count 为偶数时才渲染。

先看一下整个流程图

image.png

接下来我们点击 a 看下 React 都做了哪些事情(以下仅列出较为关键的函数)

2.1 触发更新

首先我们点击 a 会触发 batchedUpdates 的调用,实际上是 React 给所有的事件都绑定了这个监听函数。

export function batchedUpdates(fn, a, b) {
  if (isInsideEventHandler) {
    // If we are currently inside another batch, we need to wait until it
    // fully completes before restoring state.
    return fn(a, b);
  }
  isInsideEventHandler = true;
  try {
    return batchedUpdatesImpl(fn, a, b);
  } finally {
    isInsideEventHandler = false;
    finishEventHandler();
  }
}
export function setBatchingImplementation(
  _batchedUpdatesImpl,
  _discreteUpdatesImpl,
  _flushSyncImpl,
) {
  batchedUpdatesImpl = _batchedUpdatesImpl;
  discreteUpdatesImpl = _discreteUpdatesImpl;
  flushSyncImpl = _flushSyncImpl;
}

这里 batchedUpdates 里比较关键的是调用了 batchedUpdatesImpl,可以看到这个 batchedUpdatesImpl 是由 setBatchingImplementation 函数设定的

在我们这个 case 中实际上设定的是另外一个 batchedUpdates 来自于 './ReactFiberWorkLoop.old.js',为了区分,我们用 batchedUpdates$1 代替。而这一点可以从 chrome 的调用栈中看出,如下图:

image.png

export function batchedUpdates$1(fn, a) {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;
    // If there were legacy sync updates, flush them at the end of the outer
    // most batchedUpdates-like method.
    if (
      executionContext === NoContext &&
      // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
      !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy)
    ) {
      resetRenderTimer();
      flushSyncCallbacksOnlyInLegacyMode();
    }
  }
}

这里是一个 try-finally,首先我们在 try 分支中执行传入的回调函数 fn,这里面会判断这次更新是否需要用 scheduler 来进行调度。对于我们这次的 case 是不需要的,所以这里我们会给全局的 syncQueue 中放入一个重要回调函数 performSyncWorkOnRoot

接下来我们会走进 finally 分支中的 flushSyncCallbacksOnlyInLegacyMode,一路往下,来到 flushSyncCallbacks

function flushSyncCallbacks() {
    // ...
      try {
        var isSync = true;
        var queue = syncQueue; // TODO: Is this necessary anymore? The only user code that runs in this
        // queue is in the render or commit phases.

        setCurrentUpdatePriority(DiscreteEventPriority);

        for (; i < queue.length; i++) {
          var callback = queue[i];

          do {
            callback = callback(isSync);
          } while (callback !== null);
        }

        syncQueue = null;
        includesLegacySyncCallbacks = false;
      }
    // ...
    return null;
}

可以看到这里循环了 syncQueue,并执行其中的回调函数,在我们的 case 中、同步状态下,也就是执行 performSyncWorkOnRoot

function performSyncWorkOnRoot(root) {
    // ...
    var exitStatus = renderRootSync(root, lanes);
    // ...
    commitRoot(root, workInProgressRootRecoverableErrors, workInProgressTransitions);
    // ...
    return null;
}

一路往下,我们接着首先执行 renderRootSync,再往下就来到了遍历 fiber tree 的起点 workLoopSync

function workLoopSync() {
    // Already timed out, so perform work without checking if we need to yield.
    while (workInProgress !== null) {
      performUnitOfWork(workInProgress);
    }
}

2.2 遍历 fiber tree

workLoopSync 中我们会循环执行 performUnitOfWork

function performUnitOfWork(unitOfWork) {
    // The current, flushed, state of this fiber is the alternate. Ideally
    // nothing should rely on this, but relying on it here means that we don't
    // need an additional field on the work in progress.
    var current = unitOfWork.alternate;
    setCurrentFiber(unitOfWork);
    var next;

    if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
      startProfilerTimer(unitOfWork);
      next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
      stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
    } else {
      next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
    }

    resetCurrentFiber();
    unitOfWork.memoizedProps = unitOfWork.pendingProps;

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

    ReactCurrentOwner$2.current = null;
}

可以看到这里我们会先执行 beginWork$1 拿到下一个 fiber 也就是 next,当 next === null 时,我们会对当前的 fiber 执行 completeUnitOfWork

这里两个关键函数 beginWork$1completeUnitOfWork,前者就是 diff 算法运行的地方,有关 diff 的逻辑我们后面再说,这里简要说明一下整棵 fiber tree 的遍历步骤:

fiber tree 的遍历是深度优先的,简要的来说就是:

  1. 先找 儿子
  2. 没有 儿子 则找 兄弟
  3. 兄弟 找完了就往上一级找 父级 以及 父级兄弟

我们的 fiber tree 如下图所示:

image.png

有一点需要注意,diff 的执行是在当前 fiber node 的 儿子 层级进行的。对于我们这棵 fiber tree 来说,也就是当 unitOfWorkcontainer 节点时,我们对他的 儿子 层级进行 diff,也就是 abcd

对于 fiber node 的处理分为两部分:

  1. beginWork$1 执行 diff 算法(详细逻辑后文再讲):

    i. 若当前 fiber node 儿子 属于移动或新增,则给该儿子标记 Placement。(显而易见的,当前 fiber node 是否为 Placement 是在处理其父级时标记的)

    ii. 若当前 fiber node 儿子 属于删除,则给当前 fiber node 标记 ChildDeletion,并将其放进 当前 fiber nodedeletions

  2. completeUnitOfWork

    i. 之前说了,diff 的比对是在 儿子 层级进行的,简单来说就是找 fiber.child,也就是当 fiber.child 为空时,会进入 completeUnitOfWork 的逻辑。注意这并不和上述遍历 fiber tree 的逻辑矛盾。

    ii. 比对当前 fiber node 与对应的旧 fiber nodepropsstate 判断是否要标记 Update(期间同样涉及优先级的逻辑,不在本文讨论)

2.3 最终的 commitRoot

我们先列出相关的关键函数 commitRootImpl -> commitMutationEffects -> commitMutationEffectsOnFiber

function commitMutationEffectsOnFiber(finishedWork, root, lanes) {
    var current = finishedWork.alternate;
    var flags = finishedWork.flags;
    switch (finishedWork.tag) {
        case HostComponent:
        {
          recursivelyTraverseMutationEffects(root, finishedWork); // 处理 ChildDeletion
          commitReconciliationEffects(finishedWork); // 处理 Placement
          // ...
          {
            // ...
            if (flags & Update) {
               // ...
               // 处理 Update
               commitUpdate(_instance4, updatePayload, type, oldProps, newProps, finishedWork);
            }
          }

          return;
        }
    }
}

从上述的简化代码可以看出,操作真实 dom 节点的主要有三种情况:

  1. ChildDeletion -> 删除
  2. Placement -> 插入(移动)
  3. Update -> 更新

2.3.1 ChildDeletion -> 删除

首先来看看 ChildDeletion(删除)的逻辑,在 recursivelyTraverseMutationEffects中。这是一个深度的递归,遍历顺序与上文的一致,依旧是 儿子 -> 兄弟 -> 父级

function recursivelyTraverseMutationEffects(root, parentFiber, lanes) {
    var deletions = parentFiber.deletions;
    if (deletions !== null) {
      // 删除逻辑...
    }
    if (parentFiber.subtreeFlags & MutationMask) {
      var child = parentFiber.child;
      while (child !== null) {
        setCurrentFiber(child);
        commitMutationEffectsOnFiber(child, root);
        child = child.sibling;
      }
    }
  }

这里的 parentFiber 就是当前处理的 fibe node。我们会在这儿处理 ChildDeletion 的逻辑,也就是遍历当前 fiber 的 deletions 依次删除。(#1)

2.3.2 Placement -> 插入(移动)

走出 recursivelyTraverseMutationEffects 后,我们开始向上一层层的处理 fiber node 的 Placement(插入、移动) 逻辑,这部分在 commitReconciliationEffects 下的 commitPlacement

function commitPlacement(finishedWork) {
    var parentFiber = getHostParentFiber(finishedWork); 
    switch (parentFiber.tag) {
      case HostComponent:
        {
          var parent = parentFiber.stateNode;

          if (parentFiber.flags & ContentReset) {
            // Reset the text content of the parent before doing any insertions
            resetTextContent(parent); // Clear ContentReset from the effect tag
            parentFiber.flags &= ~ContentReset;
          }

          var before = getHostSibling(finishedWork); // We only have the top Fiber that was inserted but we need to recurse down its
          // children to find all the terminal nodes.
          insertOrAppendPlacementNode(finishedWork, before, parent);
          break;
        }
       // ...
    }
  }

首先,我们要找到当前 fiber node 的 before。对于我们这个 case 来说,由于 abcd都对应一个真实的 dom 节点,所以可以简单的认为当某个 fiber node2 同时满足以下两个条件,则为 before(#2):

  1. 这个 fiber node2fiber node 后面
  2. 这个 fiber node2没有标记 Placement

此时根据 before 的有、无,分为两种处理情况:

  1. 若有,则调用 dom.insertBefore(child, beforeChild);
  2. 若无,则直接 dom.appendChild(child);

2.3.3 Update -> 更新

commitRoot 阶段处理的更新逻辑实际上比较简单:

  1. 对于 fiber node 上的 props 就直接使用 completeUnitOfWork 阶段拿到的 fiber.updateQueue 遍历即可,关键函数为 commitHookEffectListMount。至于这个 fiber.updateQueue 怎么形成的就不在本文讨论了
  2. 对于 dom 节点的更新分为多种情况(#3),对于我们的这个 case 来说,实际上是实时渲染了 count,所以也就是简单更新了 a div 下的 TextNode 节点(在 React 中标记为 HostText

2.4 React更新小结

前面描述了 React 更新的大致流程,上文有 3 个 # 标记,我们在这里再简要讨论一下

  1. 删除相关的逻辑:我们的 case 中 diff 处理的 fiber node 对应的都是 dom 节点,如果是其他的情况呢?FunctionComponentClassComponent?对于 FunctionComponent 来说,涉及 useEffect 返回的回调函数,也就是 destroy 的处理;对于 ClassComponent 来说就涉及相关的生命周期的处理。当然,还有其他的情况,React.Fragment 等等,各位可以从源码中找寻答案

  2. 寻找 before相关的逻辑:同样的,如果当前 fiber node 的下一个 fiber node 是一个 FunctionComponent 或者 ClassComponent怎么办呢?这里显然的,是需要深度的去查找,找到第一个对应真实 dom 节点的 fiber node(在 React 中标记为 HostComponent

  3. 对于 dom 节点的更新:这里分为多种情况。简单的情况是处理 TextNode,还有一些其他的情况,例如 inputselectradio 等等,各位可以从源码中寻找答案,关键函数 updateProperties

3 简要实现一个 diff 算法

前置提要:

  1. 再次强调一下,diff 算法是用来比对 new fiber node 和 old fiber node 的算法,下文会多次涉及这两个名词的使用
  2. 如何判断两个 fiber node 相同?简单的说就是:同一种元素,且key相等
  3. 咱们比较的元素均为 div
  4. 下文定义的 Fiber 等等类是简化版

3.1 最基本的两种情况:单节点、多节点

首先我们来到 diff 的入口 beginWork$1,一路往下,我们会走到 reconcileChildren 正式开启 diff 的逻辑

function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
    if (current === null) {
      // If this is a fresh new component that hasn't been rendered yet, we
      // won't update its child set by applying minimal side-effects. Instead,
      // we will add them all to the child before it gets rendered. That means
      // we can optimize this reconciliation pass by not tracking side-effects.
      workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
    } else {
      // If the current child is the same as the work in progress, it means that
      // we haven't yet started any work on these children. Therefore, we use
      // the clone algorithm to create a copy of all the current children.
      // If we had any progressed work already, that is invalid at this point so
      // let's throw it out.
      workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
    }
  }

注释已经写的很明白了,如果 currentnull 是 first paint 的逻辑,否则是更新的逻辑。而 diff 的逻辑显然是在更新流程中的,也就是我们会走到 reconcileChildFibers 中。

function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
      // ...
      if (isUnkeyedTopLevelFragment) {
        newChild = newChild.props.children;
      }
      // ...
      if (typeof newChild === 'object' && newChild !== null) {
            // ...
            case REACT_ELEMENT_TYPE:
                // ... 单节点逻辑
                return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
      }
      if (isArray(newChild)) {
          // ... 多节点逻辑
          return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
      }
      // ...
    }

这里对 reconcileChildFibers 做了删减,里头调用的逻辑还是比较复杂的,这里我们重点关注两个函数:placeSingleChild 以及 reconcileChildrenArray。前者是处理单节点的,后者是处理多节点的。注意这里的单节点和多节点指的是 new fiber node,也就是 newChild

3.2 单节点逻辑

placeSingleChild 单节点逻辑的处理比较简单,我们这里就不看源码了,简要叙述一下:

  1. 若 old fiber node 为空,显然我们直接标记 Placement(表示插入)即可
  2. 若 old fiber node 不为空,则从 old fiber node 中寻找有没有与 new fiber node 相同的节点。若有,则跳过 new fiber node ,否则还是 Placement。并删除 old fiber node 中剩余的节点

3.3 多节点逻辑

reconcileChildrenArray 多节点逻辑相对来说复杂一点,从这里开始,我们依据 react 的逻辑,简单的实现一个多节点比对的算法

3.3.1 函数定义

首先我们要明白 diff 算法比对的是什么,old fiber node 我们都知道,实际上是一个 Fiber 的单向链表,由 sibling 连接。而 new fiber node 实际上是一个数组,也就是说,下述代码的 abcd 经过 babel 编译后,实际上会形成一个 array,里头的每一个元素为 JSXElement

   <div key='container'>
      <div onClick={() => {
        setCount(count + 1);
      }} key='a'>a</div>
      {count % 2 === 0 ? <div key='b'>b</div> : null}
      <div key='c'>c</div>
      <div key='d'>d</div>
    </div> 

我们先把两个重要的类定义一下,也就是 FiberJSXElement

class Fiber {
    constructor(key, type, sibling) {
        this.key = key;
        this.type = type;
        this.sibling = sibling || null;
    }
}

class JSXElement {
    constructor(key, type) {
        this.key = key;
        this.tag = type;
    }
}

根据上文描述,我们在 diff 算法中仅标记 PlacementChildDeletion,所以我们把这两者也简单定义一下

const FiberStatus = {
    PLACEMENT: 'Placement',
    CHILDDELETION: 'ChildDeletion'
};

到此,前置条件咱们基本准备好了,当然上面的两个类所需的参数可能会根据后续的需要继续补充。

现在我们不妨抛开 diff 算法,转而思考这么一道力扣题:给你一个单向链表和一个数组,依据某些规则,对两者进行比对,并生成一个新的单项链表

我们可以写出函数签名,为什么这个数组要命名为 newChildren ?可以回头看看 2.2

/**
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (node, newChildren) => {
    // rule 1
    // rule 2
    // rule 3
    // rule 4
    return new Fiber('key', 'div');
}

接下来我们一步步的补充上文所述的某些规则

3.3.2 规则 0

首先我们要给 Fiber 添加几个属性:

  1. index:这就是简单的序号,直接用一个例子来说明
// 若我们要根据下列数组里的 key,创建一个 Fiber list
const list1 = ['a', 'b', 'c', 'd'];

const createFiberList = (list) => {
    const dummyHead = new Fiber('#', '#');
    let p = dummyHead;
    for (let i = 0; i < list.length; i++) {
        const fiber = new Fiber(list[i], 'div');
        fiber.index = i;
        p.sibling = fiber;
        p = p.sibling;
    }
    return dummyHead.sibling;
};

createFiberList(list1);
  1. flags:代表我们给当前 fiber 标记上的标签
  2. alternate:若 新/旧 两个fiber node 相等,则该值指向 新/旧 fiber node,也就是说若 新/旧 fiber 相等 newFiber.alternate = oldFiber; oldFiber.alternate = newFiber

在多节点 diff 逻辑中我们要维护一个非常重要的变量 lastPlacedIndex,并且实现一个非常重要的函数 palceChild

  • lastPlacedIndex 代表什么我们暂且不管,我们先了解他是怎么更新的

    • 初始 lastPlacedIndex 为 0
    • 每次比较 新/旧 节点时,先由新节点的 alternate 找到旧节点,
      • 若没有旧节点(也即 alternatenull)则不更新 lastPlacedIndex,并且给新节点标记上 Placement 的标签
      • 若有旧节点,则将 lastPlacedIndex 更新为 Math.max(lastPlacedIndex, oldFiber.index),并且这个判断当 lastPlacedIndex 更大时,我们标记当前节点为 Placement
  • palceChild 就是更新 lastPlacedIndex 的函数,有了上述的逻辑梳理,这个函数也就呼之欲出了

const palceChild = (lastPlacedIndex, newFiber, newIndex) => {
    newFiber.index = newIndex;
    const oldFiber = newFiber.alternate;
    if (oldFiber !== null) {
        if (oldFiber.index < lastPlacedIndex) {
            newFiber.flag = FiberStatus.PLACEMENT;
            return lastPlacedIndex;
        } else {
            return oldFiber.index;
        }
    } else {
        newFiber.flag = FiberStatus.PLACEMENT;
        return lastPlacedIndex;
    }
};

为什么要这么更新 lastPlacedIndex?我们还是用一个示例来简单说明:

旧 fiber list 如下(用 ' 来表示

    b' -> c'
    0  -- 1

新 fiber list 如下

    c -> b
    0 -- 1
  1. 首先 lastPlacedIndex 是 0
  2. 开始比对,c 节点对应的旧节点 c' 的 index 为 1,大于 lastPlacedIndex,则更新 lastPlacedIndex 为 1
  3. 现在 lastPlacedIndex 是 1
  4. 比对到 b 节点,其对应的旧节点的 index 为 0,严格小于 lastPlacedIndex,标记其为 Placement,不更新 lastPlacedIndex
  5. 这样我们在操作真实 dom 的时候,直接 parrentDom.appendChild(b),就将 b 挪到 c 后面了

其他情况呢?各位可以自行思考一下

  • 这里我们再说下这个 lastPlacedIndex 代表什么:在遍历这个链表的过程中,不停的更新 oldIndex 的最大值作为 lastPlacedIndex,如果当前的 oldIndex 为空或者当前 oldIndex 小于最大值,则给当前 fiber 标记 Placement
  • 图解:

image.png

3.3.3 规则 1

  • 规则1:对于单向链表数组,我们均从头开始遍历比对,若key相等则根据比对两个 fiber node 来判断是否标记 Placement,否则直接跳出遍历

首先,我们特殊定义一个函数 createFiber 判断 新/旧 fiber 是否相等。若相等则根据数组里的元素构造一个节点,若不相等则返回 null

这里我们要给 Fiber 添加几个属性了:

  1. stateNode:指向该 fiber 对应的真实 dom 元素
  2. sibling:指向下一个 fiber node。规则1说了我们要对单向链表进行遍历,而这个单向链表是 Fiber 构成的,它由 sibling 连接起来
/**
 * @param {Fiber} node 
 * @param {JSXElement} newChild 
 * @returns {Fiber | null}
 */
const createFiber = (node, newChild) => {
    if (newChild.key === node.key) {
        const newFiber = new Fiber(newChild.key, newChild.type);
        if (newChild.type === node.type) {
            newFiber.alternate = node;
            node.alternate = newFiber;
            newFiber.stateNode = node.stateNode;
            return newFiber;
        } else {
            newFiber.stateNode = createDom(newFiber.type);
            return newFiber;
        }
    }
    return null;
};

可以看到 createFiber 函数:

  1. 若两者的 key 相等,则依据数组的 JSXElement 创建一个 new fiber node

    a. 若两者 type 仍然相等,则两个 fiber node 相等,我们用 alternate 将两者连接起来,并且,new fiber node 复用 old fiber node 的真实 dom 元素,也就是 stateNode

    b. 若两者 type 不相等,我们则根据数组的 JSXElementtype 创建一个新的 dom 元素,并将它赋值给 new fiber node 的 stateNode(#4)

  2. 若两者的 key 就不相等,则返回 null

createDom 创建真实 dom 元素直接 document.creatElement 就行了,但由于我们将在 node 环境下运行,所以先简要的如下表示

/**
 * @param {string} type 
 * @returns {string}
 */
const createDom = (type) => {
    return type;
};

回顾上文的 #4 处,我们可以想一下,如果这个 fiber node 对应的 dom 节点是新创建的,那显而易见的这个 fiber node 应该是 Placement,因为我们显然要将这个新的 dom 节点插入到 dom 树中。那我们如何给 fiber node 标记的 Placement 呢?

createFiber 代码中可以看到,若是执行 #4 的逻辑,则其 alternate 为空,所以我们应该接着执行 placeChild(详见规则 0)中就会给其标记 Placement

综上所述,规则1的代码如下

/**
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (node, newChildren) => {
    // rule 1
    let newIndex = 0;
    let lastPlacedIndex = 0;
    let resultNode = null;
    let prevNewNode = null;
    let oldFiber = node;
    const n = newChildren.length;
    for (; newIndex < n && oldFiber !== null; newIndex++) {
        const child = newChildren[newIndex];
        const newFiber = createFiber(oldFiber, child);
        if (newFiber === null) {
            break;
        }
        lastPlacedIndex = palceChild(lastPlacedIndex, newFiber, newIndex);
        if (prevNewNode === null) {
            resultNode = newFiber;
            prevNewNode = newFiber;
        } else {
            prevNewNode.sibling = newFiber;
            prevNewNode = prevNewNode.sibling;
        }
        oldFiber = oldFiber.sibling;
    }
    // rule 2
    // rule 3
    // rule 4
    return resultNode;
}

ok,到此我们简要的实现了一个满足规则1的算法。现在我们还有一个问题,那就是我们返回的这个 fiber list 是要放进 fiber tree 的,所以我们还要拿到当前的父节点。

当前的父节点怎么获取的呢?其实这是一个伪命题,还记得 2.2 所描述的吗?我们比较的实际是在当前 fiber 的子层级进行的,回忆一下函数签名,我们的这个函数应该这样调用

/**
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (node, newChildren) => {
    // rule 1
    // rule 2
    // rule 3
    // rule 4
    return new Fiber('key', 'div');
}
// newChildren 由 babel 解析而来
reconcileChildrenArray(fiber.child, newChildren);

所以我们当然能获得父节点了,更改一下函数签名和调用方式

/**
 * @param {Fiber} returnFiber
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (returnFiber, node, newChildren) => {
    // rule 1
    // rule 2
    // rule 3
    // rule 4
    return new Fiber('key', 'div');
}
// newChildren 由 babel 解析而来
reconcileChildrenArray(fiber, fiber.child, newChildren);

再回头看一下 2.2 所描述的 fiber tree 结构,我们给 Fiber 新增 returnchild 两个属性,并新增两行代码,则最终的规则1代码如下

/**
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (node, newChildren) => {
    // rule 1
    let newIndex = 0;
    let lastPlacedIndex = 0;
    let resultNode = null;
    let prevNewNode = null;
    let oldFiber = node;
    const n = newChildren.length;
    for (; newIndex < n && oldFiber !== null; newIndex++) {
        const child = newChildren[newIndex];
        const newFiber = createFiber(oldFiber, child);
        if (newFiber === null) {
            break;
        }
        lastPlacedIndex = palceChild(lastPlacedIndex, newFiber, newIndex);
        if (prevNewNode === null) {
            resultNode = newFiber;
            prevNewNode = newFiber;
            newFiber.return = returnFiber; // 新增
            returnFiber.child = newFiber; // 新增
        } else {
            prevNewNode.sibling = newFiber;
            prevNewNode = prevNewNode.sibling;
        }
        oldFiber = oldFiber.sibling;
    }
    // rule 2
    // rule 3
    // rule 4
    return resultNode;
}

最后我们根据上文给 Fiber 新增的属性,再重写一下 Fiber

class Fiber {
    constructor(key, type, index, stateNode, sibling) {
        this.index = index || 0;
        this.sibling = sibling || null;
        this.stateNode = stateNode || null;
        this.key = key;
        this.type = type;
        this.flags = null;
        this.alternate = null;
        this.return = null;
        this.child = null;
    }
}

3.3.4 规则 2 和 规则 3

这两个比较简单,我们放在一起说

规则2:如果数组(newChidren)遍历完了,那么我们给父节点标记 ChildDeletion 并对剩余的单项链表放进父节点的 deletions

规则3:如果单向链表(node)遍历完了,那么我们对剩余的数组标记新增

/**
 * @param {Fiber} returnFiber 
 * @param {Fiber} node 
 * @param {JSXElement[]} newChildren 
 * @returns {Fiber}
 */
const reconcileChildrenArray = (returnFiber, node, newChildren) => {
    // rule 1
    // rule 2
    if (newIndex >= n) {
        returnFiber.flags = FiberStatus.CHILDDELETION;
        while (oldFiber !== null) {
            deleteChild(returnFiber, oldFiber); // Q1
            oldFiber = oldFiber.sibling;
        }
        return resultNode;
    }
    // rule 3
    if (oldFiber === null) {
        for (; newIndex < n; newIndex++) {
            const newFiber = createFiber(/* Q2 */, newChildren[newIndex]);
            lastPlacedIndex = palceChild(lastPlacedIndex, newFiber, newIndex);
            if (prevNewNode === null) {
                resultNode = newFiber;
                prevNewNode = newFiber;
                newFiber.return = returnFiber;
                returnFiber.child = newFiber;
            } else {
                prevNewNode.sibling = newFiber;
                prevNewNode = prevNewNode.sibling;
            }
        }
        return resultNode;
    }
    // rule 4
}

我们解决上述代码块的两个Q

Q1: 实现 deleteChild 函数。

A1: 再一次回忆 2.2,我们的 Fiber 得先有一个 deletions 数组,用来存储要删除的节点,所以简单实现如下:

const deleteChild = (returnFiber, child) => {
    returnFiber.deletions.push(child);
};

Q2: 这里 oldFiber 已经为空了,我们调用 createFiber 时第一个参数传什么呢?

A2: 这里根据规则 3,剩余的数组标记新增,我们这里可以简单的给第一个参数传 null 来代表这种情况。

除此之外,如果 newChildnull 呢?这种情况其实也非常常见,例如下面这段代码

const Test = () => {
    const f = true;
    return (
        <div key='container'>
              <div key='a'>a</div>
              {f
                  ? <div key='b'>b</div>
                  : null
              }
              <div key='c'>c</div>
              <div key='d'>d</div>
        </div>
    )
}

如果 ftrue 时,那我们的 newChildren 自然为 [a,b,c,d]

如果 ffalse,那根据我们的判断 newChildren 自然为 [a, null, c, d]

综上,改造后的函数如下

/**
 * 
 * @param {Fiber | null} node 
 * @param {JSXElement} newChild 
 * @returns {Fiber | null}
 */
const createFiber = (node, newChild) => {
    if (newChild && node === null) {
        const newFiber = new Fiber(newChild.key, newChild.type);
        newFiber.stateNode = createDom(newChild.type);
        return newFiber;
    }
    if (newChild && newChild.key === node.key) {
        const newFiber = new Fiber(newChild.key, newChild.type);
        if (newChild.type === node.type) {
            newFiber.alternate = node;
            node.alternate = newFiber;
            newFiber.stateNode = node.stateNode;
            return newFiber;
        } else {
            newFiber.stateNode = createDom(newFiber.type);
            return newFiber;
        }
    }
    return null;
};

3.3.5 规则 4

如果规则 2 和规则 3 都走不进去,那么我们进入规则 4 的逻辑

规则4:将单向链表剩余的节点构造成一个 Map,继续遍历单向链表数组,每次创建 new fiber node 时,由创建的 Map 快速判断并找到对应的旧节点进行创建。每次创建完成后,都要调用 placeChild 来判断当前的 new fiber node 是否需要标记 Placement。遍历完成后若单向链表还有剩余,则将他们全部放进父节点的 deletions

构造 Map 时,以当前 fiber node 的 key 为 key,当前 fiber node 为 value

/**
 * @param {Fiber} returnFiber
 * @param {Fiber | null} node 
 * @param {JSXElement} newChild 
 * @returns {Fiber | null}
 */
const reconcileChildrenArray = (returnFiber, node, newChild) => {
    // rule 1
    // rule 2
    // rule 3
    // rule 4
    const oldFiberMap = createMapFromOldFiberList(oldFiber);
    for (; newIndex < n; newIndex++) {
        const child = newChildren[newIndex];
        const newFiber = createFiberFromMap(oldFiberMap, child);
        lastPlacedIndex = palceChild(lastPlacedIndex, newFiber, newIndex);
        if (prevNewNode === null) {
            resultNode = newFiber;
            prevNewNode = newFiber;
            newFiber.return = returnFiber;
            returnFiber.child = newFiber;
        } else {
            prevNewNode.sibling = newFiber;
            prevNewNode = prevNewNode.sibling;
        }
    }
    if (oldFiberMap.size > 0) {
        returnFiber.flags = FiberStatus.CHILDDELETION;
        oldFiberMap.forEach((node) => {
            deleteChild(returnFiber, node);
        });
    }
    return resultNode;
 }

这里新来了两个函数:

  • 第一个是 createMapFromOldFiberList,这个逻辑比较简单,就直接列出了
/**
 * @param {Fiber} node 
 * @returns {Map<string, Fiber>}
 */
const createMapFromOldFiberList = (node) => {
    const map = new Map();
    let tmp = node;
    while (tmp !== null) {
        map.set(tmp.key, tmp);
        tmp = tmp.sibling;
    }
    return map;
};
  • 第二个是 createFiberFromMap,这个逻辑与 createFiber 一致,我们需要加的就是若当前的 key 在 Map 中存在,则创建完成 new fiber node 后需要将其删除。注意根据前文所述,这里的 newChild 也是可能为 null
/**
 * @param {Map<string, Fiber>} oldFiberMap 
 * @param {JSXElement} newChild 
 * @returns {Fiber}
 */
const createFiberFromMap = (oldFiberMap, newChild) => {
    if (newChild === null) {
        return null;
    }
    const key = newChild.key;
    const node = oldFiberMap.get(key) || null;
    const newFiber = createFiber(node, newChild);
    if (oldFiberMap.has(key)) {
        oldFiberMap.delete(key);
    }
    return newFiber;
};

3.3.6 多节点逻辑小结

可以看出多节点逻辑的关键实际上是 规则 0 的逻辑,而要弄明白 规则 0 的逻辑,了解 react 更新流程必不可少,所以之前 2.1 - 2.4 的描述都是非常重要的。这里我们再举一个相对复杂一点的例子来理解 规则 0 为什么要这么做

旧 fiber list

a' -> b' -> c' -> d' -> f'
0 --- 1 --- 2 --- 3 --- 4

新 fiber list

c -> b -> a -> f -> d
0 -- 1 -- 2 -- 3 -- 4
  1. lastPlacedIndex = 0
  2. 从头开始比对, c 对应 c'c'index 为 2 大于 lastPlacedIndex,更新 lastPlacedIndex
  3. lastPlacedIndex = 2
  4. 比对到 b,对应 b'b'index 为 1 小于 lastPlacedIndex,不更新,但标记 bPlacement
  5. lastPlacedIndex = 2
  6. 比对到 a,对应 a'a'index 为 0 小于 lastPlacedIndex,不更新,但标记 aPlacement
  7. lastPlacedIndex = 2
  8. 比对到 f 对应 f'f'index 为 4 大于 lastPlacedIndex,更新 lastPlacedIndex
  9. lastPlacedIndex = 4
  10. 比对到 d,对应 d'd'index 为 3 小于 lastPlacedIndex,不更新,但标记 dPlacement

所以最后我们得到 fiber list 对应的状态是什么呢?

c: null -> b: Placement -> a: Placement -> f: null -> d: Placement
0 -------- 1 ------------- 2 ------------- 3 -------- 4

对照 2 部分的描述,我们最终遍历新 fiber list,应该进行如下更新:

  1. c,无标签,不处理,此时视图为 a - b - c - d - f
  2. b,找 before,有吗?有,是 f,我们调用 parentDom.insertBefore(b, f);,此时视图为 a - c - d - b - f
  3. a,找 before,有吗?有,是 f,我们调用 parentDom.insertBefore(a, f);,此时视图为 c - d - b - a - f
  4. f,无标签,不处理,此时视图为 c - d - b - a - f
  5. d,找 before,有吗?没有,我们调用 parentDom.appendChild(d);,此时视图为 c - b - a - f - d

4 小结

  • diff 算法中一般认为比较复杂的就是移动的逻辑了,这个在文中已经举例说明了,至于删除和新增了逻辑各位可以自己试一试,最重要的是了解 React 更新流程,明白删除实际上执行了什么操作,新增实际上又执行了什么操作
  • 源码:github.com/wine-fall/b…