react原理:协调算法,reconcile(diff算法)

1,748 阅读7分钟

点击这里进入react原理专栏

讲解完函数组件和类组件是如何计算状态更新之后,这篇文章讲一下reconcile的流程,也就是我们俗称的diff算法。

reconcile就是构建workInProgress树的流程

类组件的diff入口在finishClassComponent

function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
  markRef(current, workInProgress);
  var didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;

  if (!shouldUpdate && !didCaptureError) {
    // 根据shouldComponentUpdate生命周期决定是否需要更新组件
    if (hasContext) {
      invalidateContextProvider(workInProgress, Component, false);
    }

    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  var instance = workInProgress.stateNode;

  ReactCurrentOwner$1.current = workInProgress;
  var nextChildren;

  if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
    // 出现错误
    nextChildren = null;
    {
      stopProfilerTimerIfRunning();
    }
  } else {
    {
      setIsRendering(true);
      // 执行render方法
      nextChildren = instance.render();
      if ( workInProgress.mode & StrictMode) {
        // 严格模式
        disableLogs();
        try {
          instance.render();
        } finally {
          reenableLogs();
        }
      }
      setIsRendering(false);
    }
  }

  workInProgress.flags |= PerformedWork;

  if (current !== null && didCaptureError) {
    forceUnmountCurrentAndReconcile(current, workInProgress, nextChildren, renderLanes);
  } else {
    // diff的入口
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  }

  workInProgress.memoizedState = instance.state;

  if (hasContext) {
    invalidateContextProvider(workInProgress, Component, true);
  }

  return workInProgress.child;
}

对于函数组件,会在updateFunctionComponent中,renderWithHooks之后,调用reconcileChildren进入diff

入口函数

reconcileChildren方法定义如下:

function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  if (current === null) {
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

当组件是初次加载时,会执行mountChildFibers方法,更新时执行reconcileChildFibers,这两个方法定义如下:

var reconcileChildFibers = ChildReconciler(true);
var mountChildFibers = ChildReconciler(false);

再看ChildReconciler

function ChildReconciler(shouldTrackSideEffects) {
    // ... 
    return reconcileChildFibers
}

shouldTrackSideEffects表示是否有副作用。当组件初次挂载时,显然是没有副作用的,而组件更新可能会涉及到元素的删除,插入等操作,因此shouldTrackSideEffectstrue。接下来看这个方法的返回值:reconcileChildFibers

function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
    var isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== null && newChild.type === REACT_FRAGMENT_TYPE && newChild.key === null;

    if (isUnkeyedTopLevelFragment) {
      newChild = newChild.props.children;
    }

    var isObject = typeof newChild === 'object' && newChild !== null;

    if (isObject) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
      }
        // ...
    }
    
    // ...

    if (isArray$1(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
    }
    // ...
}

这个方法会根据不同的节点类型来进行对应的diff操作:比如对于单个react元素,会执行reconcileSingleElement,对于多个元素,执行reconcileChildrenArray。这也就是单节点diff和多节点diff,下面就会分析这两种算法流程。再开始分析之前,先看一下reconcileChildFibers的参数:

reconcileChildFibers参数.jpg

diff算法的对象:老的fiber树和render方法返回的jsx

下文中老fibercurrent树某一层的fiber链表,新fiberworkInProgress树要构建出的fiber链表,新jsxrender方法返回的react元素

单节点diff

单节点diff是指新的节点为单个节点时的diff流程。单节点diff由三种可能的情况:

  1. fiber为空
  2. fiber有一个节点
  3. fiber有多个节点

单节点diff比较简单,只需要在老fiber中找到keytype与新的jsx节点都相同节点,然后删除剩余老节点即可。如果找不到,删除所有老节点,创建新的节点。

  function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
    var key = element.key;
    var child = currentFirstChild;
    // 循环currentFirstChild这一层的所有老fiber节点
    while (child !== null) {
      if (child.key === key) {
        switch (child.tag) {
          // ...
          default:
            {
              if (child.elementType === element.type || (
               isCompatibleFamilyForHotReloading(child, element) )) {
                // 因为是单节点diff,所以找到key和type均相同的节点后,直接删除所有剩余节点即可
                deleteRemainingChildren(returnFiber, child.sibling);
		// 根据老fiber创建新fiber
                var _existing3 = useFiber(child, element.props);
                _existing3.ref = coerceRef(returnFiber, child, element);
                _existing3.return = returnFiber;
		// 。。。
                // 当找到key和type均相同的节点时,直接return新fiber
                return _existing3;
              }
              break;
            }
        }
	// key相同,但是type不同
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // key不同,直接删除遍历到的老fiber
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }
    // 当没有找到key和type均相同的节点时,根据jsx创建新fiber
    if (element.type === REACT_FRAGMENT_TYPE) {
      var created = createFiberFromFragment(element.props.children, returnFiber.mode, lanes, element.key);
      created.return = returnFiber;
      return created;
    } else {
      var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);

      _created4.ref = coerceRef(returnFiber, currentFirstChild, element);
      _created4.return = returnFiber;
      return _created4;
    }
  }

这里需要注意几个点:

  1. 在执行deleteChilddeleteRemainingChildren内也会调用deleteChild)时会为要删除的就fiber打上Deletiontag,表示这个旧的节点要被删除(注意,并不是真的删除这个节点,而是打上tag)。

  2. 如果要被删除的节点还有子节点,只会在要被删除的节点上打上tag,不会在其子节点上打tag

  3. 当找到可复用的fiber节点时(key和type相同),会创建一个新的fiber节点,并建立新的fiber节点和旧的fiber节点之间的联系,即设置alternate属性。但是当不能复用时,新旧fiber之间的alternate连接是不存在的。

  4. 细心的同学可能发现了,既然旧fiber会被打上Deletion的tag,那么新fiber节点呢?注意reconcileChildFibers有这样的代码:

    return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
    

    placeSingleChild方法中才会对新增的节点打上Placement的tag

单节点diff的流程还是比较简单的,这里再强调一个概念:fiber复用:所谓复用fiber,并非直接使用老的fiber对象,而是要新建一个workInProgress树的fiber对象,新对象中的属性变为新jsx中的属性。而复用fiber的条件是keytype均相同。当发生fiber复用时,新老fiber节点之间会用alternate连接;而不发生fiber复用时,新老fiber之间是不存在相互引用的。这一点非常重要。

多节点diff

单节点diff看完后,来看一下多节点diff。回到reconcileChildFibers方法,这里会做一个特殊处理

function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
    var isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== null && newChild.type === REACT_FRAGMENT_TYPE && newChild.key === null;
    // 如果新的jsx节点是没有key的Fragment节点,则取出它的children
    if (isUnkeyedTopLevelFragment) {
      newChild = newChild.props.children;
    }
    // 多节点diff
    if (isArray$1(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
    }
}

可以看到,多节点diff就是新的jsx存在多个节点时的diff策略。

这里首先说明一下,reactdiff策略并不是不惜代价地复用节点,而是在保证效率的基础上进行复用。比如一个组件发生了跨层级的移动,虽然只是位置上的变化,但是react则不会复用这个节点,这一点很多文章里也讲过,就不展开了。下面讲解一下react的多节点diff策略。

下文中老fibercurrent树某一层的fiber链表,新fiberworkInProgress树要构建出的fiber链表,新jsxrender方法返回的react元素

首先是几个变量

var resultingFirstChild = null;   // 新fiber链表中的第一个fiber节点

var newIdx = 0;                   // 用于循环新jsx数组中的指针
var previousNewFiber = null;      // 新fiber链表中,当前fiber的前一个fiber

var oldFiber = currentFirstChild; // 用于循环旧fiber节点的指针
var nextOldFiber = null;          // 老fiber链表中,当前fiber的下一个fiber

// 老fiber树中最靠右的一个不需要移动的fiber节点,在老fiber树中的位置,下文会讲到
var lastPlacedIndex = 0;          

第一部分

首先看多节点diff的第一部分:针对节点更新的循环

// 循环新fiber和老fiber
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  if (oldFiber.index > newIdx) {
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    nextOldFiber = oldFiber.sibling;
  }
  // 更新fiber节点
  // 如果新的jsx返回null,或者新老fiber的key不同,updateSlot返回null
  // 如果新老fiber的key相同,但是type不同,或者老fiber不存在,说明fiber不能复用(注意前文提到的fiber复用的含义)
  // 如果新老fiber都存在,并且能够复用,则复用fiber
  var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
  // newFiber为null时,直接跳出循环
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
  }
  // 组件更新,shouldTrackSideEffects为true
  // 组件挂载,shouldTrackSideEffects为false,前文有提到
  if (shouldTrackSideEffects) {
    // 如果没有发生fiber复用,说明老fiber被删除
    if (oldFiber && newFiber.alternate === null) {
      deleteChild(returnFiber, oldFiber);
    }
  }
  // 确定新fiber的位置,placeChild方法下文会单独讲
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  // 记录新fiber中的第一个节点
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  previousNewFiber = newFiber;
  oldFiber = nextOldFiber;
}

多节点diff第一部分.jpg

可以看出,只有在新的jsx返回null,或者新老fiberkey不同时,才会中途跳出循环。如果中途跳出了循环,会跳过下文的第二和第三部分,直接进入第四部分

第二部分

如果循环正常结束,没有中途跳出,会进入第二部分:

// 如果新fiber遍历完毕,直接删除旧fiber中的剩余节点即可,并返回resultingFirstChild
if (newIdx === newChildren.length) {
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

第三部分

接下来是第三部分:

// 如果老fiber遍历结束,则剩余的新fiber都是新增节点,直接新增即可
if (oldFiber === null) {
  for (; newIdx < newChildren.length; newIdx++) {
    var _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;
}

这里需要注意,如果第一部分的循环中途退出,则新旧fiber都不会遍历完毕,因此是不会进入第二和第三部分的,而是会直接进入第四部分。

第四部分

// 首先将老fiber链表中没有遍历到的剩余节点放到一个map中,key是fiber的key或者在老fiber链表中的位置索引,value是fiber节点
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// 循环新的jsx数组中的剩余部分
for (; newIdx < newChildren.length; newIdx++) {
  // 如果新的jsx返回null,updateFromMap返回null,跳过本轮循环
  // 新jsx不返回null,从existingChildren中找到与新jsx的key相同的老fiber,看是否能够复用fiber
  var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);

  if (_newFiber2 !== null) {
    if (shouldTrackSideEffects) {
      if (_newFiber2.alternate !== null) {
        // 当新fiber非空,并且新fiber复用了老fiber,说明新老fiber存在对应关系,从existingChildren中删除老fiber
        existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
      }
    }
    // 放置新fiber
    lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = _newFiber2;
    } else {
      previousNewFiber.sibling = _newFiber2;
    }
    previousNewFiber = _newFiber2;
  }
}

多节点diff第四部分.jpg

第五部分

最后还有一个收尾工作,遍历existingChildren,删除掉其中的旧fiber节点,并返回新fiber链表的第一个节点

if (shouldTrackSideEffects) {
  existingChildren.forEach(function (child) {
    return deleteChild(returnFiber, child);
  });
}

return resultingFirstChild;

这样,beginWork就会拿到这个函数的返回值,并返回到performUnitOfWork中,用来修改全局变量workInProgress,从而继续执行workLoopSync循环。

placeChild方法

placeChild是用来确定新fiber节点在新fiber链表中的位置,并返回前文中提到的lastPlacedIndex。接下来看一下代码

function placeChild(newFiber, lastPlacedIndex, newIndex) {
    // 确定新fiber节点的位置索引
    newFiber.index = newIndex;
    if (!shouldTrackSideEffects) {
      // 不采取任何操作
      return lastPlacedIndex;
    }
    var current = newFiber.alternate;
    if (current !== null) {
      // current不为null,说明新老fiber有关联
      var oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 老fiber在lastPlacedIndex左边,无需更新lastPlacedIndex
        newFiber.flags = Placement;
        return lastPlacedIndex;
      } else {
        // 老fiber在lastPlacedIndex的右边,说明在新fiber链表中,对应节点发生了移动
        return oldIndex;
      }
    } else {
      // current为null,说明新老fiber没有关联,直接插入新fiber
      newFiber.flags = Placement;
      return lastPlacedIndex;
    }
}

下面举个例子:

旧fiber链表
A -> B -> C -> D -> E
新fiber链表
A -> B -> D -> E -> C
  1. newIndex为0,进入第一部分的循环,执行到placeChild方法,由于新老fiber节点存在关联,因此current不为空,而oldIndexlastPlacedIndex都是0,因此返回了oldIndex
  2. newIndex为1,和第一步流程相同,也返回了oldIndex(1)lastPlacedIndex变为1
  3. newIndex为2,跳出第一部分的循环,进入第四部分,执行到placeChild方法,newFiber为D节点,current为老fiber链表的D节点,因此current不为空,oldIndex为3,lastPlacedIndex为1,因此返回3,lastPlacedIndex变为3
  4. newIndex为3,和第3步流程相同,lastPlacedIndex变为4
  5. newIndex为4,执行到placeChild方法,newFiber为C节点,current为老fiber链表的C节点,current不为空,oldIndex为2,lastPlacedIndex为4,此时oldIndex小于lastPlacedIndex,因此react认为C节点发生了移动,为其打上Placement的tag

因此,lastPlacedIndex的含义就是:在老fiber链表中,最靠右的一个不需要移动的fiber节点,在老fiber链表中的位置索引。

最后再来个整体的流程图吧

react-reconcile整体流程jpg.jpg