阅读 551
浅析React17 diff 算法源码|8月更文挑战

浅析React17 diff 算法源码|8月更文挑战

目录

一、Fiber
二、执行过程
  挂载
  更新
  卸载
三、Dom diff
  策略
  源码分析
    reconcileChildFibers
    reconcileSingleElement
    reconcileSingleTextNode
    reconcileChildrenArray
    工具函数
  小结
  lastPlacedIndex
复制代码

这里我们以 React 17.0.3 版本为例。

一、Fiber

React 从 16 开始引入了 Fiber。

在 Fiber 架构前,当 React 决定要加载或者更新组件树时,会做一个“大动作”。这个动作包括生命周期的调用、diff 过程的计算、DOM 树的更新等等。这个动作很大,耗时不短,并且它还是同步进行的,一旦开始就不能中断。这意味着你在 挂载/更新 结束前,啥也不能干。

面对“单个任务耗时过长”这个问题,解决思路是把一个庞大的任务拆分成 N 多个微小的任务(如下图)

fiber.jpg

每个微小的任务就叫 Fiber,它代表着一个单位的工作,也是接受调度的最小单元。

上图中每一个波峰和波峰之间,就意味着是一个工作单元(Fiber)。每次到达波峰时,意味着该任务交出了对主线程的占用。

每完成一个小任务,都会暂停一下对主线程的占用,看看有没有优先级更高的事情需要处理。以此来确保主线程总在做它当下最应该做的事情。

这种新的调和方式,叫做 Fiber Reconciler。

二、执行过程

react16lifecycle.png
(图片截取自 React 官网,版本>= 16.4 点这里查看

分为三个阶段:挂载、更新、卸载

挂载

组件实例被创建并插入 DOM 中时

生命周期函数调用顺序如下:

  • constructor():初始化 state 或进行方法绑定
  • static getDerivedStateFromProps():每次渲染前都会触发,不常用
  • render():检查 this.props 和 this.state 的变化并根据返回值的不同做出不一样的渲染策略
  • componentDidMount():组件挂载后(插入 DOM 树中)立即调用

更新

组件的 props 或 state 发生变化时触发更新

生命周期函数调用顺序如下:

  • static getDerivedStateFromProps():同上
  • shouldComponentUpdate():根据返回值判断 state 或 props 的变化是否重新渲染,多用于性能优化,不常用
  • render():同上
  • getSnapshotBeforeUpdate():最近一次渲染输出(提交到 DOM 节点)之前调用,即图中 Pre-Commit 阶段,不常用
  • componentDidUpdate():完成更新后被立即调用

卸载

组件从 DOM 中移除时会调用如下方法:

componentWillUnmount():组件卸载及销毁之前直接调用

三、Dom diff

策略

时间复杂度为 O(n^3) 的传统 diff 算法显然无法满足框架对性能的要求,因此,React 团队根据前端界面的特性,提出三条假设:

  1. 相同的组件有着相同的 DOM 结构,不同的组件有着不同的 DOM 结构(component diff

  2. 位于同一层次的一组子节点,它们之间可以通过唯一的 id 进行区分(element diff

  3. DOM 结构中,跨层级的节点操作非常少,可以忽略不计(tree diff

基于这三条假设将 diff 算法的时间复杂度从 O(n^3) 降到了 O(n)

更新逻辑

在 React 中,Fiber 相当于虚拟节点

这里截取了部分属性

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key; // key 值
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null; // 子节点
  this.sibling = null; // 兄弟节点
  this.index = 0; // 节点下标
  ...
  this.alternate = null;
  ...
}
复制代码

我们来看一下具体是怎么更新视图的

update.png

从根节点开始:

  • div 节点通过 child 属性找到节点 div1
  • div1 节点通过 sibing 属性找到节点 ul
  • ul 节点通过 child 属性找到节点 li
  • li 节点与自身的 alternate 属性存放的节点信息比较,比较完成后把更新 commit3 通过 return 提交到 ul 节点
  • ul 节点与自身的 alternate 属性存放的节点信息比较,比较完成后生成 commit2,连同 commit3 一同 return 给 div 节点
  • div1 节点与自身的 alternate 属性存放的节点信息比较,比较完成后把更新 commit1 通过 return 提交到 div 节点
  • 获取到所有更新(commit1-3)后,再一次更新到真实 dom 中

源码分析

这里就不一步一步慢慢查找代码了,这里直接找到 react\packages\react-reconciler\src\ReactChildFiber.new.js 文件。

reconcileChildFibers

该方法用于判断节点类型,针对不同类型使用不同的调和函数处理节点

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any, // New fiber
  lanes: Lanes,
): Fiber | null {

  // 若 newChild 是未设置 key 值的 Fragment 类型节点(顶层节点)
  // 则将其子节点赋值给 newChild
  const isUnkeyedTopLevelFragment =
      typeof newChild === 'object' &&
      newChild !== null &&
      newChild.type === REACT_FRAGMENT_TYPE &&
      newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  const isObject = typeof newChild === 'object' && newChild !== null;
  if (isObject) {
    // newChild 是个对象
    switch (newChild.$$typeof) {
      // 单节点类型
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      // 后面 case 的处理方式与单节点类型类似,就不一一分析了
      ...
    }
    if (isArray(newChild)) {
      // 数组类型
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }
    ...
    // 无效的对象类型
    throwOnInvalidObjectType(returnFiber, newChild);
  }
  // 文本节点
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes,
      ),
    );
  }
  ...
  // 更新删除掉了所有节点,执行删除
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}
复制代码

reconcileSingleElement

处理 New fiber 为单个节点的情况

function reconcileSingleElement(
  returnFiber: Fiber, // 即 workInProgress
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const key = element.key;
  let child = currentFirstChild; // Old fiber
  // 若 child 不为 null,则一直循环
  while (child !== null) {
    if (child.key === key) {
      // key 值相同
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          // 类型一致,可复用 Old fiber,删除同级兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          ...
          return existing;
        }
      } else {
        if (
          child.elementType === elementType ||
          (__DEV__
            ? isCompatibleFamilyForHotReloading(child, element)
            : false) ||
          (enableLazyElements &&
            typeof elementType === 'object' &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
          ) {
          // 类型一致,可复用 Old fiber,删除同级兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          ...
          return existing;
        }
      }
      // 类型不一致,无法复用,删除当前 child
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key 值不一致,无法复用,删除当前 child
      deleteChild(returnFiber, child);
    }
    // 走到这说明当前 child 不可被复用
    // 改用 child 的同级兄弟节点来继续比较(优化点)
    child = child.sibling;
  }
  // 上面如果执行完没有可复用的 则进入这里进行创建
  if (element.type === REACT_FRAGMENT_TYPE) {
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key,
    );
    created.return = returnFiber;
    return created;
  } else {
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}
复制代码

reconcileSingleTextNode

接下来处理文本节点类型的 New fiber

function reconcileSingleTextNode(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  textContent: string,
  lanes: Lanes
): Fiber {
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    // 类型一致,则直接复用,删除同级兄弟节点
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
    const existing = useFiber(currentFirstChild, textContent);
    existing.return = returnFiber;
    return existing;
  }
  // 类型不一致,无法复用,删除节点,创建新节点
  deleteRemainingChildren(returnFiber, currentFirstChild);
  const created = createFiberFromText(textContent, returnFiber.mode, lanes);
  created.return = returnFiber;
  return created;
}
复制代码

reconcileChildrenArray

最后是处理数组类型的 New fiber,大多数文章讨论的最多的就是这部分

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  ...
  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;

  // 第一次遍历
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      // 旧节点位于新节点右边
      // step 1: 下次循环,旧节点不变,向右取新节点
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      // step 2: 使用旧节点的兄弟节点作为下一次遍历的旧节点(循环末尾将 nextOldFiber 赋值给 oldFiber)
      nextOldFiber = oldFiber.sibling;
    }
    // step 3: 根据 key 判断是否可以复用节点
    // 若可复用则返回旧节点创建的 fiber
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber === null) {
      // step 4: 不可复用节点,跳出循环
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }

    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        // step 5:删除旧节点
        deleteChild(returnFiber, oldFiber);
      }
    }
    // step 6:更新操作
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    ...
    // step 7:将后一个节点赋值给 oldFiber
    oldFiber = nextOldFiber;
  }
  // 第一次遍历结束
  if (newIdx === newChildren.length) {
    // step 8:新节点都遍历完了,删除剩下的旧节点
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  if (oldFiber === null) {
    // 旧节点遍历完了
    // step 9:新节点若还有,则进入下面的循环逐一创建
    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;
  }

  // step 10:使用 Map 保存旧节点信息
  // 若旧节点存在 key 值,则使用 key 值作为 Map 的键值
  // 若不存在 key 值,则使用下标作为 Map 的键值
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // 第一次循环过程中碰到无法复用的节点便会跳出,走第二次循环

  // 第二次遍历
  for (; newIdx < newChildren.length; newIdx++) {
    // step 11:通过新节点的 key 值或索引,查找 existingChildren 是否有相同的旧节点
    // 若不存在,则会为新节点创建新 Fiber
    // 下一小节详解
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // step 12:若 newFiber 是复用的旧节点,则删除 existingChildren 中对应的节点
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
      // step 13:更新操作
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      ...
    }
  }
  // step 14:完成第二次遍历后,清空 existingChildren
  if (shouldTrackSideEffects) {
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }
  return resultingFirstChild;
}
复制代码

工具函数

流程基本走完了,看一下几个重要的工具函数

// 在 reconcileChildFibers 方法中处理文本节点的插入操作
// shouldTrackSideEffects 标识, 是否为 Fiber 对象添加 effectTag
// shouldTrackSideEffects 为 true 代表更新操作
// alternate 属性为 null,表示该 fiber 还未插入到 Dom 中
function placeSingleChild(newFiber: Fiber): Fiber {
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.effectTag = Placement;
  }
  return newFiber;
}
复制代码
// 在 reconcileChildrenArray 方法的第二次遍历中使用
// 遍历当前同级旧节点,得到以 key 值 或 索引 为 key,节点为值的 Map
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) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
  }
  return existingChildren;
}
复制代码
// 在 reconcileChildrenArray 方法的第二次遍历中使用
// 通过新节点的 key 值或索引,查找 existingChildren 是否有相同的旧节点
function updateFromMap(
  existingChildren: Map<string | number, Fiber>,
  returnFiber: Fiber,
  newIdx: number,
  newChild: any,
  lanes: Lanes
): Fiber | null {
  if (typeof newChild === "string" || typeof newChild === "number") {
    const matchedFiber = existingChildren.get(newIdx) || null;
    return updateTextNode(returnFiber, matchedFiber, "" + newChild, lanes);
  }
  if (typeof newChild === "object" && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        const matchedFiber =
          existingChildren.get(newChild.key === null ? newIdx : newChild.key) ||
          null;
        return updateElement(returnFiber, matchedFiber, newChild, lanes);
      }
      case REACT_PORTAL_TYPE: {
        ...
    }
    if (isArray(newChild) || getIteratorFn(newChild)) {
      const matchedFiber = existingChildren.get(newIdx) || null;
      return updateFragment(returnFiber, matchedFiber, newChild, lanes, null);
    }
    ...
  }
  ...
  return null;
}
复制代码
// 在 reconcileChildrenArray 方法中两次循环均有使用
function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    newFiber.index = newIndex;
    if (!shouldTrackSideEffects) {
      // 不需要更新
      return lastPlacedIndex;
    }
    const current = newFiber.alternate;
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 可复用旧节点在新节点左侧,则使用移动操作,即 可复用旧节点右移
        newFiber.flags |= Placement;
        return lastPlacedIndex;
      } else {
        // 不动它
        return oldIndex;
      }
    } else {
      // 新节点的插入操作
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
  }
复制代码

小结

  • 若 New fiber 为文本类型,判断与 Old fiber 类型是否为文本(文本内容不需要一样)

    • 是,则删除 Old fiber 的同级兄弟节点,并复用 Old fiber
    • 不是,则删除 Old fiber,并创建新的文本节点
  • 若 New fiber 为单节点,判断 key 值是否相等

    • 相等,则继续判断节点类型是否一致
      • 一致,则删除 Old fiber 的同级兄弟节点,并复用 Old fiber
      • 不一致,则删除当前 Old fiber,使用当前 Old fiber 的兄弟节点重新判断 key 值、类型
    • 不相等,则删除当前 Old fiber,使用当前 Old fiber 的兄弟节点重新判断 key 值、类型
  • 若 New fiber 为数组类型(存在多个节点)

    • 第一次遍历,比较相同位置的新旧节点,判断 key 值是否一致
      • 一致,则表示可复用,复用当前旧节点
      • 不一致,则跳出循环
    • 第一次遍历结束
      • 若新节点已遍历完了、旧节点还有剩余,则删除旧节点,返回结果,结束调和过程
      • 若旧节点遍历完了、新节点还有剩余,则遍历新节点创建新 Fiber,并插入,返回结果,结束调和过程
      • 若新旧节点都有剩余,即第一次遍历时跳出循环了,则遍历 Old fiber 得到以 Key 值 或 索引 作为 键值,旧节点作为值的 Map,开始第二次遍历
    • 第二次遍历,通过上一步得到的 Map,判断 Old fiber 中是否有相同 key 值的节点
      • 存在,取出旧节点,复用旧节点,插入操作,之后删除 Map 中对应的旧节点
      • 不存在,则新建(在 updateFromMap 中实现)

lastPlacedIndex

最后来看看贯穿 diff 算法的 lastPlacedIndex 到底是干嘛用的。

lastPlacedIndex 记录着上一个节点的位置。在更新操作时,比较需要更新的节点的下标与 lastPlacedIndex,当下标小于 lastPlacedIndex 时,才需要移动,即:将节点右移。

Vue 的 diff 算法允许左右移动节点,而 React 的策略是仅能向右移动


我爱学习.jpg

文章同时发在个人公众号,欢迎关注 MelonField

参考

文章分类
前端
文章标签