跳到主要内容

第 6 章:beginWork——进入一个 Fiber

第 4 章地图上,render 阶段的核心循环是 performUnitOfWork:对每个 fiber 调一次 beginWork,往下钻。本章把 beginWork 放大,看它对一个 fiber 到底做了什么。

回到第 4 章的 Counter。它第二次点击后,React 拿着 current 树和新的 workInProgress 树,从上往下逐个调用 beginWork。对每个 fiber,beginWork 只问三个问题:

  1. 该不该干活? 没变化就整棵子树跳过。
  2. 交给谁干? 函数组件、DOM、根节点……各自的活不同。
  3. 孩子怎么办? 有孩子钻下去,没孩子往上收。

先记住这一句beginWork 是 render 阶段的"进门处",每个 fiber 都在这里被决定是跳过(bailout)还是干活(分派给对应逻辑)

一图流:beginWork 的决策流程

第一问:该不该干活

beginWorkReactFiberBeginWork.js:4091)开头先做一次比对。不是首次挂载的 fiber,把旧 props 和新 props 一比:

if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceiveUpdate = true; // 有变化,干活
} else {
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
if (!hasScheduledUpdateOrContext) {
return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
// ↑ 啥都没变也没更新:直接 bailout,整棵子树不看了
}
}

这个 attemptEarlyBailoutIfNoScheduledUpdateReactFiberBeginWork.js:4141)是 React 性能的秘密武器。它能把整棵没变化的子树跳过,一个组件都不重新调。你在父组件里改了个无关的 state,子组件树根本没被重新计算,就是它在兜底。第 24 章(bailout 与优化)会讲透它的边界。

那子节点怎么办?看 childLanes,不是不管。 只比对父 props 就跳过整棵子树,会不会漏掉子组件自己的 setState?不会。每个 fiber 都带一个 childLanes,它是"这棵子树里还有哪些待办 lane"的位图。后代 setState 时,scheduleUpdateOnFiber 会把这个 lane 一路冒泡,把每个祖先的 childLanes 都置上(第 22 章)。注意这个冒泡发生在 setState 被调用的瞬间(调度阶段、render 开始之前),沿 return 链从下往上置位,等于先给祖先"预约登记"。所以父组件不用重新渲染,就知道子树里有没有活。(另一种冒泡在 complete 阶段,见第 8 章与第 4 章附录。)

bailoutOnAlreadyFinishedWorkReactFiberBeginWork.js:3714)就靠它分叉:

if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
return null; // 子树也没活:整棵跳过
}
cloneChildFibers(current, workInProgress);
return workInProgress.child; // 子树有活:不重渲染本组件,但往下钻

所以 bailout 是"本组件不重渲染",不等于"子树不遍历"。子树有活,照样 clone 子 fiber 钻下去,直到找到有活的那个;只有子树确实没事,才整棵跳过。子节点靠 childLanes 向上报信,祖先据此决定钻不钻。

第二问:交给谁干

bailout 过了,说明这活要干。接下来 beginWorkworkInProgress.tag 分派(ReactFiberBeginWork.js:4185),这就是第 3 章说的"tag 是工种标签"真正落地的时刻:

switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, Component, pendingProps, renderLanes);
case ClassComponent:
return updateClassComponent(current, workInProgress, Component, resolvedProps, renderLanes);
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
// …… 还有 Fragment、Context、Memo、Offscreen 等十几种
}

每一种 tag 对应一个 updateXxx 函数,各干各的活。你不需要记全,知道这个模式就行:同一个 beginWork,靠 tag 分流到不同的实现。后面几章逐个看这些分流。

第三问:孩子怎么办

每个 updateXxx 最终都汇聚到同一个出口:reconcileChildrenReactFiberBeginWork.js:341)。拿 FunctionComponent 举例(updateFunctionComponentReactFiberBeginWork.js:1423):它调你的组件函数,拿到返回的 JSX 元素,然后交给 reconcileChildren 把这些元素变成 fiber。

// 组件函数跑完
nextChildren = renderWithHooks(current, workInProgress, Component, props, renderLanes);
// 元素 → fiber
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
// 最后:
return workInProgress.child;

reconcileChildren 里跑的就是 diff(第 7 章),产出的孩子挂到 workInProgress.child 上。beginWork 把这个 child 返回给工作循环:

if (next === null) {
completeUnitOfWork(unitOfWork); // 没孩子:往上收
} else {
workInProgress = next; // 有孩子:钻下去
}

往下钻是 child,往上收是 return。第 3 章那两个指针,在这里正式上岗。

动手实验

  1. 看 tag 分派:在 beginWorkswitch 那行打断点,渲染一棵含 <div>、函数组件、文本的树。每次命中,看 workInProgress.tag 是什么,对应走了哪个 case。
  2. 观察 bailout:在 attemptEarlyBailoutIfNoScheduledUpdate(ReactFiberBeginWork.js:4141)打断点,第二次点击 Counter。你会发现大部分没有变化的子树直接在这里被拦下,根本没进各自的 updateXxx
  3. 数一次点击调用几次 beginWork:每次 beginWork 命中加一个计数。一棵 10 个节点的树,一次更新会命中大约 10 次(每个 fiber 一次),而 commitMutationEffects 只有几次。render 阶段的"重",重在这里。

小结

  • beginWork 对每个 fiber 问三个问题:该不该干(bailout)、交给谁干(tag 分派)、孩子怎么办(reconcileChildren)
  • bailout 是性能根基:没变化的子树整棵跳过。
  • tag 分派把 beginWork 分流到十几个 updateXxx,各类型各干各的活。
  • reconcileChildren 产出孩子,有孩子返回 child 钻下去,没孩子返回 null 往上收。
  • 下一步:reconcileChildren 里到底怎么把元素变 fiber?第 7 章看 diff 算法。