React源码系列之八:React的diff算法

3,160

前言

本次React源码参考版本为17.0.3。这是React源码系列第八篇,建议初看源码的同学从第一篇开始看起,这样更有连贯性,下面有源码系列链接。

热身准备

背景

为了避免大量,频繁操作DOM,React引入了虚拟DOM,在16版本之后又用fiber树替代了虚拟DOM树。但是当发生更新时,如何高效将老的树替换成新的树,这是个难题,按照一些通用的算法解决方案,即使使用最优的算法,算法的复杂度依然为O(n 3 )n是元素的数量。

这样的结果不是React想要的,基于这样的背景,React团队有了更大胆的想法,既然替换树的代价这么大,那我选择重新建一棵树,没错,简单粗暴。

React团队的做法

  1. 当对比两棵树时,首先会比较两棵树的根节点。如果根节点为不同类型的元素,React不会考虑替换根节点再往下递归比对,而是选择放弃这棵树,重新建一棵,在向下递归也是一样的,遇到节点类型不同,就会删除这个节点和下面的所有子节点,然后重新构建;

  2. 当对比两个相同类型的组件元素时:

    • class组件实例保持不变(所以不同的渲染时state是一致的),仅更新组件实例的props
    • 函数组件会重新执行(所以需要hooks记录状态)。
  3. 当对比两个相同类型的React元素时,React会保留DOM节点,仅比对及更新有改变的属性;

  4. 引入key,开发者手动设置元素key值来标记元素,React基于key对元素进行比对;

上面的几点就是Reactdiff算法的结果,好了,弄明白了Reactdiff算法都做了些什么,接下来看下Reactdiff算法是怎么做到这几点的。

beginWork阶段

对比不同类型的元素

代码是我删减后的伪代码,仅供参考。

if (child.elementType === element.type) {
    deleteRemainingChildren(returnFiber, child.sibling);
    
    // clone current fiber并替换pendingProps为当前的element.props
    var _existing3 = useFiber(child, element.props);  

    _existing3.return = returnFiber;
    
    return _existing3;
}
else {
  // 删除之前的fiber
  deleteRemainingChildren(returnFiber, child); 
  
  // 重新根据element创建fiber
  var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);

  _created4.return = returnFiber;
  return _created4;
}

从上面的代码,第一行的判断语句child.elementType === element.type,当React发现更新前后的元素类型不同时,根本不会考虑其他的,直接就把之前的fiber节点给干掉了,不用它了,自己重新根据新的fiber重新构建。

对比相同类型的元素

当两个元素类型相同时,React出于性能的考量会考虑复用已有的fiber,也就是保留DOM元素,但是会替换fiber.pendingProps。因为我们的DOM元素的信息都记录在props上,React直接将新的props替换老的props

if (child.elementType === element.type) {
    deleteRemainingChildren(returnFiber, child.sibling);
    
    // clone current fiber并替换pendingProps为当前的element.props
    var _existing3 = useFiber(child, element.props);  

    _existing3.return = returnFiber;
    
    return _existing3;
}

key的使用

很简单的代码,却有很大的功劳。React会将新老节点的key值进行比较来确定新老节点是不是对应关系,如果是就更新,不是就返回null跳过。

那什么情况下可能会返回null呢?

答案是:

  • 插入新的节点;
  • 删除了节点;
  • 节点顺序有变动;
{
    if (newChild.key === key) {
      if (newChild.type === REACT_FRAGMENT_TYPE) {
        return updateFragment(returnFiber, oldFiber, newChild.props.children, lanes, key);
      }

      return updateElement(returnFiber, oldFiber, newChild, lanes);
    } else {
      return null;
    }
  }

newFiber返回null会进下面的代码,这时oldFiber会通过下面的代码再次重置回上次的值,保证key的有效性,然后会跳出和oldFiber匹配key的循环。

if (oldFiber.index > newIdx) {
    nextOldFiber = oldFiber;
    oldFiber = null;
}
if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
}

在跳出循环后,会去将剩下没有匹配成功的oldFiberkey(没有key,会使用index)为键存储到Map结构中

var existingChildren = mapRemainingChildren(returnFiber, oldFiber)

在接下来, React会从没有匹配成功key的地方遍历newFiber数组,然后从oldFiberMap结构中去匹配剩下的newFiber,做到最大程度的复用,能匹配就匹配(匹配成功后,会从existingChildren将其删除掉),不能匹配就是新增的,会创建一个对应的节点。

for (; newIdx < newChildren.length; newIdx++) {
  var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);

  if (_newFiber2 !== null) {
    if (shouldTrackSideEffects) {
      if (_newFiber2.alternate !== null) {
        existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
      }
    }

    lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);

    if (previousNewFiber === null) {
      resultingFirstChild = _newFiber2;
    } else {
      previousNewFiber.sibling = _newFiber2;
    }

    previousNewFiber = _newFiber2;
  }
}

子节点位置比较

React的一个父节点是相同类型元素,替换了了props后,会继续往下递归,每个子节点的更新方式和父节点的一样:

  • 比较元素类型是否相同;
  • 替换proprs

值得注意的是,当有多个子节点时,可能会有增删节点,或者调换节点顺序的情况,这时候就需要考虑这些子节点位置有没有变化。

  function placeChild(newFiber, lastPlacedIndex, newIndex) {
    newFiber.index = newIndex;

    var current = newFiber.alternate;
    
    if (current !== null) {
      var oldIndex = current.index;

      if (oldIndex < lastPlacedIndex) {
        // 给fiber打上Placement标记,要移动
        newFiber.flags = Placement;
        return lastPlacedIndex;
      } else {
        // 和之前一样,在同一个位置
        return oldIndex;
      }
    } else {
      // 没有老节点的情况,说明这是一个插入操作
      newFiber.flags = Placement;
      return lastPlacedIndex;
    }
  }

子节点更新完毕

当子节点已经更新完了,如果还有老节点,会直接删除,不会考虑再做比对

if (newIdx === newChildren.length) {
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

completeWork阶段

之前的文章有讲过,在completeWork阶段会有个向上查找的过程,如果是更新,它会创建一个更新队列updateQueue,更新的内容就是新老props进行diff后的结果。这里进行diff的代码实在太长,我精简了下

function diffProperties(domElement, tag, lastRawProps, nextRawProps, rootContainerElement) {
  {
    validatePropertiesInDevelopment(tag, nextRawProps);
  }

  var updatePayload = null;
  var lastProps;
  var nextProps;

  switch (tag) {
    case 'input':
      lastProps = getHostProps(domElement, lastRawProps);
      nextProps = getHostProps(domElement, nextRawProps);
      updatePayload = [];
      break;

    case 'option':
      ...  //和上面类似
      break;

    case 'select':
      ...  //和上面类似
      break;

    case 'textarea':
      ...  //和上面类似
      break;

    default:
      lastProps = lastRawProps;
      nextProps = nextRawProps;
      break;
  }

  assertValidProps(tag, nextProps);
  var propKey;
  var styleName;
  var styleUpdates = null;

  for (propKey in lastProps) {
    ...
  }

  for (propKey in nextProps) {
    ...
  }

  if (styleUpdates) {
    {
      validateShorthandPropertyCollisionInDev(styleUpdates, nextProps[STYLE]);
    }

    (updatePayload = updatePayload || []).push(STYLE, styleUpdates);
  }

  return updatePayload;
}

在上面代码中,有区分表单和一般的DOM元素,因为表单可以保存用户交互状态,React需要将表单的状态组装到props中,在下面主要是遍历lastPropsnextProps,区分propstyle, dangerouslySetInnerHTML, children和其他一些属性进行不同的处理,主要是比较lastPropsnextProps,将nextProps没有而lastProps上有的重置,nextProps有且和lastProps不同的加到更新里去。

总结

通篇看下来,是不是感觉Reactdiff算法也不是特别复杂?

事实就是这样的,大道至简

做下总结:

diff算法发生在两个阶段,分别是beginWorkcompleteWork阶段。

beginWork阶段,会比对更新前后的元素类型:

  • 如果不同就删除更新前的元素及其子元素,然后基于新的元素重新构建;
  • 如果相同,会复用DOM元素,仅替换props

当父元素类型相同且有多个子节点时,会有使用key的场景,React会基于key尽可能的复用oldFiber并保证较好的性能。

completeWork阶段,React会将新老节点的props进行一次diff,通过遍历比对,找出其中的变化添加到更新队列中,在commit阶段完成更新渲染。

想了个口诀: 求同不存异,相同就比key,最后比props

看完这篇文章, 我们可以弄明白下面这几个问题:

  1. React为什么要做diff算法?
  2. Reactdiff算法是怎么实现的?
  3. Reactdiff算法发生在哪个阶段?
  4. React的元素的key是做什么的?
  5. React是怎么通过key实现高效diff的?

系列文章安排:

  1. React源码系列之一:Fiber
  2. React源码系列之二:React的渲染机制
  3. React源码系列之三:hooks之useState,useReducer
  4. React源码系列之四:hooks之useEffect
  5. React源码系列之五:hooks之useCallback,useMemo
  6. React源码系列之六:hooks之useContext
  7. React源码系列之七:React的合成事件
  8. React源码系列之八:React的diff算法;
  9. React源码系列之九:React的更新机制;
  10. React源码系列之十:Concurrent Mode;

参考:

React官方文档

github