阅读 645

React16 diff全面讲解

本文较长,请耐心读完,相信我定有收货,如果有错误欢迎指正。

reconcileChildren这个方法做的事情是对于刚创建的组件,会创建新的子 Fiber 节点,update 组件,将当前组件与该组件在上次更新时对应的 Fiber 节点比较(也就是俗称的 Diff 算法),将比较的结果生成新 Fiber 节点。 

function reconcileChildren(current, workInProgress, nextChildren, renderExpirationTime) {
  // 首次渲染时只有root节点存在current,所以只有root会进入reconcile产生effectTag
  // 其他节点会appendAllChildren形成DOM树
  if (current === null) {
    workInProgress.child = mountChildFibers(
        workInProgress, null, nextChildren, renderExpirationTime
    );
  } else {
    workInProgress.child = reconcileChildFibers(
        workInProgress, current.child, nextChildren, renderExpirationTime
    );
  }
}
复制代码

通过调试,current 首次渲染只有 root 上有值,其他节点为 null,等状态更新时候所有的 current 都会存在了,可以看到传入的参数还有一个 workInProgress,这个就是 react 中用到的双缓冲技术。

current 代表当前展示视图对应的 fiber 树,workInProgress 代表正在构建中的树,通过 alternate 连接。

workInProgress 构建完成后根节点又通过改变 current 指向 workInProgress,所以 wip 又变回了 current 树,其中在构建 wip 树的时候会选择性的复用 current 树节点。

另外通过初始渲染和更新渲染后都返回了 workInProgress.child 作为下个时间分片任务单元。

一、ChildReconciler总览

最上面的代码中有两个分支,初始加载和更新时,调用不同的方法,但是最终都是执行,通过参数区分:

var reconcileChildFibers = ChildReconciler(true);
var mountChildFibers = ChildReconciler(false);
复制代码

但是这个方法 中做了很多的事情,大致看下结构:

function ChildReconciler(shouldTrackSideEffects) {
    function deleteChild(returnFiber, childToDelete) {}
    function deleteRemainingChildren(returnFiber, currentFirstChild) {}
    function mapRemainingChildren(...) {}
    function useFiber(...) {}
    function placeChild(...) {}
    function placeSingleChild(...) {}
    function updateTextNode(...) {}
    function updateElement(...) {}
    function updatePortal(...) {}
    function updateFragment(...) {}
    function createChild(...) {}
    function updateSlot(...) {}
    function updateFromMap(...) {}
    function warnOnInvalidKey(...) {}
    function reconcileChildrenArray(...) {}
    function reconcileChildrenIterator(...) {}
    function reconcileSingleTextNode(...) {}
    function reconcileSingleElement(...) {}
    function reconcileSinglePortal(...) {}
    function reconcileChildFibers(...) {}
    return reconcileChildFibers;
}
复制代码

乍一看这个方法无从下手,从命名来看有插入、更新、删除、调和等关键信息,最后暴露出的是 reconcileChildFibers 方法,所以先从这里入手,大致了解其中做的事情。

二、reconcileChildFibers

function reconcileChildFibers(...): Fiber | null {
  // fragments <>{[...]}</> and <>...</>. 所以经常使用的fragments优化的点体现在这里,直接拿子节点
  ...
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  // Handle object types
  const isObject = typeof newChild === 'object' && newChild !== null;
  //对象的形式
  if (isObject) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(...),
        );
      case REACT_PORTAL_TYPE:
        ...
    }
  }
  //return 1 单个
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(...),
    );
  }
  //return [1,2,3] 节点中有并级  现在的业务中这里是一般必然要走的
  if (isArray(newChild)) {
    return reconcileChildrenArray(...);
  }

  ...

  // 更新删除掉了所有节点,执行删除
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}
复制代码

看下 placeSingleChild 方法:

// shouldTrackSideEffects为true 代表更新操作 标记为插入
function placeSingleChild(newFiber: Fiber): Fiber {
  // alternate存在表示该fiber已经插入到DOM
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.effectTag = Placement;
  }
  return newFiber;
}
复制代码

这里有个疑问为什么 shouldTrackSideEffectsture 也就是更新时候并且 alternate 不存在时候才会标记插入标识,那么初次渲染时候除了 root 节点会被标记为插入,其他的节点都不会进入这个判断。那其他的节点是怎么插入到页面中?答案是在 completeWork 中会创建真实节点(stateNode),也是优化的一个点,如果初始全部为插入标识,那么可想而知这种操作量是很大的,所以初次应该一次性插入。

一些 effectTag 标识:

export const Placement = /*             */ 0b0000000000010; //插入
export const Update = /*                */ 0b0000000000100; //更新
export const PlacementAndUpdate = /*    */ 0b0000000000110; //插入到页面并更新
export const Deletion = /*              */ 0b0000000001000; //删除
export const Ref = /*                   */ 0b0000010000000; //ref
...
复制代码

三、reconcileSingleElement

到了这里就开始了 react 中的 diff 算法,单个节点的调和过程。reactdiff 策略如下:

1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

//returnFiber就是 wip | 
// currentFirstChild就是wip上的alternate的child(如果存在的话) | 
// element是 render返回的
// expirationTime过期时间
function reconcileSingleElement(...): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    //初始渲染这个currentFirstChild直接传的null,所以更新时候才会diff
    while (child !== null) {
      //查看节点是否可以复用
      if (child.key === key) {
        switch (child.tag) {
            ...
          default: {
            //节点类型也相同
            if (child.elementType === element.type) {
              // 相同的老的节点的兄弟节点清空  为了本次可以复用
              deleteRemainingChildren(returnFiber, child.sibling);
              const existing = useFiber(child, element.props);
              ...
              return existing;
            }
            break;
          }
        }
        // key相同但是节点类型不同,无法复用并且兄弟节点也不可复用,全部删除
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // key都不相同 则无法服用,但是其兄弟可能还会复用上次的
        deleteChild(returnFiber, child);
      }
      //既然是单个节点的调和  为何还用while遍历所有兄弟节点呢?
      /**
       * old: a b
       * new: b
       * 如果老节点存在兄弟节点(老节点和新节点不一致),刚好和现在的节点类型一致,这样也可复用
       */
      child = child.sibling;
    }
    //上面如果执行完没有可复用的 则进入这里进行创建
    if (element.type === REACT_FRAGMENT_TYPE) {
      ...
    } else {
      const created = createFiberFromElement(...);
      ...
      return created;
    }
  }
复制代码

单个节点的复用逻辑比较清晰,初次渲染直接 created,更新时判断 key 是否一致,再判断节点类型是否一致,如果条件满足则复用旧的节点。不满足时稍微复杂一些,有如下三个注意点:

**key 相同但 type 不同:**代表更新的单个节点和旧的节点(以及旧节点的兄弟节点)肯定无法复用了,所以执行的方式是删除旧的和兄弟节点(deleteRemainingChildren)。

old:    div > p p p    
new:    div > span
复制代码

**key 不同:**代表更新的单个节点和旧的某个节点无法复用,但是有可能旧的兄弟节点可被复用,所以执行的方式是只删除旧的节点(deleteChild)。

old: p1  p2  p3
new: p2
复制代码

while 循环:通过上面整个例子可以知道为何单个节点还要遍历,这就是 react 的一种优化措施,key 不同,但是有可能兄弟节点还是可复用的,所以继续 child.sibling

四、reconcileSingleTextNode

单个节点

function reconcileSingleTextNode(...): Fiber {
  //旧的节点也是一个 text节点 则可以复用
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    //删除兄弟
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
    //复用
    const existing = useFiber(currentFirstChild, textContent);
    existing.return = returnFiber;
    return existing;
  }
  // 否则创建新的fiber节点,将旧的节点和旧节点的兄弟都删除 
  deleteRemainingChildren(returnFiber, currentFirstChild);
  const created = createFiberFromText(...);
  created.return = returnFiber;
  return created;
}
复制代码

五、reconcileChildrenArray

第一次遍历

子节点是数组的情况,分步骤来看,第一段代码为第一个循环遍历,第一次遍历代表相同位置的比较

//第一次遍历  同位置的比较
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  if (oldFiber.index > newIdx) {
    // oldFiber在本循环的最下面会被赋值为 nextOldFiber,不断的寻找兄弟节点
    //[null, a] => [b, a]
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    //正常的情况下 为了下轮循环,拿到兄弟节点下面赋值给oldFiber
    nextOldFiber = oldFiber.sibling;
  }
  //这里面根据key 判断是否可以复用节点(准确的说,有可能节点类型会不同,其他属性和值相同)
  const newFiber = updateSlot(...);
  //节点无法复用 跳出循环 下方详解
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
  }
  //更新
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      //删除,下方详解
      deleteChild(returnFiber, oldFiber);
    }
  }
  //本次遍历会给新增的节点打 插入的标记
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  ...
  //重新给 oldFiber 赋值继续遍历
  oldFiber = nextOldFiber;
}
复制代码

本次遍历终止条件有两个,第一是循环条件中明显的:

  • 新节点和旧节点数量一致,同时遍历完成,终止 [a,b] =update=> [a,b]
  • 新节点还有,旧节点遍历完,终止 [a,b] =update=> [a,b,c]
  • 新节点遍历完,旧节点还有,终止 [a,b,c] =update=> [a,b]

第二是节点无法复用的情况直接跳出,key 的变化直接导致认为节点不可直接用,因为这种情况是交换位置,等下面会处理:

              [
                <li key="0">0</li>,
                <li key="1">1</li>,
              ] 
           ================
              update
           ================
              [
                <li key="1">0</li>,
                <li key="0">1</li>,
              ]
复制代码

其中会执行 deleteChild 方法,当 key 相同,但是节点类型会变化的情况下才会执行老节点删除标记,同时新节点标记为插入,从代码中看应该 updateSlot 时候并没有直接跳出循环,而是会进入到 updateElement 中会再次判断类型是否相同,类型不同则会走到 new FiberNode 重新创建新节点,所以删除这里可以通过 newFiber.alternatenull 的判断(代表全新节点)。

              [ 
                <li key="0">0</li>,
                <li key="1">1</li>,
              ] 
           ================
              update
           ================
              [
                <li key="0">0</li>,
                <div key="1">1</div>,
              ]
复制代码

同时执行到 placeChild 时候是进行新节点的插入标记(此时只针对本次循环来说,因为下面还会执行这个方法)

if (current !== null) {
    ...
}else{
    newFiber.effectTag = Placement;
}
复制代码

第一次遍历完成后,旧节点还有剩余,则进行删除:

//遍历完成后,新节点遍历完成,但是旧节点还存在,则执行删除  [a,b,c] => [a,b]
if (newIdx === newChildren.length) {
  //删除操作
  deleteRemainingChildren(returnFiber, oldFiber);
  // 返回的是 updateSlot 产生的 newFiber
  return resultingFirstChild;
}
复制代码

新节点还有剩余,则进行插入:

//旧节点遍历完 新节点还有值 [a,b] => [a,b,c]
if (oldFiber === null) {
  //遍历 插入标记
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(...); //创建新的fiber节点
    // 插入标记 
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    ...
  }
  return resultingFirstChild;
}
复制代码

第二次遍历

能进入这次的遍历意味着在第一次的遍历中有 break 中断的情况,比如节点的顺序发生了变化,这次的****遍历理解会比上面更简单

return !flag ? [
    <li key="0">0</li>,
    <li key="1">1</li>,
    <li key="2">2</li>,
  ] : ([
    <li key="1">0</li>,
    <li key="2">2</li>,
    <li key="0">2</li>,
  ])
复制代码

第一次遍历时,通过 updateSlot 方法断定 key 不一致就返回 null,这样断言是节点位置发生改变。其次还需要注意一个点是:新节点为 number | string 类型时候,旧节点如果存在 key,也意味着节点不可复用需要跳出第一次的遍历,因为数字或者字符是没有 key 属性的。

第二次的遍历优先存储一个 map 结构,因为对比的时候,可以直接从 map 中取值,看看是否存在相同的 key 或者 index 标识的旧节点:

function mapRemainingChildren(
  returnFiber: Fiber,  // 这个参数暂时没用
  currentFirstChild: Fiber,
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild;
  while (existingChild !== null) {
    if (existingChild.key !== null) {
      //如果 key 存在 则存储 key
      existingChildren.set(existingChild.key, existingChild);
    } else {
      // 否则就存 index 比如 一些纯文本、数字 节点没有key值
      existingChildren.set(existingChild.index, existingChild);
    }
    // 兄弟节点挨个遍历
    existingChild = existingChild.sibling;
  }
  return existingChildren;
}
复制代码

接着只针对新节点的总数遍历,其中注意 updateFromMapupdateSlot 的区别,内容逻辑是一致的,但是这里不再通过单纯的 key 判定是否可复用,而是通过新建的 map 对象中取旧节点,如果能取到意味着可以复用旧节点,反之创建新节点:

for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(...); // 这里正常情况下会返回值  复用或新创建 和updateSlot不同
  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        // 新的节点是复用老节点  从Map对象中删除旧节点值
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key,
        );
      }
    }
    // 更新index 判定哪些节点需要被标记 插入effectTag
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      //返回时候只需要返回自己   因为兄弟节点都挂载到自己身上了
      resultingFirstChild = newFiber;
    } else {
      //不停的给自己追加兄弟 a--->b--->c--->d
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

if (shouldTrackSideEffects) {
  //旧节点中还存在,没法被复用  这里需要遍历删除
  existingChildren.forEach(child => deleteChild(returnFiber, child));
}
// 只需要返回第一个节点即可   后面兄弟通过 slibling连接
return resultingFirstChild;
复制代码

其中 placeChild 的设计是真正的值得揣摩,算法十分巧妙精简,上面第一轮遍历的时候通过此方法会进行插入标记,本次循环时候会可能触发另一个分支的情况:

function placeChild(...): number {
  newFiber.index = newIndex;
  ...
  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // This is a move.
      newFiber.effectTag = Placement;
      return lastPlacedIndex;
    } else {
      // This item can stay in place.
      return oldIndex;
    }
  } else {
    // 新添加的节点 插入标记
    ...
  }
}
复制代码

引用 《深入React技术栈》中的一段话:此方法是一种顺序优化手段,lastPlacedIndex 一直在更新,初始为 0,表示访问过的节点在旧集合中最右的位置(即最大的位置)。如果新集合中当前访问的节点比 lastPlacedIndex 大,说明当前访问节点在旧集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作。只有当访问的节点比 lastPlacedIndex 小时,才需要进行移动操作。

为了更好的理解这个方法,通过几个例子代码+图形形式来辅助,绿色线条代表复用节点且无操作,黄色代表插入操作,蓝色插入,红色删除:

示例1:

          return !flag ? [
            <li key="0">0</li>,
            <li key="1">1</li>,
            <li key="2">2</li>,
          ] : ([
            <li key="1">0</li>,
            <li key="2">2</li>,
            <li key="0">2</li>,
          ])
复制代码

                

过程描述:

新节点 key1,Map 集合中存在 key1 则取出复用,key1 老节点的 oldIndex 为 1,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 1。
新节点 key2,Map 集合中存在 key2 则取出复用,key2 老节点的 oldIndex 为 2,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 2。
新节点 key0,Map 集合中存在 key0 则取出复用,key0 老节点的 oldIndex 为 0,满足 oldIndex < lastPlacedIndex,则将 key0 标记为插入,返回 lastPlacedIndex。

示例2:

              return !flag ? [  
                <li key="0">0</li>,
                <li key="1">1</li>,
                <li key="2">2</li>,
                <li key="3">2</li>,
              ] : ([
                <li key="1">1</li>,
                <li key="0">0</li>,
                <li key="3">3</li>,
                <li key="2">2</li>,
              ])
复制代码

          

过程描述:

新节点 key1,Map 集合中存在 key1 则取出复用,key1 老节点的 oldIndex 为 1,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 1。
新节点 key0,Map 集合中存在 key0 则取出复用,key0 老节点的 oldIndex 为 0,满足 oldIndex < lastPlacedIndex,则将 key0 标记为插入,返回 lastPlacedIndex。
新节点 key3,Map 集合中存在 key3 则取出复用,key3 老节点的 oldIndex 为 3,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 3。
新节点 key2,Map 集合中存在 key2 则取出复用,key2 老节点的 oldIndex 为 2,满足 oldIndex < lastPlacedIndex,则将 key2 标记为插入,返回 lastPlacedIndex。

示例3:

                    return !flag ? [
                      <li key="0">0</li>,
                      <li key="1">1</li>,
                      <li key="2">2</li>,
                      <li key="3">2</li>,
                    ] : ([
                      <li key="1">1</li>,
                      <li key="5">5</li>,
                      <li key="3">3</li>,
                      <li key="0">0</li>,
                    ])
复制代码

         

过程描述:

新节点 key1,Map 集合中存在 key1 则取出复用,key1 老节点的 oldIndex 为 1,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 1。
新节点 key5,Map 集合中不存在 key5 新建节点,不满足 current !== null,则将 key5 标记为插入,返回 lastPlacedIndex。
新节点 key3,Map 集合中存在 key3 则取出复用,key3 老节点的 oldIndex 为 3,不满足 oldIndex < lastPlacedIndex,返回 oldIndex,并且赋值给 lastPlacedIndex 值更新为 3。
新节点 key0,Map 集合中存在 key0 则取出复用,key0 老节点的 oldIndex 为 0,满足 oldIndex < lastPlacedIndex,则将 key0 标记为插入,返回 lastPlacedIndex。
剩余节点 key2 通过 existingChildren 遍历删除,被复用过的节点因为从 map 集合中已经移除了,所以这里的删除只是为被复用的。

示例4(性能最差的一种情况):

         return !flag ? [
            <li key="0">0</li>,
            <li key="1">1</li>,
            <li key="2">2</li>,
          ] : ([
            <li key="2">2</li>,
            <li key="0">0</li>,
            <li key="1">1</li>,
          ])
复制代码

                       

过程同上,但是这种操作会使得顺序优化算法失去效果,除了最后一个节点没有 effect,其他节点都会被执行插入操作,所以尽量避免将最后一个节点更新到第一个节点的位置操作。

通过这些样例的整理,对 react 中现有的 diff 策略有了一个清晰的认识,相对于调度部分的代码来说这里简直太简单,因为可以进行调试。代码虽然读起来比较简单,但是这种设计思路是非常值得学习和推敲的,尤其是顺序优化的代码。

更多详细的源码内容请关注我的博客:

www.gitsu.cn/article61

文章分类
前端
文章标签