react diff源码解析

198 阅读9分钟

前备知识

如果应用每发生一次更新都直接更新真实dom,浏览器会频繁的布局(回流)和绘制(重绘),性能会很差。因此react中使用了虚拟dom,当用户与页面产生交互时,将多次更新整合到一颗新的虚拟dom树中,然后一次性同步到视图。

在一个渲染帧内(主流浏览器是60帧,一个渲染帧约为16.7ms),浏览器要执行js代码、布局、绘制,而js线程和gui线程是互斥的,如果执行js的时间过长,gui线程就没有足够的时间布局和绘制,这一帧的画面就会丢失,呈现出的效果就是掉帧。在老的react架构中,如果虚拟dom树过于庞大,页面在更新时就会卡顿,为了解决这个问题react团队使用fiber架构重构了react。

react在更新时要遍历虚拟dom做一些工作,fiber是链表,遍历时可以中断,并且可以从中断处恢复,而遍历树需要递归,做不到从中断处恢复。异步可中断更新的好处是,遍历一定时间fiber后中断,让浏览器在当前渲染帧有足够的时间布局和绘制,在下一渲染帧继续遍历fiber。

每个fiber有return、child、sibling三个指针,父子之间通过return和child互相连接,兄弟节点之间通过sibling单向连接。

fiber中有type属性,如果是函数组件,type是这个函数;如果是类组件,type是class;如果是原生标签,type是字符串,例如div、span等。

react应用的根节点为fiberRoot,它有一个current指针,renderer会根据current所指的fiber树渲染视图。当应用发生更新时,会在内存中构建一颗fiber树,每个fiber被称作workInProgress,当workInProgress树构建好之后,会让fiberRoot的current指针指向workInProgress树,以此达到上文提到的一次性将多个更新同步到视图上的目的。对于workInProgress而言,渲染树的fiber就是oldFiber,它们之间用alternate指针互指,即workInProgress.alternate === oldFiber,oldFiber.alternate === workInProgress。

更多细节可以参考React源码中使用的数据结构 - 掘金 (juejin.cn)

diff简介

更新时,完全根据新元素创建一颗newFiber树是糟糕的,因此需要复用。

diff的作用是找出oldFiber和newElement的异同处,存在差异的部分新建fiber,相同的部分复用oldFiber。

但传统diff的时间复杂度为O(n3)O(n^3),因此react团队提出了一种启发式的diff算法:

  1. 只进行同级比较
  2. 如果type改变直接不复用,即使子节点完全一样(正是因为这种可能性特别低才这么设计)
  3. 通过key进行复用

关于特殊情况:

  1. 如果oldFiber和newElement都只有一个,判断type和props是否发生改变就可以决定是否复用oldFiber;如果oldFiber不存在,newElement只有一个,直接新建newFiber,此逻辑被单节点diff覆盖。
  2. 如果oldFiber不存在,newElement有多个,逐个创建newElement,此逻辑被多节点diff覆盖。

下面看一下diff算法的入口,current === null说明是挂载流程,否则是更新流程,reconcileChildFibers就是diff算法的入口。

export 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,
    );
  }
}

单节点diff

此情形下,oldFiber的数量为0~n,新元素仅有一个。

如果oldFiber链表不为空,找到key相同的oldFiber,其余的oldFiber全部标记删掉,如果type相同则复用。

如果oldFiber链表为空,或者type不同,根据新元素创建newFiber。

详情请看代码及注释(为了让字体清晰,我尽量使用多行注释)。

function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
  const key = element.key;
  let child = currentFirstChild;

  while (child !== null) {
    if (child.key === key) {
      const elementType = element.type;
      if (
        child.elementType === elementType ||
        (typeof elementType === 'object' &&
          elementType !== null &&
          elementType.$$typeof === REACT_LAZY_TYPE &&
          resolveLazy(elementType) === child.type)
      ) {
        /*
         * key和type都相等,将其他oldFiber打上删除标记、复用此oldFiber
         */
        deleteRemainingChildren(returnFiber, child.sibling);
        const existing = useFiber(child, element.props);
        existing.ref = coerceRef(returnFiber, child, element);
        existing.return = returnFiber;
        return existing;
      }
      /*
       * key相同但type不同,不能复用、将所有oldFiber打上删除标记,跳出循环
       */
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      /*
       * key不相等就将此oldFiber打上删除标记,继续往下找
       */
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  
  /*
   * 根据element创建newFiber
   */
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

这里我仅对key相同但type不同,不能复用、将所有oldFiber打上删除标记,跳出循环进行解释,react中不允许同层级有相同的key,key相同就代表着找到了对应的oldFiber,不论能否复用都无须继续找了。

多节点diff

此情形下,oldFiber有多个,新元素也有多个。

if (isArray(newChild)) {
  return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}

reconcileChildrenArray内部有一个主循环,其余逻辑用于处理主循环退出时的各种情况。

理想状况(更新)

此时oldFiber链表和newChildren的顺序是一致的(key一一对应),即只发生了更新,没有移动,即使有插入和删除,也是发生在数组末端。

此时会比较二者的key和type,都相等才能复用;key不等,newFiber会是null导致跳出循环;key相等但type不等,会根据newChildren[newIdx]创建一个newFiber,详情请看代码及注释。

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  if (oldFiber.index > newIdx) { // 跳过空节点
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    nextOldFiber = oldFiber.sibling;
  }
  
  /*
   * 比较oldFiber.key和newChildren[newIdx].key, oldFiber.type和newChildren[newIdx].type
   */
  const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
  /*
   * 如果为null,说明key的顺序改变了,需要跳出循环做其他处理
   */
  if (newFiber === null) {
    if (oldFiber === null) {
      oldFiber = nextOldFiber;
    }
    break;
  }
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      /*
       * newFiber.alternate === null说明这是新创建的fiber而不是复用的oldFiber,因此要打上删除标记
       */
      deleteChild(returnFiber, oldFiber);
    }
  }
  
  /*
   * newFiber.index = newIdx
   * 如果不存在current说明需要把newFiber插入进去
   * 如果存在current,判断是否发生了移动
   */
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

  /*
   * 将newFiber通过sibling连接在一起,也就是组装成链表,resultingFirstChild指向链表头
   */
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  previousNewFiber = newFiber;
  oldFiber = nextOldFiber;
}

这里我解释一下updateSlot的作用,先对比key,如果不相等会返回null,导致触发上述代码第15行的break跳出循环;如果key相等且type相等,复用oldFiber;如果key相等但type不等,会根据newChildren[newIdx]创建一个newFiber。

循环正常结束(仅更新或者末端新增、删除)

说明是理想状况,即只有两种可能:

  1. newChildren遍历完毕导致循环退出
  2. oldFiber遍历完毕导致循环退出

如果是第一种情况,要么是仅发生了更新要么是末端发生了删除,直接调用deleteRemainingChildren(returnFiber, oldFiber)给剩余的oldFiber打上删除标记,然后返回resultingFirstChild。

如果是第二种情况,发生了末端新增,需要依次创建newFiber,然后连接到resultingFirstChild的末端,最后将resultingFirstChild返回。

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

if (oldFiber === null) {
  for (; newIdx < newChildren.length; newIdx++) {
    const 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;
}

循环非正常结束(移动、非末端插入、删除)

此时可能发生了移动、非末端插入、非末端删除,这三者任意组合,无论多复杂的情况,都会在本次loop中处理。

简而言之,就是复杂情境下导致newChildren的顺序被打乱了,此时需要将剩余的oldFiber放在map中,然后通过key来查找可复用的oldFiber。react官方不建议用index作为key的原因就是在这里。

/*
 * 将剩余oldFiber存入map中
 */
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
  /*
   * 通过key查找oldFiber,如果找不到就根据newChildren[newIdx]新建newFiber
   */
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    lanes,
  );

  if (newFiber !== null) {
    if (shouldTrackSideEffects) {
      if (newFiber.alternate !== null) {
        /**
         * newFiber !== null && newFiber.alternate !== null
         * 说明是复用的oldFiber,将这个oldFiber从map里删除
         */
        existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
      }
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

if (shouldTrackSideEffects) {
  /*
   * 将没复用的oldFiber全部打上删除标记
   */
  existingChildren.forEach((child) => deleteChild(returnFiber, child));
}

return resultingFirstChild; // 返回newFiber链表

placeChild

lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)的作用:

  1. 给newFiber.index赋值
  2. 给newFiber的flags赋值,在commit阶段告诉renderer该怎么做

如果newFiber是根据element创建的,newFiber.alternate会是null,这种情况说明它是新增的;如果newFiber是复用的oldFiber,要判断是否需要移动。

如果是新增的或者需要移动,需要打上Placement标记。

function placeChild(newFiber, lastPlacedIndex, newIndex) {
  newFiber.index = newIndex;
  if (!shouldTrackSideEffects) {
    newFiber.flags |= Forked;
    return lastPlacedIndex;
  }
  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      /*
       * 顺序与原本不一致时,在map中找到的oldFiber并且在lastPlacedIndex前面,走此分支
       */
      newFiber.flags |= Placement | PlacementDEV;
      return lastPlacedIndex;
    } else {
      /*
       * 按顺序复用的oldFiber、在map中找到的oldFiber并且在lastPlacedIndex后面,走此分支
       */
      return oldIndex;
    }
  } else {
    /*
     * 根据element创建newFiber走此分支
     */
    newFiber.flags |= Placement | PlacementDEV;
    return lastPlacedIndex;
  }
}

这里我解释一下lastPlacedIndex。

为了方便表述,我将上一个按整体顺序被复用的oldFiber记作lastOldFiber。

从代码中可以看到,只有按整体顺序被复用的oldFiber才会使lastPlacedIndex增加,也就是说lastPlacedIndex的作用是标记lastOldFiber的index。

如果oldFiber.index < lastPlacedIndex,说明原始顺序中oldFiber在lastOldFiber之前,而更新后的位置却在之后,当然要将它移动;

否则,说明原始顺序中oldFiber在lastOldFiber之后,与更新后的位置关系大体一致,中间多余的oldFiber早在之前的流程中被标记删除了,或者在本流程中被标记移动,因此只要位置关系大体一致就不需要移动。

例:

<div key="a">a<div>
<div key="b">b<div>
<div key="c">c<div>
<div key="d">d<div>
<div key="e">e<div>

更新时:

[
  { key: 'a' }, // 按顺序复用,oldFiber.index为0,lastPlacedIndex为0,顺序,lastPlacedIndex = 0
  { key: 'b' }, // 按顺序复用,oldFiber.index为1,lastPlacedIndex为0,顺序,lastPlacedIndex = 1
  { key: 'e' }, // 在map中用key查找,oldFiber.index为4,lastPlacedIndex为1,顺序,lastPlacedIndex = 4
  { key: 'c' }, // 在map中用key查找,oldFiber.index为2,lastPlacedIndex为4,逆序,lastPlacedIndex = 4,标记Placement
  { key: 'f' }, // 新建fiber,lastPlacedIndex为4,标记Placement
]

可见,key=“d”的div被删除,key=“c”的div被移动到后面,只要a、b、e的整体顺序没变就不需要移动。

写在后面

删除了源码中的一些细节,这些细节对理解diff没有影响,反而会让代码更加清晰直观,感兴趣的朋友请自行查阅源码。

错误的地方请在评论区指出。