React-Diff解析

187 阅读6分钟

React-Diff解析

在使用React的时候,我们一直被提示要保证数组循环的过程中,每一项都有一个唯一的标识符key,但是有时候却不明白为什么需要有一个唯一的标识符。

它在2个数组进行对比的时候有什么作用, React是怎么做到复用原有的fiber结构的。这整个过程发生在调和阶段,主要是把vnode转换成对应的fiber结构。

原理综述

React的数组虚拟dom对比主要的逻辑在reconcileChildrenArray中,它负责通过key元素类型,查看是否可以利用原有的虚拟dom,如果不能利用,就将React.createElement创建的对象转换成新的vnode

主要分几个部分

  1. 从新vnode列表页面中的第一个vnode开始查看,是否和旧列表中的第一个fiber匹配。如果匹配的话,就继续循环,直到没有匹配的时候,停止循环。
  2. 新的vnode在旧的fiber不存在,但是又没有循环完,说明新的vnode是新建的,走创建流程
  3. 新vnode和旧的fiber列表如果只是位置上的改变,通过一个map映射表进行查找和替换。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {}
// 参数解释
// 1. returnFiber:  列表父元素
// 2. currentFirstChild: 父元素的第一个子元素
// 3. newChildren:  新的React.createElement创建的对象数组,此时还没有变成fiber结构

场景

数组对比主要是分为几种情况

  1. 新增数组元素
  2. 删除了某一个元素
  3. 在原有的基础上进行移动位置

新增数组元素

在原有的列表(A,B,C) 的基础上添加一个D, 新的列表为A,B,C,D, 我们来看看React是怎么操作。 React-diff.png

主要流程:

  1. 在循环新的子列表的时候, 通过调用updateSlot函数,React发现旧的列表的第一个元素oldFiber和新列表第一个元素的key和元素类型都相等(newChildRen[newIdx]),然后就将oldFiber复用给新的元素
  2. 继续循环newChildRen, 同时oldFiber的指针通过oldFiber.sibling后移。直到找不到相等的元素后,结束循环。
  3. 循环结束后,发现oldFiber指向为null, 如果newIdx < newChildren.length就说明之后的节点都是属于新增内容,所以会走到新创建fiber的流程。

删除元素

在原有的列表(A,B,C) 的基础上删除一个C, 新的列表为A,B

React-diff (1).png

主要流程:

  1. 前2步都和新增元素一样,直到newChildren循环完成。
  2. 发现oldFiber还有指向,并且(newIdx === newChildren.length),所以会执行删除逻辑,从父fiberreturnFiber中删除掉oldFiber部分,直到为null

移动元素

在原有的列表(A,B,C)基础上,我们移动元素打乱之前的顺序,新的列表为C,B,A

React-diff (2).png

主要流程:

  1. 再第一次循环新列表的时候,React发现oldFiber和新的列表第一个子元素并不匹配,就结束第一次循环
  2. 此时新的列表元素都没有处理newIdx = 0,旧的元素也没有处理oldFiber不等于null。
  3. React为了降低时间复杂度,通过mapRemainingChildren函数将旧的fiber列表转换成一个map, 赋值给existingChildren
  4. 再次循环newChildren, 从existingChildren中查找是否包含相应的元素,如果存在就复用原有的fiber,如果不存在,就根据元素对象(React.createElement创建的)新建一个fiber。
  5. 在查找的过程中,有一个lastPlacedIndex变量,它主要是记录着旧列表中最大的索引,由于新列表的是从头开始循环的,如果匹配的fiber旧列表中,有fiber的index < lastPlacedIndex, 就标记为需要移动。比如例子中lastPlacedIndex的值为2, 当遍历到B的时候,B在旧列表中的index为1,但是newChildren需要放在C的后面,所以是要移动。 tree (1).png

源码部分

通过上面的图解析,我们大概已经知道了运行的场景,下面我们来通过源码来查看一下距离的流程。首先明白几个名字的作用,不需要去深究内容。

lastPlacedIndex: 记录旧的fiber列表中,已经遍历的最大索引

oldFiber: 旧的fiber列表中的第一个元素。 newChildren: 新的vnode列表

updateSlot: 根据oldFiber和newChildren[newIdx] 判断是否匹配,如果匹配就根据pendingProps更新旧的fiber, 做到复用。如果oldFiber不存在,说明是新建,就根据pendingProps新建一个fiber返回。

placeChild: 找到遍历过程中旧的fiber列表中的最大索引

deleteRemainingChildren:删除旧的fiber

shouldTrackSideEffects: 更新阶段为true, 非更新阶段为false

reconcileChildrenArray接受4个参数

  1. returnFiber: 旧的fiber列表的父容器。
  2. currentFirstChild: 第一个子fiber(上面例子中的A元素)
  3. newChildren: 新的vnode数组,此时还没有调和成fiber
  4. lanes: 更新的优先级

源码分步(第一步)

这个是第一个部分,可以和我们的删除尾部元素相对应,就是保留原有的旧的fiber顺序,遍历完newChildren后,删除对于的旧的fiber。

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {

  var resultingFirstChild = null; // 记录返回的新fiber列表的第一个,之后会用于递归调和
  var previousNewFiber = null; // 记录fiber链,第一个之后通过sibling链接
  var oldFiber = currentFirstChild;
  var lastPlacedIndex = 0; 
  var newIdx = 0;
  var nextOldFiber = null;

  // 旧的fiber存在,开始第一次循环
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling; // 赋值旧的列表中下一个fiber
    }

    // 这里updateSlot是查找新的vnode是否和oldFiber相匹配,如果匹配旧返回新的,如果不匹配就返回null
    var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);

    if (newFiber === null) {
      // TODO: This breaks on empty slots like null children. That's
      // unfortunate because it triggers the slow path all the time. We need
      // a better way to communicate whether this was a miss or null,
      // boolean, undefined, etc.
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      // 没有找到就结束循环
      break;
    }

    if (shouldTrackSideEffects) {
      
      if (oldFiber && newFiber.alternate === null) {
        // 匹配后要删除老的fiber的一些数据
        deleteChild(returnFiber, oldFiber);
      }
    }

    // 记录当前的已经处理的最大索引,用于标记之后的fiber是移动还是新增等
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    if (previousNewFiber === null) {
      // 第一次运行的时候,记录新列表的第一个子fiber
      resultingFirstChild = newFiber;
    } else {
      // 非第一次运行,通过sibling链式新的fiber
      previousNewFiber.sibling = newFiber;
    }

    previousNewFiber = newFiber; 
    oldFiber = nextOldFiber; // 赋值旧的fiber中的下一个
  }

  if (newIdx === newChildren.length) {
    // 新的vnode列表遍历完成后,删除老的还没有使用过的fiber
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }
}

源码分步(第二步)

如果在上面的流程并没有走到return resultingFirstChild中,我们来看看接下来的流程

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
    // xxxxxx 省略上一步的代码
    
    // 此时说明旧的fiber列表已经处理完。
    if (oldFiber === null) {
      // 但是新的vnode列表并没有处理完,说明要根据vnode元素新增fiber
      for (; newIdx < newChildren.length; newIdx++) {
        // 根据上一步未处理完的vnode, 创建新的fiber
        var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);

        if (_newFiber === null) {
          continue;
        }
        // oldFiber为null,标记当前的fiber为新增元素
        lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);

        if (previousNewFiber === null) {
          // 首次处理
          resultingFirstChild = _newFiber;
        } else {
          previousNewFiber.sibling = _newFiber;
        }
        previousNewFiber = _newFiber;
      }

      return resultingFirstChild;
    }
}

源码分步(第三步)

第三步主要是处理位置变化的逻辑,根据旧的fiber列表生成一个map映射,查找复用的fiber节点

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
    // xxxxxx 省略上一步的代码
    
    // 生成旧的fiber的map映射(key或者Index作为key, fiber节点作为value)
    var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
   
    // 再次开始循环   
    for (; newIdx < newChildren.length; newIdx++) {
      // 在map中查找是否有存在的,如果有对应的key存在,就更新对应的fiber并返回,如果没有,就要新增fiber
      var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);

      if (_newFiber2 !== null) {
        if (shouldTrackSideEffects) {
          // _newFiber2的alternate如果不为null,就说明是复用
          if (_newFiber2.alternate如果不为null !== null) {
            // 删除map中的对应元素
            existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
          }
        }

        lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);

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

        previousNewFiber = _newFiber2;
      }
    } // 结束循环

    if (shouldTrackSideEffects) {
      // 处理完后,如果还有存在在map中的,就要进行删除处理,因为新的vnode列表已经转换完成
      existingChildren.forEach(function (child) {
        return deleteChild(returnFiber, child);
      });
    }

    return resultingFirstChild; // 整体结束,返回第一个子元素,用于之后的调和
}

在这里reconcileChildrenArray就完全的执行完成了。

总结

React在调和过程中,采用的复用旧的fiber的过程,整体来说,是通过循环递增的方法,通过标记最大的移动偏移量进行移动标记。

和Vue2.0的双指针对比,这样也有一个弊端,比如下面的一组更新,在Vue中,我们只需要讲A节点移动到最后就可以了,一次移动。但是在React中,我们会首先找到B,然后遍历到C,然后再到A,这样就多了2次的查找。

tree.png