react 引入fiber之后的diff算法

5,152 阅读8分钟

一、概览

  众所周知,react diff算法是在更新时,根据state的更新,计算出变化的节点,再渲染。那么diff是通过什么和什么对比呢,又是如何对比的呢?
  首先,我们知道react 16.3之后采用fiber的数据结构,在更新时会存在两个fiber树(虽然不是树结构,为描述方便,称之为树),同时还会存在页面中的真实dom树以及render阶段的jsx对象,如下:

  1. current fiber
  2. alternate fiber,也可称为workInProgress fiber
  3. 真实dom
  4. jsx react做的就是对比上面的1和4,最终生成2,最后将2渲染到页面中。
    那么diff是在什么阶段发生的呢?react可以分为三个阶段,分别是
  5. schedule
  6. reconcile
  7. render diff算法就是发生在reconcile这个阶段,reconcile会根据state的变化生成jsx,对比jsx和current fiber得到workInProgress fiber。最后提交给render,渲染到浏览器中。

二、diff算法瓶颈

  我们知道diff算法就是循环去对比current fiber和jsx,从算法的角度看,它的复杂度是O(n3),如果页面dom体量大的话,这会是一个相当大的开销,那么react是如何解决这个算法瓶颈呢?react通过三个前提来优化这个算法,分别是:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。   通过这样的限制,最终简化算法。基于以上三点限制,我们看看diff到底是怎样的?

三、diff的入口函数

  我们从Diff的入口函数reconcileChildFibers出发, reconcileChildFibers函数在reconcile的beginWork阶段(即阶段)调用

/**
    returnFiber 父节点fiber
    currentFisteChild 当前对比的fiber
    newChild 更新后的jsx对象
    lanes 优先级,此文不涉及
*/
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
    // 处理fragment类型
    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));
        case REACT_PORTAL_TYPE:
          // do something
        case REACT_LAZY_TYPE:
          // do something
      }
    }
    // 处理多节点
    if (isArray$1(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
    }
    ...more
}

  reconcileChildFibers接收currentFisteChild和newChild,最终生成新的workInProgress fiber。可以看到首先会判断newChild的类型,对不同类型的newChild有不同的处理,我们重点看下单节点和多节点两种情况,即typeof newChild === 'object' 和 isArray$1(newChild) === true两种情况

四、单节点diff

  这里的单节点是指更新后得到的jsx是单节点,单节点diff最终会进入reconcileSingleElement

function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
    var key = element.key;
    var child = currentFirstChild;
    // 处理多节点变单节点情况,一直循环,直到找到key和type相同的fiber或者遍历了所有child和其子节点
    while (child !== null) {
      if (child.key === key) {
        switch (child.tag) {
          case Fragment:
           // 针对fragment,deleteRemainingChildren删除该fiber节点及其所有兄弟节点,调用useFiber,复用该节点并返回
          default:
            {
            // 重点关注,如果key相同且type相同,deleteRemainingChildren删除该fiber节点及其所有兄弟节点(因为是单节点,已经找到目标fiber,之后的兄弟节点无需再处理,直接删除),useFiber复用该fiber节点并返回
              if (child.elementType === element.type || ( // Keep this check inline so it only runs on the false path:
               isCompatibleFamilyForHotReloading(child, element) )) {
                deleteRemainingChildren(returnFiber, child.sibling);

                var _existing = useFiber(child, element.props);

                _existing.ref = coerceRef(returnFiber, child, element);
                _existing.return = returnFiber;

                {
                  _existing._debugSource = element._source;
                  _existing._debugOwner = element._owner;
                }

                return _existing;
              }

              break;
            }
        } // Didn't match.

		// key相同,type不相同(比如div变成p),deleteRemainingChildren删除该fiber节点及其所有兄弟节点(因为是单节点,已经找到目标fiber,之后的兄弟节点无需再处理,直接删除)
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
      // key不相同,给child打上flags为Deletion
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }
	// 如果以上未找到element对应的child(没有相同key),即认为element是新增的节点
    if (element.type === REACT_FRAGMENT_TYPE) {
      // 处理fragment类型的element
    } else {
      // 根据element创建一个新的fiber并返回,
      var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);

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

从上面代码得知
1. react会遍历child和其兄弟节点,对比jsx和目标fiber(即代码中的child)的key以及节点类型type,判断是否存在与element有相同key和type的child
  存在,则复用该child属性生成新的fiber,并将新生成的fiber的pendingProps赋值为element.props,在reconcile的completeWork阶段(即归阶段)生成updateQueue(数组,i项为更新属性的key,i+1项为更新的值),并为fiber.flags打上Update标签,render阶段会做相应的dom更新
  不存在,删除child节点(child.flags打上Deletion标签,render阶段会删除对应dom节点)

2. 如果遍历所有的child及sibling后未找到目标child,即element是全新的节点,删除所有child及其兄弟节点(child.flags打上Deletion标签,render阶段会删除对应dom节点),并根据element创建新的fiber,并为fiber.flags打上Placement标签

五、多节点diff

  首先,多节点diff可能同时存在节点的新增、删除、更新、移动等我这个情况,按照我们日常的编写代码思维,首先判断节点是属于哪种,然后做相应的逻辑。react在平时的开发中发现,更新出现的概率会远大于新增和删除,所以diff会优先处理更新。
  多节点diff入口函数为reconcileChildrenArray,也是在reconcile的beginWork阶段(即阶段)调用,diff算法会经历两轮遍历:

1.第一轮遍历会处理节点的更新

    // 以下为删减后代码
    var resultingFirstChild = null;
        var previousNewFiber = null;
        var oldFiber = currentFirstChild;
        var lastPlacedIndex = 0;
        var newIdx = 0;
        var nextOldFiber = null;

    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      // 根据newChildren下标和oldFiber的index,找到对比的oldFiber
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
	  // 对比oldFiber和newChildren[newIdx]
      // updateSlot逻辑与以上单节点diff逻辑相同,如果oldFiber可复用(即更新节点),返回值为更新后的fiber,否则返回null
      var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);

      if (newFiber === null) {
      // 如果newFiber为null,退出第一轮循环
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
		
        break;
      }
      // 记录第一遍循环最后一次复用的fiber节点的index,在第二轮遍历时使用
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

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

      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }

举个例子:

  1. 更新前abcd
  2. 更新后adcb 当newIdx === 0,更新前后a可以复用,updateSlot返回更新后的fiber,循环继续
    当newIdx === 1,因为element.type不相同,updateSlot返回null,循环结束,进行第二次遍历,此时lastPlacedIndex === 0

2.第二轮处理第一轮遍历剩下的fiber节点

第一轮遍历结束后,会存在几种情况

  1. newChildren全部遍历完了,oldFiber未遍历完
  2. oldFiber全部遍历完了,newChildren未遍历完
  3. newChildren和oldFiber都未遍历完了
  4. newChildren和oldFiber都遍历完了,此时diff算法结束 第一种情况,newChildren全部遍历完了,oldFiber未遍历完
if (newIdx === newChildren.length) {
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

  代码很简单,因为newChildren都遍历完了,剩下的oldFiber全部是删除的节点,标记为DELETION,在renderer阶段删除

第二种情况,oldFiber全部遍历完了,newChildren未遍历完,处理也比较简单

 if (oldFiber === null) {
  for (; newIdx < newChildren.length; newIdx++) {
    var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);

    if (_newFiber === null) {
      continue;
    }
	// 处理dom移动,记录最后一次复用的fiber节点的index
    lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);

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

    previousNewFiber = _newFiber;
  }

  return resultingFirstChild;
}

  因为oldFiber遍历完了,所以剩下的newChildren全是新增的节点,只要循环遍历剩下的newChildren,创建新的fiber

第三种情况,newChildren和oldFiber都未遍历完了,重点关注一下这个情况

// 遍历剩下oldFiber,生成一个以oldFiber.key为key,oldFiber为value的map
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍历剩下的newChildren,通过key找到existingChildren对应的fiber,进行diff算法
for (; newIdx < newChildren.length; newIdx++) {
  var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);

  if (_newFiber2 !== null) {
    if (shouldTrackSideEffects) {
      // 清理存在的alternate,垃圾回收
      if (_newFiber2.alternate !== null) {
        existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
      }
    }
	// 处理dom移动,记录最后一次复用的fiber节点的index
    lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
	// 将newFiber通过sibling形成一个单向链表
    if (previousNewFiber === null) {
      resultingFirstChild = _newFiber2;
    } else {
      previousNewFiber.sibling = _newFiber2;
    }

    previousNewFiber = _newFiber2;
  }
}

  其中updateFromMap跟单节点diff算法逻辑一致,最终返回一个新的fiber,我们重点看一下placeChild,这个是处理dom移动

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

    if (!shouldTrackSideEffects) {
      // Noop.
      return lastPlacedIndex;
    }

    var current = newFiber.alternate;

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

      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags = Placement;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        return oldIndex;
      }
    } else {
      // 处理newFiber是新增情况
      newFiber.flags = Placement;
      return lastPlacedIndex;
    }
  }

  上面这段代码重点就在于lastPlacedIndex这个字段,lastPlacedIndex记录了最后一个可复用的fiber的index,即oldFiber.index。我们知道要移动一个fiber时,要寻找一个参照物,而lastPlacedIndex就是这个参照物,举个例子:

  1. 更新前abcd
  2. 更新后dabc 经历第一次遍历后,因为d下标对应的oldFiber是a,所以退出循环,此时lastPlacedIndex === 0。

    开始第二次循环,通过遍历newChildren,第一项为d,找到更新前的d,此时更新前的d的index为3 ,即oldFiber.index > lastPlacedIndex,不发生位移, 返回lastPlacedIndex = oldIndex。

    此时遍历到a,a的oldIndex为0,及oldIndex < lastPlacedIndex,a的flags标记为Placement,返回lastPlacedIndex

    依次类推,把bc移动到a后面。我们发现abcd -> dabc移动了abc三个节点,而不是d一个节点,所以在编写代码时尽量避免把元素移动到前面的操作。

      以上就是diff算法的大体内容

六、diff的最终结果是什么

  通过以上内容可知,diff算法的结果是生成新的fiber节点,节点有新增、删除、更新、移动几种情况,那么fiber是如何通过数据来表现这几种情况呢?

  1. 新增,reactDom将fiber的flags打上Placement
  2. 删除,reactDom将fiber的flags打上Deletion
  3. 更新,reactDom将fiber的flags打上Update,并在fiber.updateQueue上保存了更新的属性
  4. 移动,移动和新增一样,只是打上了在fiber的flags打上Placement,那么reactDom是如何知道placement插入的位置呢,我们知道执行dom的插入有两种方法,insertBefore和appendChild,react会优先去找插入fiber的sibling,如果找到了执行insertBefore,如果没有找到就执行appendChild,从而实现了新节点插入位置的准确性。   最后在render阶段,react会根据fiber的flags类型,执行相应的操作。细心的同学可能发现了,在render阶段react难道是从rootFiber开始遍历寻找有flags的fiber进行更新吗?这不是很消耗性能吗?其实不然,react会在completeWork结束后,根据fiber的flags形成一个effectList链表,记录了所有需要增、删、更新的fiber,最终render处理的是这条链表而不是整个fiber树。