React 在 Render 阶段的 beginWork 做了什么事情

1,086 阅读12分钟

今天我们主要来讲讲 Render 阶段做的事情。

还记得我们 上一篇 文章讲的下面这段 Fiber 树的遍历算法吗?

function traverse(node) {
  const root = node;
  let current = node;

  while(true) {
    console.log('当前遍历的节点是:' + current.type)

    if (current.child) {
      current = current.child
      beginWork(current);
      continue
    }
    // ... 其他的省略了
}

可以看到,我额外添加了一个 beginWork 方法。在 React 中,beginWork 的调用大概就在遍历的那个位置。

在 React 的 Render 阶段遍历整个 Fiber 树的过程中,会先调用 beginWork 方法,那它做了什么事呢?

ps: mounted 阶段或者 updated 阶段是针对一个单独的 Fiber 对象说的,第一次渲染就是 mounted 阶段,后续更新就是 updated 阶段。

如果是 mounted 阶段,比如说是第一次渲染应用的时候,此时整个 Fiber 树还没有构建出来,我们就会在调用这个方法过程中把它构建出来;如果是 updated 阶段,比如说我们列表中的某一项被删除了,我们就会进行新老节点的对比,尽可能的复用老的节点,根据新的值更新我们的 Fiber 树。

有一点需要额外注意一下,在这个过程中,我们只会新建、更新一些 Fiber 节点,如果某些节点需要删除或者移动,我们只会给他标记好,真正的删除或者移动要在后面的 Commit 阶段执行。

下面这段代码就是如何在 Fiber 树中调用 beginWork 过程的代码,想必聪明的你在看了上一篇 什么是 Fiber 树 之后,理解起来没什么困难的。

下面这段代只是数量多一点、函数名奇怪一点,但是原理和我们之前遍历 Fiber 的那段代码一样,大家可以停下来梳理一下流程,等梳理完了,再看后面的内容。

workLoopSync() 

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;

  let next;

  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;

  const siblingFiber = completedWork.sibling;

  do {
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      workInProgress = siblingFiber;
      return;
    }
    // Otherwise, return to the parent
    completedWork = returnFiber;
    // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null);
}

function beginWork(fiber) {
  // 这是我们今天要讲的内容
}

好了,相信你已经知道如何在整个 Fiber 树中循环的调用 beginWork 的了。下面我们看一下我们是怎么走到调用 beginWork 这一步的。

由于 ReactDOM.render 在 18 版本就要被废弃了,取而代之的是 ReactDOM.createRoot,所以我们就来看一下这二者走到 beginWork 之前有什么差别。

有一点比较有趣,现在,我和你一块看的代码,全球还没有别人看。尽管这并没有什么用。

image.png

createRoot 的调用方式如下:

ReactDOM.createRoot(rootNode).render(<App />);

createRoot 最终返回了一个 ReactDOMRoot 对象,而 render 方法就挂在这个对象的原型上面。

ReactDOMRoot.prototype.render = function(children) {
  const root = this._internalRoot;
  if (root === null) {
    throw new Error('Cannot update an unmounted root.');
  }

  updateContainer(children, root, null, null);
};

rendercreateRoot 的不同之处就在于后者不是同步的,大致的区别可以看下图。

请你原谅我的字丑 :)

image.png

不管是 render 还是 createRoot 最后都会调用 performUnitOfWork,但是调用的方式略有差别,前者不可中断,后者整体受 React 调度器(Scheduler)的管理,是可以被中断的:


// 同步
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// 异步
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

performUnitOfWork 内部,就会调用 beginWork

function performUnitOfWork(unitOfWork: Fiber): void {
  let next;
  ...
  
  next = beginWork(current, unitOfWork, subtreeRenderLanes);
  
  ...
}

希望上面的内容能让你从宏观上对 beginWork 有一个理解,接下来,我们聚焦于它的内部,看它到底做了什么。

在整个遍历的过程中,React 会维护一个全局变量 workInProgress,代表当前遍历的 Fiber 对象,在 beginWork 中,会根据当前的 workInProgress 的 tag 属性来判断怎么更新当前的 Fiber 对象。

image.png

今天我们只分析 tag 为 FunctionCompomponent 的情况。

beginWork 函数接受三个参数:

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
):

在上一篇文章的末尾,你应该还记得 Fiber 节点有一个叫做 alternate 的属性,为了避免每次在对比新老 Fiber 树的时候都新建新 Fiber 树的对象,React 会一直存在两套 Fiber 树,在后面提交更新的过程会有互换的操作。

在下图中,用橙色线画的就是 Fiber 节点的 alternate 属性。

image.png

我们下面的讨论都建立在当前节点的类型是 FunctionComponent 的基础上。

beginWork 有返回值,返回值就是它的第一个孩子节点或者 null,如果下一个孩子节点为 null,它就会走 completeUnitOfWork 的逻辑,这个在我们上面的代码里有。而 beginWork 的返回值就是从 updateFunctionComponent 这里来的。

switch (workInProgress.tag) {
    ...
    case FunctionComponent: {
      // 这个就是我们的函数本身,也就是传给 createElement 的第一个参数
      const Component = workInProgress.type; 
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
 }

updateFunctionComponent 内部,会首先调用 renderWithHooks,在这个函数的内部会执行 Component 这个函数,并拿到返回值作为 nextChildren。接下来我们就要对拿到的 nextChildren 进行 reconcile 了。也就是调用 reconcileChildren 方法。对孩子节点 reconcile 完毕后,我们会返回 workProgress.child,这个值同样也是 beginWork 的返回值

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {

    nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
    );
    
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
    return workInProgress.child;

我和你先来一起看一下 renderWithHooks 做了什么事。

在看源码之前,我也没有注意,原来组件的 mounted 阶段和 updated 阶段,引用的 hooks 是不同的,也就是说,虽然我们在 mounted 阶段、updated 阶段,都在使用 useEffect,但是实质上是调用的方法略有区别的。这个区分就是在 renderWithHooks 方法内部做的。

mounted 阶段引用的是 HooksDispatcherOnMount 这个对象内部的 mountEffect 方法,而 updated 阶段引用的是 HooksDispatcherOnUpdate 阶段的 updateEffect 方法。

同时,在我们之前讲解 useState源码 的时候,有借助一个全局变量 workInProgressHook,它代表着当前的 hook对象,也是在 renderWithHooks 内部清空。这个是合理的,我们当前组件后面会用到这个值,在使用之前需要进行一些清理操作。

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
    currentlyRenderingFiber = workInProgress;

    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

    let children = Component(props, secondArg);
   
    currentHook = null;
    workInProgressHook = null;

    return children;
}

接下来再和你一起看一下 reconcilChildren 做了什么事。

首先,我们会根据 current 节点来判断是处于 mounted 阶段还是 updated 阶段。为什么呢?

current 始终代表的是展示在页面上对应的那棵树,如果 mounted 阶段,页面啥也没有,就是 null,如果 update 阶段,就有值了。

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

mountChildFibersreconcileChildFibers 有细小的差别:

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

接下来,就来一起分析最后的这个函数了:childReconciler

我们拿到的孩子节点可能是单个节点,也可能是多个,并且它还可能是各种类型的。所以说这个函数的本质就是根据孩子节点不同的类型做不同的事情。如果当前节点有三个孩子?那它就会根据三个孩子新建三个 Fiber 对象,并用 sibling 指针连起来,如果当前孩子被删除或者移动了,那它就会把这些有变更的节点打上一个标志,待后面 Commit 阶段处理。

我们今天就分析两种情况:单个普通的 ReactElement 元素和列表。

对于单个元素,会先调用 reconcileSingleElement ,它的返回值是一个新的 Fiber 对象,拿到返回值后再去调用 placeSingleChild

switch (newChild.$$typeof) {
  case REACT_ELEMENT_TYPE:
    return placeSingleChild(
      reconcileSingleElement(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      ),
    );
}

placeSingleChild 的代码比较简单:

function placeSingleChild(newFiber: Fiber): Fiber {
  // shouldTrackSideEffects 在 mounted 阶段为 false,updated 阶段为 true
  // 两个条件连起了就是相当于更新阶段的插入操作。
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.flags |= Placement; 
    // 这就是标志这个节点移动了,后续 commit 阶段会移动它
  }
  return newFiber;
}

接下来我们进入 reconcileSingleElement,由于这段代码比较长,分开讲解可能会让你产生割裂感,于是我采用在代码注释的方法。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const key = element.key;
  // 如果孩子节点只有一个, currentFirstChild 就是唯一的孩子节点,
  // 如果孩子节点有多个,对每一个孩子节点都会按顺序调用 reconcileSingleElement,
  // 那此时 currentFirstChild 就是当前的孩子节点
  let child = currentFirstChild;
  while (child !== null) { 
    // 孩子节点不为空,说明之前就新建过了,
    // 我们会对比 key、elementType,查找第一个能
    // 复用的孩子节点,并把剩余的都标记为删除
    if (child.key === key) {
      const elementType = element.type;

      if (child.elementType === elementType) {
        // 发现了一个可以复用的,把这个节点后面的孩子节点都标记为删除
        deleteRemainingChildren(returnFiber, child.sibling);
        // 复用这个 fiber 节点
        const existing = useFiber(child, element.props);
        existing.ref = coerceRef(returnFiber, child, element);
        // 把新的节点指向父元素,
        // 注意,这里的 returnFiber 对应的 Fiber 树
        // 和 currentFirstChild 对应的不是一个
        // 可以把前者对应的看做新的,后者看做老的。
        existing.return = returnFiber;

        return existing;
      }

      // key 一样但是元素类型不一样,也当做没找到可以复用的,
      // 直接删掉把所有剩余的标记为删除
      // 并在删除后跳出循环
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 这里是还没有找到 key 相同的节点,就一个个的标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }


  // 如果 current === null 相当于 mounted 阶段,
  // 不考虑复用了,直接新建一个节点
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

下面是对于孩子节点是数组的处理,这可能是我们今天最有难度的一段代码了,这里也可以叫做 React 的 diff 算法。在这里,我一样采用了注释的方法讲解。不过为了让大家更好理解,我们还会举几种情况,再次梳理一下它的逻辑。

这段代码得自己调一下才能更好理解,你可以打开控制台,搜索 react-dom.development.js 的 reconcileChildrenArray 方法,尝试打一下断点。

image.png

在 Vue 3 的 diff 算法中,会先从头到尾直接 patch 的节点,直到碰到 key 不同、元素类型不同的节点停止,再从尾到头寻找能直接 patch 的节点,碰到 key 不同、元素类型不同的节点停止。

最后,为了只移动最少的项,会在剩下的那些项中查找最长子序列,只移动不在最长子序列中的元素,这样就完成了整个 diff 过程。在 React 中不一样了,在孩子节点中,它只有指向兄弟节点的 sibling 指针,就不能实现从后往前找的过程。于是乎,它只有从前往后找这一个过程。

接下来,我们详细的看一下它是怎么做的。

  1. [1, 2, 3, 4] -> [1, 2, 3, 4]

我们从头开始遍历,一直遍历到了结尾,发现没有任何变化,都能复用,那这一次就愉快的结束了。

  1. [1, 2, 3] -> [1, 2, 3, 4, 5]

前三个都能复用,到了最后一个,发现新的孩子节点还没有完全遍历完毕,就会把没有遍历完的视为插入,也就是说 45 都看做新增。

  1. [1, 2, 3] -> [1, 2]

前两个可以复用,但是第三个在新的列表中被删除了,我们会在老的节点中将 3 标记为删除。

  1. [a, b, c, d, e] -> [a, c, d]

从肉眼看,acd 都能复用,我们先从头遍历,第一个复用,并把 b 标记为删除,接下来就停止遍历了,但会把老节点还没有遍历的节点收集为一个 map。key 是 index,value 是它的值,就上面的例子来说,就是:

2 -> Fiber // 值是 c
3 -> Fiber // 值是 d
4 -> Fiber // 值是 e

接着在新节点中根据 key 去这个 map 里找那些可以复用的,此时发现 cd 都行,但是 e 不行,于是就在最后只把 e 删除。

  1. [a, b, c, d] -> [a, d, c, b]

这是属于移动的情况,对与这种情况,所有的节点都能复用的,但是我们会给新节点的 cb 打上移动(placement)的标志。

分析完了各种情况,下面是具体的代码解析。

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

  // oldFiber 是老的孩子节点,通过 sibling 指针相连
  // 每个节点的 index 属性是从 0 开始,依次递增
  // 0 -> 1 -> 2 -> 3
  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  
  
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // 说明当前新节点被删除了
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // 根据老的 Fiber 节点更新
    // 如果 key 相同,就复用,不同就返回 null
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    
    // 碰到删除的节点就会走到这里
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    
    // updated 为 true,mounted 为 false
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        // We matched the slot, but we didn't reuse the existing fiber, so we
        // need to delete the existing child.
        deleteChild(returnFiber, oldFiber);
      }
    }
    
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    
    if (previousNewFiber === null) {
      // 为了构建兄弟节点之间的链表,只在第一次进来
      resultingFirstChild = newFiber;
    } else {
      // 构建 sibling 指针连接起来的链表
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  // 新孩子列表已经遍历到末尾了,剩下的老节点已经不用再看了,删除剩下的所有
  if (newIdx === newChildren.length) {
    // We've reached the end of the new children. We can delete the rest.
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  // 老节点已经没有了,但是还有新节点,认为剩下的新节点都是新增
  if (oldFiber === null) {
    // If we don't have any more existing children we can choose a fast path
    // since the rest will all be insertions.
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  
  // 把老节点 index 做 key,fiber 对象做值存起来
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

 
  for (; newIdx < newChildren.length; newIdx++) {
    // 如果在 existingChildren 找到了和新节点对应的值,那就不是 null
    // 不然就是 null
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // 这个老节点可以复用,就把它先从 existingChildren 删除掉
          // 因为后面我们会把 existingChildren 里所有的节点标记为删除
          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) {
    // 删除掉所有无法复用的孩子节点
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }

  // 返回当前链表的第一个孩子节点
  return resultingFirstChild;
}

到这里, beginWork 就讲完了,我们先从宏观上了解了它在哪里调用,然后又分析了一下它的主流程的代码。希望能帮到你理解这一个过程,也希望看到这里的你还不算太晕。

你的国庆过得还快乐吗?希望过得很快乐。 🥰😇😏

别人可能不知道,但是,我想,不想上班的人过得肯定很快乐。


前两天从高中路过,发现春雨书店搬位置了,不过依然和以前一样,各式各样的杂志铺满了店门口,旁边的安东文体的门也变得有了年代感,不过,在那个相同的位置,还卖着我最喜欢的中性笔。