React 多节点 diff 原理(含js实现版)

125 阅读3分钟

我们都知道react分为reconcilerender两个过程,reconcile过程负责找出变化的虚拟dom,render过程负责去更新这些变化的虚拟dom。那如何去以一个尽可能小的代价去找出变化的虚拟dom呢?

诞生背景

由于Diff操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的时间复杂程度也要 O(n^3),所以react为了降低算法复杂度,他做了以下几点。

  1. react只对同级元素进行Diff。但是同级节点通过增加,删除,替换操作来实现复用,最少需要O(n^2)。react团队觉得时间复杂度还是太高了。
  2. 所以通过标记 key 来降低复杂度,做到O(n)。如果key和type都相同,即可以复用。例如,如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

思路

todo...

todo...

代码如何实现

这是我自己实现的,难免有bug,有问题在评论区留言。。

这里要注意,oldFiber是一条以sibling属性连起来的链表。newChildren是数组,是从fiber节点上拿下来的(fiber.props.children)。

要用到的功能函数

判断能否复用

export function isSameType(a, b) {
  return a && b && a.type === b.type && a.key === b.key;
}

删除节点

function deleteChild(returnFiber, childToDelete) {
  let deletions = returnFiber.deletions;
  if (deletions === null) {
    returnFiber.deletions = [childToDelete];
    // returnFiber.flags |= ChildDeletion;
  } else {
    deletions.push(childToDelete)
  }
}

function deleteRemainingChildren(returnFiber, currentFirstChild) {
  let childToDelete = currentFirstChild;
  while (childToDelete) {
    deleteChild(returnFiber, childToDelete);
    childToDelete = childToDelete.sibling;
  }
}

把老fiber节点放入map中,方便后续查找

function mapRemainingChildren(oldFiber) {
  const existingChildren = new Map();
  let prev = oldFiber;

  while (prev) {
    existingChildren.set(prev.key || prev.index, prev);
    prev = prev.sibling;
  }
  return existingChildren;
}

更新lastPlacedIndex并为fiber节点打上flags

function placeIndex(newFiber, lastPlacedIndex, index, shouldTrackSideEffects) {
  newFiber.index = index;
  // 父节点 是否初次渲染
  if (!shouldTrackSideEffects) {
    return lastPlacedIndex
  } else {
    // 子节点 是否初次渲染
    const current = newFiber.alternate;
    if (current) {
      const oldIndex = current.index;
      if (oldIndex >= lastPlacedIndex) {
        return oldIndex;
      } else {
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      }
    } else {
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }
}

diff实现

首先,需要进行初始化

export function reconcileChildren(returnFiber, children) {

  let lastPlacedIndex = 0;// 上次插入的位置
  let shouldTrackSideEffects = !!returnFiber.alternate; //是否初次渲染
  let nextOldFiber; //暂存oldFiber
  if (isStringOrNumber(children)) {
    return;//文本节点直接返回
  }
  // todo 单子节点和多子节点分开处理,这里都当做多子节点处理
  const newChildren = isArray(children) ? children : [children]
  let previousFiber = null;
  let oldFiber = returnFiber.alternate?.child;
  
  // .........................
}

1.如果有oldFiber,就将oldFiber和newChildren挨个对比,符合条件就复用,不符合就退出循环。此时,有4种可能结果(2,3,4,5)

  let i = 0;
  for (; oldFiber && i < newChildren.length; i++) {
    const newChild = newChildren[i];
    if (newChild === null || newChild === undefined) {
      continue;
    }
​
    if (oldFiber.index !== i) {
      nextOldFiber = oldFiber;
      oldFiber = null;// 会跳出循环
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // type和key都相同才返回true
    if (!isSameType(oldFiber, newChild)) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
​
    const newFiber = createFiber(newChild, returnFiber);
    Object.assign(newFiber, {
      flags: Update,
      stateNode: oldFiber.stateNode,
      alternate: oldFiber
    })
    console.log('复用成功1', oldFiber, newFiber)
    lastPlacedIndex = placeIndex(newFiber, lastPlacedIndex, i, shouldTrackSideEffects)
​
    if (previousFiber === null) {
      returnFiber.child = newFiber;
    } else {
      previousFiber.sibling = newFiber;
    }
    previousFiber = newFiber;
    oldFiber = nextOldFiber;
  }

2.oldFiber没遍历完,newChildren遍历完。

  • 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次放入父fiber节点的deletions数组,后续在commit阶段(也就是前面所说的render阶段)再去删除。
   if (i === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return;
  }

3.newChildren没遍历完,oldFiber遍历完。

(初次渲染也属于这种情况,因为没有oldFiber)

已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement。

  if (!oldFiber) {
    for (; i < newChildren.length; i++) {
      const newChild = newChildren[i];
      // 比如写jsx时写成这样 {num?<span>111</span>:null} ,null不渲染
      if (newChild === null || newChild === undefined) {
        continue;
      }
      const newFiber = createFiber(newChild, returnFiber);
      lastPlacedIndex = placeIndex(newFiber, lastPlacedIndex, i, shouldTrackSideEffects);
      if (previousFiber === null) {
        returnFiber.child = newFiber;
      } else {
        previousFiber.sibling = newFiber;
      }
      previousFiber = newFiber;
    }
  }

4.newChildren和oldFiber都没遍历完,比较麻烦。

  const existingChildren = mapRemainingChildren(oldFiber);
  for (; i < newChildren.length; i++) {
    const newChild = newChildren[i];
    if (newChild === null || newChild === undefined) {
      continue;
    }
    const shouldAlternate = existingChildren.has(newChild.key || newChild.index);
    const newFiber = createFiber(newChild, returnFiber);
    if (shouldAlternate) {
      const oldFiber = existingChildren.get(newChild.key || newChild.index);
      Object.assign(newFiber, {
        flags: Update,
        stateNode: oldFiber.stateNode,
        alternate: oldFiber
      })
      lastPlacedIndex = placeIndex(newFiber, lastPlacedIndex, i, shouldTrackSideEffects)
      console.log('复用成功4', oldFiber, newFiber)
      existingChildren.delete(newChild.key || newChild.index);
    }

    if (previousFiber === null) {
      returnFiber.child = newFiber;
    } else {
      previousFiber.sibling = newFiber;
    }
    previousFiber = newFiber;
  }
  // 还要删除existingChildren剩下的
  // 更新阶段才可能有existingChildren,所有外层加个if
  if (shouldTrackSideEffects) {
    for (const [, fiber] of existingChildren) {
      console.log('删除existingChildren剩下的')
      deleteChild(returnFiber, fiber);
    }
  }

5.都遍历完了,皆大欢喜。直接结束!

源码地址

github.com/missingone6…