React 19.3.1 源码深度剖析-第 8 章 并发特性(Concurrent Features)(下)

1 阅读1小时+

React 19.3.1 源码深度剖析-第 8 章 并发特性(Concurrent Features)(下)

本篇接续《第08章_并发特性_A》。上篇已建立并发渲染的骨架:shouldTimeSlice 判定树、renderRootConcurrent 逐段拆解、六种 RootExitStatus、模块级"栈外栈"如何保存中断现场、三种 work loop 的对比、transition 纠缠,以及 8.14 View Transition 的前六小节。

本篇回答的是"并发渲染在真实场景里怎么收场":View Transition 的提交挂起状态机(8.14.7 起)、并发下的错误处理与恢复、SSR 预渲染为什么必须切片、useDeferredValue 的完整状态机、async action 的等待机制、如何调试并发渲染,最后以"从更新到像素"的完整时序收束。

阅读前置:第 3 章(Lane 优先级模型)、第 5 章(Scheduler 时间切片)、第 8 章上篇。


本篇导航(8.14.7 ~ 8.27)

维度位置
View Transition(承上篇 8.14)8.14.7 ~ 8.14.9
并发下的错误处理与恢复8.15
SSR 预渲染的并发路径8.16
useDeferredValue 完整状态机8.17
边界、坑与调试8.18
startTransition 的 async action8.19
调试并发渲染的实操8.20
从更新到像素:完整时序8.21
实战速查:什么时候用什么8.22
callbackNode 与模块级状态重排8.23
并发下的经典陷阱8.24
全章源码索引表8.25
全章 FAQ8.26
本章小结8.27

8.14 View Transition 与并发(承上篇)

本节标题承接上篇 8.14 的开头(## 8.14 View Transition 与并发)与 8.14.1~8.14.6。上篇讲了 lane 门槛、completeRootWhenReady、commitRoot 里的 startViewTransition、<ViewTransition> 组件、gesture transition 与 unstable_Scope;本篇从 8.14.7 继续。

8.14.7 shouldStartViewTransition 的三处置位:什么时候真的会启动

8.14.3 展示了 commitRoot 里的 if (enableViewTransition && shouldStartViewTransition)。这个布尔量定义在 ReactFiberCommitViewTransitions.js:54,并且在任何渲染开始前由 commitRoot 主动重置(ReactFiberWorkLoop.js:3846 调用 resetShouldStartViewTransition(),实现见 :56-58)。它有三处被置 true:

  1. trackEnterViewTransitions(:79-91):出现(enter)的 fiber 是 ViewTransition 组件,或它的 subtreeFlags 含 ViewTransitionStatic。
// ReactFiberCommitViewTransitions.js:79-91
export function trackEnterViewTransitions(placement: Fiber): void {
  if (
    placement.tag === ViewTransitionComponent ||
    (placement.subtreeFlags & ViewTransitionStatic) !== NoFlags
  ) {
    // If an inserted or appearing Fiber is a ViewTransition component or has one as
    // an immediate child, then that will trigger as an "Enter" in future passes.
    // We don't do anything else for that case in the "before mutation" phase but we
    // still have to mark it as needing to call startViewTransition if nothing else
    // updates.
    shouldStartViewTransition = true;
  }
}
  1. applyViewTransitionToHostInstancesRecursive(:138-…):给宿主节点挂 view-transition-name 时顺手置 true(:152 的 persistence 分支、:203 的 mutation 分支)。也就是“只要有任意一个 DOM 节点参与了这次 transition,就必须启动”。

  2. trackAppearingViewTransition(:69-77) 会往 appearingViewTransitions 这个 Map 登记“新出现的命名边界”,供 before-mutation 阶段找删除配对;它自己不置位,但配对成功后会走 1 的路径。

关键推论:shouldStartViewTransition 与 lane 资格是两个独立的闸门。lane 资格(8.14.1 的 includesOnlyViewTransitionEligibleLanes)决定“这次提交有资格走 View Transition 通道”,shouldStartViewTransition 决定“这次提交确实有东西要过渡”。两者必须同时为真才会调 startViewTransition;只有资格而没内容,commitRoot 会走 else 分支同步 flush 四个子阶段(:3915-3921)。

这里还有个容易忽略的细节:beginWork/completeWork 阶段遍历时并不知道最终会不会置位,所以 shouldStartViewTransition 是在 commit 阶段的前半段(commitBeforeMutationEffects 及 accumulateSuspenseyCommit)才被确定的。而 commitRoot 的 if 判断排在 commitBeforeMutationEffects 之后(:3894 的 pendingEffectsStatus = PENDING_MUTATION_PHASE 就在 if 前面),顺序是对的。这个“先重置、中途置位、末尾消费”的三段式,是 React 在 commit 阶段用模块级变量的标准范式。

8.14.8 提交挂起的状态机:pendingEffectsStatus

8.14.2 讲了 completeRootWhenReady 会把 commit 推迟。推迟后,commit 的四个子阶段由浏览器 View Transition API 回调驱动,中间状态记在模块级变量 pendingEffectsStatus 上。取值与含义:

值含义谁推进
NO_PENDING_EFFECTS没有挂起的 commitflushAfterMutationEffects(:4001)、passive 阶段结束
PENDING_MUTATION_PHASE已进入 commit,等待 mutationcommitRoot(:3894) 设置
PENDING_LAYOUT_PHASEmutation 完成,等待 layoutflushMutationEffects 尾部
PENDING_AFTER_MUTATION_PHASElayout 完成,等待 afterMutationflushLayoutEffects 尾部
PENDING_PASSIVE_PHASEafterMutation 完成,等待 passiveflushAfterMutationEffects
SWITCHED_TO_SYNC_COMMIT / ABORTED_VIEW_TRANSITION_COMMIT逃逸路径见下

对应的“扫描器”是 hasPendingCommitEffects()(ReactFiberWorkLoop.js:780-785):

// ReactFiberWorkLoop.js:780-785
export function hasPendingCommitEffects(): boolean {
  return (
    pendingEffectsStatus !== NO_PENDING_EFFECTS &&
    pendingEffectsStatus !== PENDING_PASSIVE_PHASE
  );
}

注意 PENDING_PASSIVE_PHASE 被排除在外:passive(useEffect)阶段已经不再持有对渲染树的独占,允许新的渲染任务开始。performWorkOnRootViaSchedulerTask 开头就查 hasPendingCommitEffects()(ReactFiberRootScheduler.js:530-541):

// ReactFiberRootScheduler.js:530-541
  if (hasPendingCommitEffects()) {
    // We are currently in the middle of an async committing (such as a View Transition).
    // We could force these to flush eagerly but it's better to defer any work until
    // it finishes. This may not be the same root as we're waiting on.
    // ...
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    return null;
  }

只要某个 root 正处于 mutation/layout 的异步 commit 中间,任何新渲染任务都直接 return null——先让旧的 commit 走完。这就是“View Transition 进行中,React 暂停对该 root 的调度”的实现,也解释了为什么长动画期间新的 transition 会排队等待。

flushPendingEffects(:4662)负责在合适时机把 pendingEffectsStatus 一路推到 NO_PENDING_EFFECTS:completeRoot(:3530-3538)用一个 do…while 反复调它,直到状态完全清空——因为 passive effect 里可能又触发新的 passive effect。

// ReactFiberWorkLoop.js:3530-3538
  do {
    // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
    // means `flushPassiveEffects` will sometimes result in additional
    // passive effects. So we need to keep flushing in a loop until there are
    // no more pending effects.
    flushPendingEffects();
  } while (pendingEffectsStatus !== NO_PENDING_EFFECTS);

ABORTED_VIEW_TRANSITION_COMMIT 是一条重要的逃逸:如果 View Transition 启动失败或被打断(如用户切换标签页),commit 会 abort,然后同步跑完剩余阶段,避免 UI 卡在“mutation 已做、layout 未做”的中间态。这保证了“View Transition 是可失败的增强,不是必需的依赖”——这也是 reportViewTransitionError(:3924-3932)存在的原因:准备 View Transition 过程中的错误走 onRecoverableError,而不是抛给 Error Boundary,因为 UI 本身并没有坏。

8.14.9 gesture 的 lane 资格是另一套:includesOnlySuspenseyCommitEligibleLanes

8.14.1 的 includesOnlyViewTransitionEligibleLanes(ReactFiberLane.js:668)只认 TransitionLanes | RetryLanes | IdleLane。但 suspensey commit 的资格更宽(:672-678):

// ReactFiberLane.js:672-678
export function includesOnlySuspenseyCommitEligibleLanes(
  lanes: Lanes,
): boolean {
  return (
    (lanes & (TransitionLanes | RetryLanes | IdleLane | GestureLane)) === lanes
  );
}

多出来的正是 GestureLane。为什么 gesture 有资格“等宿主资源就绪”却没有资格“启动 View Transition”?看 completeRootWhenReady 的代码就懂了(:1602、:1619-1630):

// ReactFiberWorkLoop.js:1602、1619-1630(节选)
  const isGestureTransition = enableGestureTransition && isGestureRender(lanes);
  // ...
    if (
      isViewTransitionEligible ||
      (isGestureTransition &&
        root.pendingGestures !== null &&
        // If this gesture already has a View Transition running then we don't
        // have to wait on that one before proceeding. We may hold the commit
        // on the gesture committing later on in completeRoot.
        root.pendingGestures.running === null)
    ) {
      // Wait for any pending View Transition (including gestures) to finish.
      suspendOnActiveViewTransition(suspendedState, root.containerInfo);
    }

gesture 的提交不启动新的 View Transition,但需要等上一个 View Transition 结束(只要它还没在跑)。这正是 commitRoot 里那句注释的含义(:3887-3890):

// ReactFiberWorkLoop.js:3887-3890
      // Stop any gestures that were committed.
      if (isGestureRender(lanes)) {
        stopCommittedGesture(root);
        // Note that shouldStartViewTransition should always be false here because
        // committing a gesture never starts a new View Transition itself since it's
        // not a View Transition eligible lane. Only follow up Transition commits can
        // cause animate.
      }

手势是“即时模式”:它逐帧提交 useOptimistic 的乐观状态,等手势结束(Action 被调用)后,才由后续的 transition commit 启动真正的 View Transition。两套资格因此刻意分开——一个管“能不能开始”(includesOnlyViewTransitionEligibleLanes),一个管“要不要等”(includesOnlySuspenseyCommitEligibleLanes)。

这也解释了两条辅助判定的存在(ReactFiberLane.js:684-716):includesBlockingLane 把 GestureLane 算作“阻塞 lane”(手势不允许被时间切片,必须逐帧响应),而 isGestureRender(:718-724)要求 lanes === GestureLane 精确相等——手势渲染不能和其他任何 lane 混批。同一个 GestureLane,在三个谓词里有三种身份,这是读 lane 代码时最容易迷路的地方。


8.15 并发下的错误处理

8.10 讲了 SuspendedOnError 在挂起状态机里的去向,本节讲错误的完整生命周期:收集、恢复、再收集、提交。核心角色是 workInProgressRootRecoverableErrors。

8.15.1 错误的三个口袋

一次渲染中,错误可能进入三个不同的容器:

容器行号装什么命运
workInProgressRootConcurrentErrors:505渲染中抛出、未被边界捕获的错误RootErrored 时随树上提交,抛给最近的 Error Boundary
workInProgressRootRecoverableErrors:509被恢复的错误——渲染失败了,但重试成功了提交时仅 log,用户不可见
queueConcurrentError(:2604)—上述两者的入队函数—

关键在 recoverFromConcurrentError(:1327-1406)。performWorkOnRoot 发现 RootErrored 后,会先问 getLanesToRetrySynchronouslyOnError(ReactFiberLane.js:596):

export function getLanesToRetrySynchronouslyOnError(
  root: FiberRoot,
  originallyAttemptedLanes: Lanes,
): Lanes {
  if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) {
    // The error recovery mechanism is disabled until these lanes are cleared.
    return NoLanes;
  }

  const everythingButOffscreen = root.pendingLanes & ~OffscreenLane;
  if (everythingButOffscreen !== NoLanes) {
    return everythingButOffscreen;
  }
  if (everythingButOffscreen & OffscreenLane) {
    return OffscreenLane;
  }
  return NoLanes;
}

它返回“应该同步重试的 lane 集合”。错误恢复的本质是:用同步渲染(renderRootSync(root, errorRetryLanes, false),:1357)再跑一遍。为什么不惜放弃并发也要同步重试?源码注释(:1366-1383)给出了精彩解释:

// During the synchronous render, we attached additional ping listeners.
// This is highly suggestive of an uncached promise (though it's not the
// only reason this would happen). If it was an uncached promise, then
// it may have masked a downstream error from ocurring without actually
// fixing it. Example:
//
//    use(Promise.resolve('uncached'))
//    throw new Error('Oops!')
//
// When this happens, there's a conflict between blocking potential
// concurrent data races and unwrapping uncached promise values. We
// have to choose one or the other. Because the data race recovery is
// a last ditch effort, we'll disable it.

如果同步重试过程中又挂了 ping 监听,说明有未缓存 promise,它可能掩盖下游错误。此时 React 选择 workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; return RootSuspendedWithDelay;(:1388-1389)——把原始 lane 标记为“并发中被更新过”,强制再挂起等一轮,也不启用错误恢复。

重试成功时,两轮渲染的错误按因果顺序合并(:1395-1401):第一轮的 recoverable errors 在前,第二轮的在后——日志里错误的出现顺序必须符合因果。

8.15.2 errorRecoveryDisabledLanes:错误恢复的熔断器

// ReactFiberWorkLoop.js:1380-1383
      root.errorRecoveryDisabledLanes = mergeLanes(
        root.errorRecoveryDisabledLanes,
        originallyAttemptedLanes,
      );

以及 throwForcedInfiniteRenderLoopError(:5201-5220):

function throwForcedInfiniteRenderLoopError(
  root: FiberRoot | null,
  renderLanes: Lanes,
): empty {
  if (root !== null) {
    // Disable concurrent error recovery for the in-progress render so the thrown
    // error reaches the nearest error boundary and breaks the infinite update
    // loop instead of being silently retried by the recovery mechanism.
    root.errorRecoveryDisabledLanes = mergeLanes(
      root.errorRecoveryDisabledLanes,
      renderLanes,
    );
  }
  throw new Error(
    'Maximum update depth exceeded. This could be an infinite loop. ...'
  );
}

错误恢复机制本身可能成为无限循环的放大器(渲染失败→恢复重渲→又失败→再恢复……)。熔断器一旦打开(getLanesToRetrySynchronouslyOnError 直接返回 NoLanes),错误就按正常路径抛给 Error Boundary,循环被打破。

8.15.3 finishConcurrentRender 的 RootErrored 分支

// ReactFiberWorkLoop.js:1466-1473
    case RootErrored: {
      // This render errored. Ignore any recoverable errors because we weren't actually
      // able to recover. Instead, whatever the final errors were is the ones we log.
      // This ensures that we only log the actual client side error if it's just a plain
      // error thrown from a component on the server and the client.
      workInProgressRootRecoverableErrors = null;
      break;
    }

恢复失败时,recoverable errors 被主动丢弃——它们已经被“最终错误”取代了,留着只会造成重复上报。SSR 场景下这条很重要:服务端抛的错和客户端抛的错如果是同一个,只报客户端的。

8.15.4 unwind 时错误与挂起的待遇差异

throwAndUnwindWorkLoop 里有个容易漏的细节(:3276-3288):

  if (unitOfWork.flags & Incomplete) {
    // Unwind the stack until we reach the nearest boundary.
    let skipSiblings;

    if (
      // The current algorithm for both hydration and error handling assumes
      // that the tree is rendered sequentially. So we always skip the siblings.
      getIsHydrating() ||
      suspendedReason === SuspendedOnError
    ) {
      skipSiblings = true;
      // We intentionally don't set workInProgressRootDidSkipSuspendedSiblings,
      // because we don't want to trigger another prerender attempt.
    } else if (...)

错误路径永远跳过兄弟子树,且不标记 workInProgressRootDidSkipSuspendedSiblings。为什么?错误意味着“这棵树不可信”,继续渲染兄弟可能连锁触发更多错误;而挂起(数据未到)只是“还没好”,兄弟子树值得预热。这个 if 用一行注释解释了错误与挂起在哲学上的分野。

8.15.5 致命错误:panicOnRootError 与 RootFatalErrored

两个出口:

  • handleThrow 里 erroredWork === null(:2373-2381)→ RootFatalErrored;
  • throwAndUnwindWorkLoop 里 throwException 返回 didFatal(:3258-3261)→ panicOnRootError(:3352)——throwException 自己抛异常(比如读 componentDidCatch 属性时炸了)时的防循环保护,把错误继续往上抛。

performWorkOnRoot 对 RootFatalErrored 的处理(:1292-1308):prepareFreshStack(root, NoLanes) 清栈,markRootSuspended(root, lanes, NoLane, true) 假装渲染过整棵树(避免再排预渲染),然后 break——最终 current 树被卸载,用户看到白屏。这是 React 最后的底线:宁可白屏,不显示一个半信半疑的 UI。

8.15.6 三个口袋的完整生命周期(含提交时消费)

8.15.1 列了三个容器,这里把“谁写、谁读、何时清”补全。

阶段workInProgressRootConcurrentErrors(:505)workInProgressRootRecoverableErrors(:509)
渲染中抛错queueConcurrentError(:2604) 写入—
prepareFreshStack置 null(:2271)置 null(:2272)
恢复重试开始快照为 errorsFromFirstAttempt(:1336)被第二轮写入
恢复成功被丢弃合并两轮错误,保持因果顺序(:1395-1401)
恢复失败保留,随 RootErrored 提交主动置 null(:1471)
提交作为 recoverableErrors 传给 completeRoot → logRecoveredRenderPhase(:3571)同左

值得展开的是“恢复成功时的合并顺序”(recoverFromConcurrentError:1392-1401):

// ReactFiberWorkLoop.js:1392-1401
    // The errors from the failed first attempt have been recovered. Add
    // them to the collection of recoverable errors. We'll log them in the
    // commit phase.
    const errorsFromSecondAttempt = workInProgressRootRecoverableErrors;
    workInProgressRootRecoverableErrors = errorsFromFirstAttempt;
    // The errors from the second attempt should be queued after the errors
    // from the first attempt, to preserve the causal sequence.
    if (errorsFromSecondAttempt !== null) {
      queueRecoverableErrors(errorsFromSecondAttempt);
    }

注意它不是简单地把两组拼起来:先把第二轮的错误抓出来,再把 workInProgressRootRecoverableErrors 重置为第一轮的错误数组,最后把第二轮 append 上去。这样做的原因是 workInProgressRootRecoverableErrors 在同步重试期间可能被 queueRecoverableErrors 追加(比如重试时又收集到错误),所以必须显式重建顺序。最终 completeRoot 收到的 recoverableErrors 一定是 [第一轮…, 第二轮…],日志里错误的时间线符合因果。

completeRoot 里对它们的消费(:3565-3578):

// ReactFiberWorkLoop.js:3565-3578
    } else if (recoverableErrors !== null) {
      const hydrationFailed =
        finishedWork !== null &&
        finishedWork.alternate !== null &&
        (finishedWork.alternate.memoizedState as RootState).isDehydrated &&
        (finishedWork.flags & ForceClientRender) !== NoFlags;
      logRecoveredRenderPhase(
        completedRenderStartTime,
        completedRenderEndTime,
        lanes,
        recoverableErrors,
        hydrationFailed,
        workInProgressUpdateTask,
      );
    }

hydrationFailed 这个布尔量只影响日志标注(DevTools 上显示为 “Recovered” 还是 “Hydration Recovered”),不影响行为。真正的“用户可见性”由 root.onRecoverableError 决定——这些错误不会抛给 Error Boundary。

8.15.7 恢复渲染里的 hydration 特殊路径

recoverFromConcurrentError 开头有一个 wasRootDehydrated 分支(:1338-1355),它做的事很特别:在重试之前就手动 prepareFreshStack 并给新树打上 ForceClientRender:

// ReactFiberWorkLoop.js:1338-1357(节选)
  const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;

  const wasRootDehydrated = supportsHydration && isRootDehydrated(root);
  if (wasRootDehydrated) {
    // The shell failed to hydrate. Set a flag to force a client rendering
    // during the next attempt. To do this, we call prepareFreshStack now
    // to create the root work-in-progress fiber. This is a bit weird in terms
    // of factoring, because it relies on renderRootSync not calling
    // prepareFreshStack again in the call below, which happens because the
    // root and lanes haven't changed.
    const rootWorkInProgress = prepareFreshStack(root, errorRetryLanes);
    rootWorkInProgress.flags |= ForceClientRender;
  }

  const exitStatus = renderRootSync(root, errorRetryLanes, false);

这段代码依赖一个微妙的前提:renderRootSync 内部只有当 workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes 时才调 prepareFreshStack(:2635)。这里已经用相同的 lanes 预建了栈,所以 renderRootSync 不会重建,ForceClientRender 得以保留——注释(:1343-1352)自己承认“this is a bit weird in terms of factoring”,并把它列为未来重构项。

意义:SSR 注水失败时,React 必须先抛弃服务端 HTML、整个改成客户端渲染,再谈错误恢复。如果直接重试,重试会再次尝试注水、再次失败。预建栈 + ForceClientRender 是“一次性切换到客户端渲染”的开关。8.15.3 讲的 RootErrored 分支里 workInProgressRootRecoverableErrors = null 的“只报客户端错误”也和这条路径呼应。

这里还有一个容易漏的返回值细节(:1357-1363):

// ReactFiberWorkLoop.js:1357-1363
  const exitStatus = renderRootSync(root, errorRetryLanes, false);
  // A status of RootSuspendedAtTheShell means the retry unwound to the root
  // without completing (e.g. something suspended in the shell), so the tree is
  // incomplete and must not be treated as recovered — committing it would
  // corrupt the current tree. Fall through and return the status as-is so the
  // root stays suspended.
  if (exitStatus !== RootErrored && exitStatus !== RootSuspendedAtTheShell) {

条件里除了 RootErrored 还特意排除了 RootSuspendedAtTheShell。这是“恢复必须真的完成”的守卫:如果重试只是把树挂到 shell(数据没到),它不算恢复,不能把第一轮的错误当成 recoverable 丢弃掉,否则一旦数据到达、真正的错误浮现,日志里就少了第一轮的线索。

8.15.8 从 RootErrored 到 Error Boundary:一条完整链路

把错误从“被抛出”到“被边界渲染”串起来(不含预渲染分支):

sequenceDiagram
    participant C as 组件
    participant W as workLoop/replay
    participant HT as handleThrow(:2302)
    participant TU as throwAndUnwindWorkLoop(:3234)
    participant TX as ReactFiberThrow.throwException
    participant UB as unwindUnitOfWork(:3433)
    participant B as Error Boundary
    C->>W: 抛错
    W->>HT: catch(thrownValue)
    HT->>HT: 分类 → SuspendedOnError(=1)
    alt workInProgress === null
        HT->>HT: RootFatalErrored(:2375) + logUncaughtError
    else erroredWork 存在
        W->>TU: throwAndUnwindWorkLoop
        TU->>TX: throwException(root, returnFiber, ...)
        alt didFatal(连 componentDidCatch 都炸了)
            TU->>TU: panicOnRootError(:3352) → RootFatalErrored
        else 找到边界
            TX-->>UB: 标记 Incomplete 的 fiber 链
            UB->>UB: getIsHydrating() 或 SuspendedOnError → skipSiblings = true
            UB->>B: unwindWork 返回边界 → 重新 beginWork 渲染 fallback
        end
        alt unitOfWork.flags & Incomplete
            UB->>UB: 逐级 unwind,置父级 Incomplete、清 subtreeFlags、清 deletions
        else 否(Legacy Suspense)
            TU->>TU: completeUnitOfWork(故意提交不一致状态,靠隐藏兜底)
        end
    end
    B->>W: workInProgressRootExitStatus = RootErrored 或 commit

三个要点:

  1. 错误永远是“同步 unwind”,没有 replay 的机会——SuspendedOnError 在 renderRootConcurrent 的 resumeOrUnwind 里直接 throwAndUnwindWorkLoop(:2831-2842),而数据挂起(SuspendedOnData)才会先试 replaySuspendedUnitOfWork(:2846-2851)。这是错误与挂起在状态机里的根本分野。
  2. unwindUnitOfWork 逐级清理(:3479-3488):returnFiber.flags |= Incomplete、returnFiber.subtreeFlags = NoFlags、returnFiber.deletions = null。清 deletions 尤其重要——WIP 树上已经记录的待删除节点,如果边界要重新渲染 fallback,这些删除记录必须作废,否则会删到不该删的节点。
  3. HostEffectMask 裁剪(:3454):找到边界后 next.flags &= HostEffectMask,只保留宿主副作用标记。原因是之前已完成的部分 fiber 上挂着各种 flag,重启边界渲染时不能带着旧的 effect 标记,否则会重复执行 effect。

这三点加在一起,解释了为什么“错误边界能正常工作”看似简单,实则依赖 unwind 阶段一系列精确的清理。任何一个清理漏掉,症状都是“偶发地状态串味”,极难复现。


8.16 SSR 预渲染的并发路径

第 9 章会讲 Fizz 服务端渲染,但“预渲染(prerendering)”是客户端 reconciler 的一个并发特性,必须在本章收尾。

8.16.1 什么是预渲染:checkIfRootIsPrerendering

// ReactFiberLane.js:412-425
export function checkIfRootIsPrerendering(
  root: FiberRoot,
  renderLanes: Lanes,
): boolean {
  const pendingLanes = root.pendingLanes;
  const suspendedLanes = root.suspendedLanes;
  const pingedLanes = root.pingedLanes;
  // Remove lanes that are suspended (but not pinged)
  const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes);

  // If there are no unsuspended or pinged lanes, that implies that we're
  // performing a prerender.
  return (unblockedLanes & renderLanes) === 0;
}

语义:本次渲染的 lane 全部处于“挂起且未 ping”状态——没有任何真实用户在等这批 lane 的结果。这种渲染就是“预渲染/预热”:典型场景是 Offscreen 树(Activity/OffscreenLane)或 SSR 的 shell 等待。既然没人在等,渲染的目标就从“尽快出结果”变成“别妨碍别人”。

8.16.2 预渲染为什么必须时间切片

三处代码在反复强调同一件事:

// ReactFiberWorkLoop.js:1172-1177(performWorkOnRoot)
  const shouldTimeSlice =
    (!forceSync &&
      !includesBlockingLane(lanes) &&
      !includesExpiredLane(root, lanes)) ||
    // If we're prerendering, then we should use the concurrent work loop
    // even if the lanes are synchronous, so that prerendering never blocks
    // the main thread.
    checkIfRootIsPrerendering(root, lanes);

预渲染可以推翻 forceSync 和 blocking lane 的判断:即使 lane 是同步的,只要判定为预渲染,就强制走并发路径。注释给出理由:"so that prerendering never blocks the main thread"——预渲染是后台准备工作,真实用户的交互随时可能到来,后台渲染霸占主线程 300ms 是不可接受的。

renderRootSync 里也有对称的一道保险(:2701-2712):同步渲染中途如果发现切进了预渲染模式(shouldYieldForPrerendering && workInProgressRootIsPrerendering),立刻 exitStatus = RootInProgress; break outer——把控制权还给调度器,下次用并发 loop 重进。performWorkOnRoot 随后补一刀 markRootSuspended(root, lanes, NoLane, false)(:1200)确保 root 保持挂起状态等待恢复。

8.16.3 预渲染改变 unwind 行为

// ReactFiberWorkLoop.js:3289-3325(throwAndUnwindWorkLoop,节选)
    } else if (
      // Check whether this is a prerender
      !workInProgressRootIsPrerendering &&
      // Offscreen rendering is also a form of speculative rendering
      !includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
    ) {
      // This is not a prerender. Skip the siblings during this render. A
      // separate prerender will be scheduled for later.
      skipSiblings = true;
      workInProgressRootDidSkipSuspendedSiblings = true;
      // ...(挂 ScheduleRetry,见 :3300-3321)
    } else {
      // This is a prerender. Don't skip the siblings.
      skipSiblings = false;
    }

真实渲染中,一个组件挂起 → 跳过兄弟 → 只渲染到边界 → 提交 fallback;预渲染中,一个组件挂起 → 不跳过兄弟,继续把整棵树渲染完(边界内显示 fallback,其他部分照常)。这样数据到达时整棵树都是热的,一次渲染直接出最终 UI。workInProgressRootDidSkipSuspendedSiblings 是否置位,决定了 finishConcurrentRender 里 didAttemptEntireTree 的值(:1457),进而影响 markRootSuspended 后要不要再排一次预热。

8.16.4 预渲染与 useDeferredValue 的互动

requestDeferredLane(ReactFiberWorkLoop.js:884)里有专门的预渲染分支:

    // OffscreenLane is used for prerendering, but we also use OffscreenLane
    // for incremental hydration. It's given the lowest priority because the
    // initial HTML is the same as the final UI. But useDeferredValue during
    // hydration is an exception — we need to upgrade the UI to the final
    // value. So if we're currently hydrating, we treat it like a transition.
    const isPrerendering =
      includesSomeLane(workInProgressRootRenderLanes, OffscreenLane) &&
      !getIsHydrating();
    if (isPrerendering) {
      // There's only one OffscreenLane, so if it contains deferred work, we
      // should just reschedule using the same lane.
      workInProgressDeferredLane = OffscreenLane;
    } else {
      // Everything else is spawned as a transition.
      workInProgressDeferredLane = claimNextTransitionDeferredLane();
    }

预渲染中的 deferred 工作直接复用 OffscreenLane(反正没人看,不必另起 transition);但注水(hydration)期间例外——HTML 与最终 UI 一致只是暂时的,必须升级到最终值,所以按 transition 处理。

8.16.5 shell 挂起计数器

// ReactFiberWorkLoop.js:2733-2741(renderRootSync 尾部)
  // Check if something suspended in the shell. We use this to detect an
  // infinite ping loop caused by an uncached promise.
  //
  // Only increment this counter once per synchronous render attempt across the
  // whole tree. Even if there are many sibling components that suspend, this
  // counter only gets incremented once.
  if (didSuspendInShell) {
    root.shellSuspendCounter++;
  }

“shell”指最外层 Suspense 边界以内的内容。如果一次同步渲染在 shell 里挂起(getSuspenseHandler() === null,:2694),计数器 +1。这个计数器配合 enableInfiniteRenderLoopDetection 检测“未缓存 promise 导致的无限 ping 循环”——8.19 会看到 async action 场景下这个陷阱的实例。

didSuspendInShell 的置位点只有一个(:2694-2696),且只在 renderRootSync 的 SuspendedOnImmediate/OnData/OnAction/OnDeprecatedThrowPromise 分支里:

// ReactFiberWorkLoop.js:2693-2696
          case SuspendedOnImmediate:
          case SuspendedOnData:
          case SuspendedOnAction:
          case SuspendedOnDeprecatedThrowPromise: {
            if (getSuspenseHandler() === null) {
              didSuspendInShell = true;
            }

只在同步渲染里统计——renderRootConcurrent 里没有对称的代码。这个不对称是刻意的:计数器要检测的“无限 ping 循环”只会出现在同步渲染里,因为同步渲染没有时间切片保护,ping→重渲→ping 的循环会瞬间把主线程烧干。并发渲染天然会被 shouldYield 打断,反而不会形成“无法观察的死循环”。

8.16.6 预渲染的 lane 调度:warmLanes 与 prewarm

8.16.1 的 checkIfRootIsPrerendering 回答了“当前这次渲染是不是预渲染”。还有第二个问题:“接下来要不要做预渲染?”——由 getNextLanes 的 prewarm 分支回答(ReactFiberLane.js:283-301):

// ReactFiberLane.js:283-301
    // First check for fresh updates.
    const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
    if (nonIdleUnblockedLanes !== NoLanes) {
      nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
    } else {
      // No fresh updates. Check if suspended work has been pinged.
      const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes;
      if (nonIdlePingedLanes !== NoLanes) {
        nextLanes = getHighestPriorityLanes(nonIdlePingedLanes);
      } else {
        // Nothing has been pinged. Check for lanes that need to be prewarmed.
        if (!rootHasPendingCommit) {
          const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes;
          if (lanesToPrewarm !== NoLanes) {
            nextLanes = getHighestPriorityLanes(lanesToPrewarm);
          }
        }
      }
    }

warmLanes(root 上的字段)记录“已经预热过的 lane”。nonIdlePendingLanes & ~warmLanes 就是“还挂着、既没被 ping、也没预热过的 lane”。这段代码的位置很讲究:它排在“fresh updates”和“pinged lanes”之后——预渲染永远是最后的选择。注释里还有一句关键说明(:266-276):rootHasPendingCommit 为真时不预热,因为“a completed tree that is ready to commit”比投机性的预热更值钱。

markRootSuspended 的 didAttemptEntireTree 参数(:1802-1821)就是给这套机制用的:只有“没渲染完整棵树”(didAttemptEntireTree === false)时才把 lane 记进 warmLanes,表示“这棵子树还是冷的,值得再热一次”;如果已经整棵渲染完(预渲染的成功路径),就不再重复预热。

于是在 finishConcurrentRender 里能看到三种 didAttemptEntireTree 的来源(ReactFiberWorkLoop.js):

  • RootSuspendedWithDelay / RootSuspendedAtTheShell:!workInProgressRootDidSkipSuspendedSiblings(:1457)——跳过了兄弟 = 没渲染完整棵;
  • retry 节流:同上(:1514-1515);
  • 致命错误:硬编码 true(:1306)——注释说“pretend we attempted the entire tree, to avoid scheduling a prerender”,因为树已经不可信,预热毫无意义。

三个来源体现出同一套判断标准:“这次渲染有没有留下冷的、还没尝试过的子树?”

8.16.7 预渲染的四处判定入口(汇总)

checkIfRootIsPrerendering(ReactFiberLane.js:412)在整个模块里被调用了四次,每次的目的都不同:

// 1) ReactFiberWorkLoop.js:1168-1177 —— 决定渲染姿态
  const shouldTimeSlice =
    (!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes)) ||
    checkIfRootIsPrerendering(root, lanes);

// 2) ReactFiberWorkLoop.js:2262 —— prepareFreshStack 里记录到模块级变量
  workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);

// 3) ReactFiberWorkLoop.js:2808-2814 —— continuation 分支下重算
  } else {
    workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
  }

// 4) ReactFiberRootScheduler.js:442-448 —— 决定回调优先级
    includesSyncLane(nextLanes) &&
    !checkIfRootIsPrerendering(root, nextLanes)

四处各司其职:

  • 入口 1:本次用同步还是并发 loop——这是 8.16.2 的主题;
  • 入口 2:把判定结果冻结成模块级变量 workInProgressRootIsPrerendering(:471),供 throwAndUnwindWorkLoop(8.16.3,:3291)和 requestDeferredLane(8.16.4,:899)使用。为什么要冻结?因为渲染过程中可能发生 ping / 新更新,root.pendingLanes 会变,实时重算会让同一次渲染的前后半段采用不同策略。冻结保证“一次渲染内策略一致”;
  • 入口 3:continuation(同一 root 同 lanes 重入)时重新评估——这次渲染可能已经“转正”,不再是预渲染,于是 unwind 行为要切回来;
  • 入口 4:调度器里把 includesSyncLane(nextLanes) 的同步任务降级为普通 Scheduler 任务,实现“同步 lane 也不阻塞主线程”。8.16.2 强调了同一件事——预渲染可以推翻同步。

四处调用共享同一个判定函数,但消费方式完全不同。读 React 源码时这类“同一谓词多处使用、语义各异”的模式非常常见,逐个确认调用点的目的是理解并发行为的关键。checkIfRootIsPrerendering 尤其典型:它读三个 root 字段(pendingLanes/suspendedLanes/pingedLanes)算出一个“有没有人在等”的布尔量,而这个布尔量在四处被解释成四种不同的策略开关。


8.17 useDeferredValue 的完整状态机

8.6 给了 updateDeferredValueImpl 的概念示意,这里给出带全部边界分支的真实代码与状态机。

8.17.1 四个入口函数

// ReactFiberHooks.js:2990-3012
function mountDeferredValue<T>(value: T, initialValue?: T): T {
  const hook = mountWorkInProgressHook();
  return mountDeferredValueImpl(hook, value, initialValue);
}

function updateDeferredValue<T>(value: T, initialValue?: T): T {
  const hook = updateWorkInProgressHook();
  const resolvedCurrentHook: Hook = currentHook as any;
  const prevValue: T = resolvedCurrentHook.memoizedState;
  return updateDeferredValueImpl(hook, prevValue, value, initialValue);
}

function rerenderDeferredValue<T>(value: T, initialValue?: T): T {
  const hook = updateWorkInProgressHook();
  if (currentHook === null) {
    // This is a rerender during a mount.
    return mountDeferredValueImpl(hook, value, initialValue);
  } else {
    // This is a rerender during an update.
    const prevValue: T = currentHook.memoizedState;
    return updateDeferredValueImpl(hook, prevValue, value, initialValue);
  }
}

useDeferredValue(value, initialValue) 有三个挂载点(mount/update/rerender),统一汇入两个 impl。rerenderDeferredValue 的存在是因为组件可能先挂起后重放(replayBeginWork 用 re-render dispatcher)——那时 currentHook 可能还是 null(挂起发生在第一个 hook 之前)。

8.17.2 mount 的两条路径

// ReactFiberHooks.js:3028-3054
function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
  if (
    // When `initialValue` is provided, we defer the initial render even if the
    // current render is not synchronous.
    initialValue !== undefined &&
    // However, to avoid waterfalls, we do not defer if this render
    // was itself spawned by an earlier useDeferredValue. Check if DeferredLane
    // is part of the render lanes.
    !isRenderingDeferredWork()
  ) {
    // Render with the initial value
    hook.memoizedState = initialValue;

    // Schedule a deferred render to switch to the final value.
    const deferredLane = requestDeferredLane();
    currentlyRenderingFiber.lanes = mergeLanes(
      currentlyRenderingFiber.lanes,
      deferredLane,
    );
    markSkippedUpdateLanes(deferredLane);

    return initialValue;
  } else {
    hook.memoizedState = value;
    return value;
  }
}

路径 A(initialValue !== undefined 且当前不是 deferred 渲染):先渲染 initialValue,并排一个 deferred lane 的后续渲染去换成 value。markSkippedUpdateLanes(ReactFiberWorkLoop.js:2539)把这笔账记到 workInProgressRootSkippedLanes,告诉 work loop“有一笔更新被跳过了”。

路径 B(无 initialValue,或当前渲染本身就是 deferred 工作):直接用 value。

isRenderingDeferredWork 是防瀑布的关键(:3014-3026):

function isRenderingDeferredWork(): boolean {
  if (!includesSomeLane(renderLanes, DeferredLane)) {
    // None of the render lanes are deferred lanes.
    return false;
  }
  // At least one of the render lanes are deferred lanes. However, if the
  // current render is also batched together with an update, then we can't
  // say that the render is wholly the result of deferred work. We can check
  // this by checking if the root render lanes contain any "update" lanes, i.e.
  // lanes that are only assigned to updates, like setState.
  const rootRenderLanes = getWorkInProgressRootRenderLanes();
  return !includesSomeLane(rootRenderLanes, UpdateLanes);
}

两个条件同时满足才算“纯 deferred 渲染”:render lanes 里有 DeferredLane,且 root render lanes 里没有 update lane。否则——比如 deferred 追赶渲染恰好和一个紧急更新被批到一起——就不能再 defer 一次,否则出现 initialValue → 旧value → 新value 的三段式瀑布。

8.17.3 update 的四条分支

// ReactFiberHooks.js:3056-3109(注释保留原文)
function updateDeferredValueImpl<T>(
  hook: Hook,
  prevValue: T,
  value: T,
  initialValue?: T,
): T {
  if (is(value, prevValue)) {
    // The incoming value is referentially identical to the currently rendered
    // value, so we can bail out quickly.
    return value;
  } else {
    // Received a new value that's different from the current value.

    // Check if we're inside a hidden tree
    if (isCurrentTreeHidden()) {
      // Revealing a prerendered tree is considered the same as mounting new
      // one, so we reuse the "mount" path in this case.
      const resultValue = mountDeferredValueImpl(hook, value, initialValue);
      // Unlike during an actual mount, we need to mark this as an update if
      // the value changed.
      if (!is(resultValue, prevValue)) {
        markWorkInProgressReceivedUpdate();
      }
      return resultValue;
    }

    const shouldDeferValue =
      !includesOnlyNonUrgentLanes(renderLanes) && !isRenderingDeferredWork();
    if (shouldDeferValue) {
      // This is an urgent update. Since the value has changed, keep using the
      // previous value and spawn a deferred render to update it later.

      // Schedule a deferred render
      const deferredLane = requestDeferredLane();
      currentlyRenderingFiber.lanes = mergeLanes(
        currentlyRenderingFiber.lanes,
        deferredLane,
      );
      markSkippedUpdateLanes(deferredLane);

      // Reuse the previous value. We do not need to mark this as an update,
      // because we did not render a new value.
      return prevValue;
    } else {
      // This is not an urgent update, so we can use the latest value regardless
      // of what it is. No need to defer it.

      // Mark this as an update to prevent the fiber from bailing out.
      markWorkInProgressReceivedUpdate();
      hook.memoizedState = value;
      return value;
    }
  }
}

四条分支画成状态机:

stateDiagram-v2
    [*] --> Check
    Check: is(value, prevValue)?
    Check --> Same : 是
    Same --> [*] : return value(快路径,零开销)
    Check --> Different : 否
    Different --> Hidden : isCurrentTreeHidden()
    Hidden --> MountPath : 复用 mount 逻辑
    MountPath --> MarkUpdate : resultValue !== prevValue?
    MarkUpdate --> [*] : markWorkInProgressReceivedUpdate
    Different --> NotHidden : 非隐藏树
    NotHidden --> Urgent : !includesOnlyNonUrgentLanes(renderLanes)<br/>&& !isRenderingDeferredWork()
    Urgent --> Defer : shouldDeferValue = true
    Defer --> [*] : requestDeferredLane<br/>markSkippedUpdateLanes<br/>return prevValue(慢半拍)
    NotHidden --> NonUrgent : 当前就是低优先级渲染
    NonUrgent --> [*] : markWorkInProgressReceivedUpdate<br/>hook.memoizedState = value<br/>return value(追赶上了)

几个关键点:

  1. includesOnlyNonUrgentLanes(ReactFiberLane.js:625):
export function includesOnlyNonUrgentLanes(lanes: Lanes): boolean {
  // TODO: Should hydration lanes be included here? This function is only
  // used in `updateDeferredValueImpl`.
  const UrgentLanes =
    SyncLane | InputContinuousLane | DefaultLane | GestureLane;
  return (lanes & UrgentLanes) === NoLanes;
}

紧急 lane = Sync + InputContinuous + Default + Gesture。当前渲染 lanes 不含任何紧急 lane 时才允许“直接采用新值”——这次渲染本身就是追赶渲染,再 defer 就是无限循环。

  1. markWorkInProgressReceivedUpdate(ReactFiberBeginWork.js:3800)只有一行:didReceiveUpdate = true。它的作用是阻止 fiber bailout——beginWork 的 bailout 优化会检查这个标志,如果组件“渲染了但没有收到任何更新”且 props 没变,就直接复用子树。deferred 追赶渲染必须真正走到子组件,所以显式标记“我收到更新了”。反之,defer 分支刻意不调它(:3096-3097 注释:"We do not need to mark this as an update, because we did not render a new value.")——这次渲染根本没产生新值,bailout 掉正好。

  2. isCurrentTreeHidden() 分支:被 Activity/Offscreen 隐藏的树里,useDeferredValue 走 mount 路径——揭示一个预渲染树等同于挂载新树。

8.17.4 deferred lane 如何被紧急更新打断

requestDeferredLane 申请的是 TransitionDeferredLanes(TransitionLane11..14,claimNextTransitionDeferredLane,ReactFiberLane.js:738)。回到 8.7.1 的中断保护(ReactFiberLane.js:337-357):

  if (
    wipLanes !== NoLanes &&
    wipLanes !== nextLanes &&
    (wipLanes & suspendedLanes) === NoLanes
  ) {
    const nextLane = getHighestPriorityLane(nextLanes);
    const wipLane = getHighestPriorityLane(wipLanes);
    if (
      nextLane >= wipLane ||
      (nextLane === DefaultLane && (wipLanes & TransitionLanes) !== NoLanes)
    ) {
      // Keep working on the existing in-progress tree. Do not interrupt.
      return wipLanes;
    }
  }

推论:

  • DefaultLane(普通 setState)不能打断 deferred 追赶渲染——刻意设计,否则用户的每次普通输入都会重启追赶。
  • SyncLane / InputContinuousLane 可以——nextLane >= wipLane 为假(Sync 位更小),不返回 wipLanes,于是 getNextLanes 返回新的同步 lane,ensureRootIsScheduled 会重新调度,当前 deferred 渲染被废弃(下一次进来时 lane 不同,走 prepareFreshStack)。
  • 兜底:过期。markStarvedLanesAsExpired(:541)给 transition/deferred lane 计算 transitionLaneExpirationMs 过期时间,饿太久后 includesExpiredLane 为真,shouldTimeSlice 转假,强制同步跑完。

这就是 8.6.3 说的“deferred 渲染必须能被紧急更新打断”在 lane 层的完整落地:能被 Sync/InputContinuous 打断,不能被 Default 打断,永远不可能被饿死。

8.17.5 DidDefer 标志与 Suspense 边界的联动

requestDeferredLane(ReactFiberWorkLoop.js:884)在返回 lane 之前,还做了一件事(:911-918):

// ReactFiberWorkLoop.js:911-918
  // Mark the parent Suspense boundary so it knows to spawn the deferred lane.
  const suspenseHandler = getSuspenseHandler();
  if (suspenseHandler !== null) {
    // TODO: As an optimization, we shouldn't entangle the lanes at the root; we
    // can entangle them using the baseLanes of the Suspense boundary instead.
    // We only need to do something special if there's no Suspense boundary.
    suspenseHandler.flags |= DidDefer;
  }

  return workInProgressDeferredLane;

给最近的 Suspense 边界打上 DidDefer。这个 flag 在边界 completeWork 时被读走,作用是让边界把 deferred lane 转成自己的 retry lane——即“我里面有个 useDeferredValue 生成了延迟工作,边界需要等它,并在它完成时重新渲染”。

为什么注释说这里“不应该在 root 上纠缠 lane”?因为把 deferred lane 纠缠到整个 root 意味着任何 deferred 追赶都会牵连 root 上所有同批更新。理想方案是让边界用自己的 baseLanes 承载这笔延迟工作,范围更小。这是 React 团队自己标注的待优化点,也解释了为什么 deferred lane 的行为在某些复杂边界嵌套下看起来“比预期更着急”。

注意:useDeferredValue 不需要 Suspense 也能工作。getSuspenseHandler() === null 时 DidDefer 无处可打,deferred lane 就退化成一笔普通的 transition 更新(claimNextTransitionDeferredLane,ReactFiberLane.js:738),由 root 级别调度。差异只在“能否被边界局部消化”。

另外,requestDeferredLane 的“同一次渲染内复用”也值得强调(:885-909):if (workInProgressDeferredLane === NoLane)——一条渲染里所有 useDeferredValue 产生的延迟工作共享同一条 lane。这是为了让它们能一次追赶渲染全部完成,而不是每个 hook 各排一次。这个优化和 isRenderingDeferredWork(:3014)配合,避免“N 个 useDeferredValue → N 次追赶渲染”的瀑布。

8.17.6 与 Activity / hidden 树的互动:isCurrentTreeHidden

8.17.3 的分支表里有个特例:isCurrentTreeHidden() 为真时走 mount 路径。把这段(ReactFiberHooks.js:3069-3080)与 8.16 的预渲染放一起看,语义就完整了:

// ReactFiberHooks.js:3069-3080
    // Check if we're inside a hidden tree
    if (isCurrentTreeHidden()) {
      // Revealing a prerendered tree is considered the same as mounting new
      // one, so we reuse the "mount" path in this case.
      const resultValue = mountDeferredValueImpl(hook, value, initialValue);
      // Unlike during an actual mount, we need to mark this as an update if
      // the value changed.
      if (!is(resultValue, prevValue)) {
        markWorkInProgressReceivedUpdate();
      }
      return resultValue;
    }

逐点:

  1. 隐藏树里没有“追赶”的概念。被 Activity/Offscreen 隐藏的子树不参与提交,屏幕上什么都没显示,所以不存在“先给用户看旧值、再追新值”的体验诉求。此时直接按“挂载”处理:有 initialValue 就渲染它并排 deferred lane,没有就直接用 value。
  2. 但仍要标 markWorkInProgressReceivedUpdate(条件 !is(resultValue, prevValue))。原因和 8.17.2 里“mount 路径不标记”不同:mount 时 fiber 本来就是新建的,不存在 bailout 的可能;而在 update 中走到这条分支时,fiber 可能因为 props 没变、lane 不够而准备 bailout。如果隐藏树里 useDeferredValue 的值确实变了却不标 didReceiveUpdate,子树会被错误地跳过,等树被揭示(reveal)时就会显示旧值。
  3. 隐藏树的揭示等价于挂载——这条注释是理解 Activity 与并发关系的钥匙。它意味着“揭示”本身不是一次“更新”,而是把预渲染好的、早已算完的树一次性呈现。这也解释了 8.16.4 为什么预渲染中的 deferred 工作可以直接复用 OffscreenLane:预渲染树的最终值本来就是靠 OffscreenLane 那一轮算出来的。

把三条分支(mount 路径、紧急 defer、非紧急采用)画成一张全景图:

flowchart TD
    Start[&#34;useDeferredValue(value, initialValue?)&#34;] --> M{&#34;mount 还是 update?&#34;}
    M -->|mount| MI{&#34;initialValue !== undefined<br/>且 !isRenderingDeferredWork()?&#34;}
    MI -->|是| MS[&#34;hook.memoizedState = initialValue<br/>requestDeferredLane + markSkippedUpdateLanes&#34;]
    MI -->|否| MP[&#34;hook.memoizedState = value&#34;]
    M -->|update| EQ{&#34;is(value, prevValue)?&#34;}
    EQ -->|是| EQY[&#34;return value(零开销 bailout)&#34;]
    EQ -->|否| HID{&#34;isCurrentTreeHidden()?&#34;}
    HID -->|是| HM[&#34;走 mount 逻辑<br/>值变了则 markWorkInProgressReceivedUpdate&#34;]
    HID -->|否| URG{&#34;!includesOnlyNonUrgentLanes(renderLanes)<br/>且 !isRenderingDeferredWork()?&#34;}
    URG -->|是| UD[&#34;保留 prevValue<br/>requestDeferredLane + markSkippedUpdateLanes<br/>刻意不标 update(可 bailout)&#34;]
    URG -->|否| UN[&#34;markWorkInProgressReceivedUpdate<br/>hook.memoizedState = value&#34;]

三条“排 deferred lane”的路径(MS / HM 里的 mount 路径 / UD)都调用了 requestDeferredLane,但它们标记/不标记 update 的选择各不相同:MS 不标(挂载无需 bailout)、HM 按值是否变化标、UD 刻意不标。这张表是排查“useDeferredValue 在隐藏树里不更新”类问题的第一手资料。

最后要强调一个默认值的陷阱。mountDeferredValueImpl 的 initialValue !== undefined 判断(:3032)用的是 !== undefined 而不是 != null:useDeferredValue(value, null) 会被当作“没有提供 initialValue”,直接渲染 value。这不是 bug——initialValue 的类型是 T | undefined,传 null 在语义上就是“初值就是 value 本身”。但如果你的 value 类型允许 null,容易误用。


8.18 边界、坑与调试

  1. startTransition 回调必须同步。如果你在回调里 await 之后才 setState,那个更新已经不在 transition 的 lane 上下文里了(React 19 支持 async action 是另一套机制,见第 13 章)。
  2. useDeferredValue 不是防抖。防抖是“等用户停手再算”,useDeferredValue 是“先渲染旧值,空闲时追赶新值”。前者会让 UI 滞后于输入,后者不会。
  3. transition 里抛出的错误仍然会被 Error Boundary 捕获。并发不等于“错误被吞掉”——SuspendedOnError 分支就是干这个的。
  4. 不要在 transition 里做副作用。transition 可能被丢弃、重跑多次,useEffect 也可能被跳过(React 19 的 view transition 场景)。副作用放进 effect,或者用 useEffectEvent。
  5. 调试技巧:Chrome DevTools 的 Performance 面板里,React 的并发渲染会显示为多个 Schedule / Render 片段,中间夹着其他任务。如果你看到一次渲染是一条长长的、不间断的黄色块,说明它走了同步路径——查 shouldTimeSlice 的四个条件。

8.19 startTransition 的 async action:React 19 的等待机制

React 19 让 startTransition 的回调可以返回 Promise,React 自动保持 isPending 直到 Promise settle。这套机制的完整实现在 ReactFiberAsyncAction.js(263 行)与 ReactFiberHooks.js 的 startTransition(:3117-3262)。

8.18.1 useTransition 的 isPending 到底怎么“保持”

先看消费端。mountTransition(ReactFiberHooks.js:3425):

function mountTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const stateHook = mountStateImpl(false as Thenable<boolean> | boolean);
  // The `start` method never changes.
  const start = startTransition.bind(
    null,
    currentlyRenderingFiber,
    stateHook.queue,
    true,
    false,
  );
  const hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [false, start];
}

isPending 只是 mountStateImpl(false) 的一个普通 state hook。start 是绑定了 fiber、queue、pendingState=true、finishedState=false 的 startTransition。

更新端(:3443-3456):

function updateTransition(): [boolean, (callback) => void] {
  const [booleanOrThenable] = updateState(false);
  const hook = updateWorkInProgressHook();
  const start = hook.memoizedState;
  const isPending =
    typeof booleanOrThenable === 'boolean'
      ? booleanOrThenable
      : // This will suspend until the async action scope has finished.
        useThenable(booleanOrThenable);
  return [isPending, start];
}

这是全章最精巧的一处设计:async action 进行中,isPending 的 state 值不是 true,而是一个 thenable!typeof booleanOrThenable === 'boolean' 为假,于是走 useThenable——挂起当前组件渲染,直到 action scope 完成。也就是说:

  • action 进行中 → isPending 是 thenable → 组件挂起(通常挂在 transition 边界里,用户看到旧 UI + 边界不闪烁);
  • action 完成 → thenable resolve → state 变成 false → 重放 → isPending = false。

8.18.2 startTransition(reconciler 侧)逐段

// ReactFiberHooks.js:3117-3262(节选,注释保留原文)
function startTransition<S>(
  fiber: Fiber,
  queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
  pendingState: S,
  finishedState: S,
  callback: () => mixed,
  options?: StartTransitionOptions,
): void {
  const previousPriority = getCurrentUpdatePriority();
  setCurrentUpdatePriority(
    higherEventPriority(previousPriority, ContinuousEventPriority),
  );

  const prevTransition = ReactSharedInternals.T;
  const currentTransition: Transition = {} as any;
  // ...(types / gesture / name / startTime 初始化,:3132-3153)
  ReactSharedInternals.T = currentTransition;
  dispatchOptimisticSetState(fiber, false, queue, pendingState);

  try {
    const returnValue = callback();
    const onStartTransitionFinish = ReactSharedInternals.S;
    if (onStartTransitionFinish !== null) {
      onStartTransitionFinish(currentTransition, returnValue);
    }

    // Check if we're inside an async action scope. If so, we'll entangle
    // this new action with the existing scope.
    // ...
    if (
      returnValue !== null &&
      typeof returnValue === 'object' &&
      typeof returnValue.then === 'function'
    ) {
      const thenable = returnValue as any as Thenable<mixed>;
      // ...
      // Create a thenable that resolves to `finishedState` once the async
      // action has completed.
      const thenableForFinishedState = chainThenableValue(
        thenable,
        finishedState,
      );
      dispatchSetStateInternal(
        fiber,
        queue,
        thenableForFinishedState as any,
        requestUpdateLane(fiber),
      );
    } else {
      dispatchSetStateInternal(
        fiber,
        queue,
        finishedState,
        requestUpdateLane(fiber),
      );
    }
  } catch (error) {
    // This is a trick to get the `useTransition` hook to rethrow the error.
    // When it unwraps the thenable with the `use` algorithm, the error
    // will be thrown.
    const rejectedThenable: RejectedThenable<S> = {
      then() {},
      status: 'rejected',
      reason: error,
    };
    dispatchSetStateInternal(
      fiber,
      queue,
      rejectedThenable,
      requestUpdateLane(fiber),
    );
  } finally {
    setCurrentUpdatePriority(previousPriority);
    // ...(types 转移给外层 transition,:3228-3245)
    ReactSharedInternals.T = prevTransition;
    // ...(DEV 下 >10 个更新警告,:3248-3260)
  }
}

五个要点:

  1. setCurrentUpdatePriority(higherEventPriority(previousPriority, ContinuousEventPriority)):transition 内的更新至少是 Continuous 优先级——requestUpdateLane 最终走到 requestTransitionLane(transition)(ReactFiberWorkLoop.js:860)拿 transition lane,而不是事件默认 lane。这解决了“在 click 事件里 startTransition,更新却拿了 Discrete 优先级”的问题。
  2. dispatchOptimisticSetState(fiber, false, queue, pendingState):先把 isPending 置 true。注释(:3155-3160)说明这是 optimistic update,与后面的 revert update 共享 lane。
  3. chainThenableValue(thenable, finishedState):把用户的 Promise 包装成“resolve 后值为 finishedState(即 false)”的 thenable,作为 state 更新 dispatch。于是 action 完成后,isPending 的 state 自然变成 false。
  4. catch 分支的 rejectedThenable:回调同步抛错时,制造一个 status: 'rejected' 的 thenable 作为新 state。下次渲染时 useThenable 读到 rejected thenable,把错误抛出来给 Error Boundary。这是“startTransition 里抛错也能被边界捕获”的实现路径。
  5. ReactSharedInternals.T 的嵌套管理:prevTransition 保存外层,finally 恢复。嵌套 startTransition 时,types 集合父子共享(:3228-3245)——内外层 transition 在 View Transition 语义上是同一个。

packages/react/src/ReactStartTransition.js 里的公开 API 版 startTransition(:45-118)结构类似但没有 queue/fiber 参数——它只负责设置 ReactSharedInternals.T 与调用 ReactSharedInternals.S(DevTools 注入的 onStartTransitionFinish 钩子),真正的更新调度在 react-reconciler 内部完成。这是 React 19 “react 包只留 API 外壳,逻辑在 reconciler” 架构的又一个例证。

8.18.3 ReactFiberAsyncAction.js:纠缠的 action scope

// ReactFiberAsyncAction.js:37-54(模块注释)
// If there are multiple, concurrent async actions, they are entangled. All
// transition updates that occur while the async action is still in progress
// are treated as part of the action.
//
// The ideal behavior would be to treat each async function as an independent
// action. However, without a mechanism like AsyncContext, we can't tell which
// action an update corresponds to. So instead, we entangle them all into one.

// The listeners to notify once the entangled scope completes.
let currentEntangledListeners: Array<() => mixed> | null = null;
// The number of pending async actions in the entangled scope.
let currentEntangledPendingCount: number = 0;
// The transition lane shared by all updates in the entangled scope.
let currentEntangledLane: Lane = NoLane;
// A thenable that resolves when the entangled scope completes. ...
let currentEntangledActionThenable: Thenable<void> | null = null;

entangleAsyncAction(:71-101):

export function entangleAsyncAction<S>(
  transition: Transition,
  thenable: Thenable<S>,
): Thenable<S> {
  // `thenable` is the return value of the async action scope function. Create
  // a combined thenable that resolves once every entangled scope function
  // has finished.
  if (currentEntangledListeners === null) {
    // There's no outer async action scope. Create a new one.
    const entangledListeners = (currentEntangledListeners = []);
    currentEntangledPendingCount = 0;
    currentEntangledLane = requestTransitionLane(transition);
    const entangledThenable: Thenable<void> = {
      status: 'pending',
      value: undefined,
      then(resolve: void => mixed) {
        entangledListeners.push(resolve);
      },
    };
    currentEntangledActionThenable = entangledThenable;
    // ...
  }
  currentEntangledPendingCount++;
  thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
  return thenable;
}

行为:第一个 async action 创建 scope(申请共享 lane + 一个“聚合 thenable”);后续所有并发 action 只是 pendingCount++ 并挂同一个完成回调。pingEngtangledActionScope(:103-139)在计数归零时把聚合 thenable 置 fulfilled、清空所有模块级变量、依次通知监听者。

为什么需要纠缠? 假设页面同时发起两个 async action:action A 加载用户信息,action B 加载订单。两者都返回 transition。如果不纠缠,A 完成后 transition 提交一半 UI(用户信息有了、订单没有),B 完成后再提交另一半——用户看到两次中间态。纠缠后,两个 action 共享一条 transition lane、一个聚合 thenable,只有全部完成才恢复渲染,一次提交出最终 UI。8.13.1 讲的 lane 纠缠是“同一批更新的原子性”,这里是“同一批异步工作的原子性”,两套机制在 requestTransitionLane 处汇合(ReactFiberRootScheduler.js:713-717 优先复用 peekEntangledActionLane())。

8.18.4 isPending 的完整生命周期

sequenceDiagram
    participant U as 用户代码
    participant ST as startTransition(Hooks)
    participant Q as isPending state queue
    participant EA as ReactFiberAsyncAction
    participant R as renderRootConcurrent
    U->>ST: startTransition(async () => {...})
    ST->>Q: dispatchOptimisticSetState(true)
    ST->>U: 执行回调
    U-->>ST: 返回 Promise
    ST->>EA: entangleAsyncAction(transition, promise)
    EA->>EA: currentEntangledPendingCount = 1<br/>currentEntangledLane = 申请
    ST->>Q: dispatchSetState(chainThenableValue(promise, false))
    Note over Q: state = thenableForFinishedState
    R->>R: transition 渲染,组件挂起在 thenable 上
    Note over R: useThenable → SuspendedOnData → break outer
    U-->>EA: Promise resolve
    EA->>EA: pendingCount = 0 → 聚合 thenable fulfilled
    EA->>R: 通知监听者 → ensureRootIsScheduled
    R->>R: 重入,isThenableResolved = true<br/>replaySuspendedUnitOfWork
    R->>Q: state = false
    Note over R: isPending = false,UI 更新到最终状态

一个边界:回调同步抛错时,rejectedThenable 让 useThenable 在下一次渲染时抛出错误,走 SuspendedOnError → unwind → Error Boundary。也就是说 async action 的错误处理复用了本章的挂起状态机,没有单独的机制。

8.18.5 DEV 计数器与 act

// ReactFiberHooks.js:3185-3189
      if (__DEV__) {
        // Keep track of the number of async transitions still running so we can warn.
        ReactSharedInternals.asyncTransitions++;
        thenable.then(releaseAsyncTransition, releaseAsyncTransition);
      }

ReactSharedInternals.asyncTransitions 是 DEV 计数器,addTransitionType 在异步空档期被调用时会检查它是否为 0 来报警告(ReactTransitionType.js:32)。测试环境里忘记 await 的 async action 会挂起渲染,act 通过 ReactSharedInternals.actQueue(ReactFiberRootScheduler.js:634)接管所有调度回调,保证 act 作用域内渲染一次跑完。


8.20 调试并发渲染的实操

并发渲染的问题(闪烁、卡顿、pending 不结束)很难用 console.log 定位,因为“渲染被分成了很多段”。本节给出一套基于源码标记的调试方法。

8.19.1 DevTools Performance 面板:识别并发渲染的三个特征

React 在并发渲染中会在 Performance 面板留下可识别的痕迹,全部来自 ReactFiberWorkLoop.js 的 profiling 埋点:

痕迹源码埋点含义
同一组件出现多个 Render 段,中间夹着其他任务markRenderStarted(:2817) / markRenderYielded(:3034) / markRenderStopped(:3040)渲染被 Scheduler 切分——并发路径的指纹
段上标注 “Interrupted”logInterruptedRenderPhase(:2061)渲染被更高优先级更新打断,进度作废(prepareFreshStack 里记录)
段上标注 “Suspended”logSuspendedRenderPhase(:1448)渲染挂起等数据
一条超长不间断黄块只有 started/stopped 没有 yielded走了同步路径——查 shouldTimeSlice 四条件

操作流程:录制一段交互 → 在火焰图中找 React 的 Render 任务 → 看它是否被切成多段。如果每次交互都是单个长任务,先用 8.2 的四个条件排查(flushSync?过期 lane?含 SyncLane?)。

8.19.2 enableSchedulingProfiler 与 enableComponentPerformanceTrack

// shared/ReactFeatureFlags.js:248-258
export const enableComponentPerformanceTrack: boolean = true;
export const enableSchedulingProfiler: boolean =
  !enableComponentPerformanceTrack && __PROFILE__;

19.3.1 里 enableComponentPerformanceTrack 默认开启(真正的生产构建中两者都关)。它启用了大量细粒度埋点,散落在本章各处:

  • logSuspendedYieldTime / logActionYieldTime / logYieldTime(performWorkOnRoot 开头,:1151-1161):按 yieldReason 分类记录让出时长——这是量化“等待数据花了多久”的直接数据。
  • startYieldTimer(workInProgressSuspendedReason)(:1206):让出开始时记账,重入时结算。
  • logInconsistentRenderPhase(:1229):store 不一致导致的同步重渲。
  • logRecoveredRenderPhase(:3571):错误恢复渲染(提交时记录)。
  • logThrottledRetry 相关:FALLBACK_THROTTLE_MS 节流的 retry。

这些数据最终进入 workInProgressUpdateTask(:543)——每个 render 段在 DevTools 里都对应一个 ConsoleTask,鼠标悬停可以看到触发它的 update/event 来源(gestureUpdateTask、transitionUpdateTask 等,prepareFreshStack 里按 lane 类型选择,:2073-2228)。

8.19.3 读 Scheduler 的 trace

workLoopConcurrentByScheduler 依赖 shouldYield(),所以 Scheduler 的行为直接决定并发渲染的切分。用 unstable_shouldYield(scheduler/src/forks/Scheduler.js:598 导出)可以在控制台验证:

// 在控制台执行(需先拿到 Scheduler 实例,如通过 react-dom 的内部导出或测试环境)
const t0 = performance.now();
while (!Scheduler.unstable_shouldYield()) { /* 忙等 */ }
console.log('yielded after', performance.now() - t0, 'ms');

正常情况下应该 ≈5ms 量级。如果远大于 5ms,说明主线程被长任务阻塞,Scheduler 的 MessageChannel 回调得不到执行——这不是 React 的问题,但表现为“React 卡住不响应输入”。

配合 forceFrameRate(fps)(:481)可以主动改帧率做实验:forceFrameRate(2) 会让 Scheduler 每 500ms 才让出一次,并发渲染会退化成“准同步”,用来复现“低优先级更新饿死高优先级”的场景,验证 8.7 的三层防饥饿机制。

8.19.4 act 环境:测试里看到的不一定是真的

ReactSharedInternals.actQueue !== null 时,整个调度链被替换(ReactFiberRootScheduler.js:630-639):

function scheduleCallback(priorityLevel: PriorityLevel, callback: RenderTaskFn) {
  if (__DEV__ && ReactSharedInternals.actQueue !== null) {
    // Special case: We're inside an `act` scope (a testing utility).
    // Instead of scheduling work in the host environment, add it to a
    // fake internal queue that's managed by the `act` implementation.
    ReactSharedInternals.actQueue.push(callback);
    return fakeActCallbackNode;
  } else {
    return Scheduler_scheduleCallback(priorityLevel, callback);
  }
}

加上 renderRootConcurrent 里的 workLoopSync 分支(:3007-3013),测试环境里:没有 Scheduler、没有时间切片、没有让出。所以:

  • 测试里永远不会观察到“transition 被输入打断”——那是好事也是陷阱:涉及优先级竞争的 bug 在测试里天然隐身。
  • warnIfUpdatesNotWrappedWithActDEV(:5606)会在忘记包 act 时警告——因为不包 act,更新可能永远不 flush(微任务队列被测试框架控制)。
  • 异步 action 的测试必须 await act(async () => {...}):asyncTransitions 计数器和聚合 thenable 都依赖微任务真正执行。

8.19.5 无限循环检测

// ReactFiberWorkLoop.js:5222-5230
export function throwIfInfiniteUpdateLoopDetected(
  isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
) {
  if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
    nestedUpdateCount = 0;
    ...
    throw new Error(
      'Maximum update depth exceeded. This could be an infinite loop. ...'
    );
  }
}

markRootUpdated(:1768)和 markRootPinged(:1783)都会调它。并发场景下最隐蔽的无限循环是“挂起→ping→再挂起→再 ping”(未缓存 promise 每次渲染都是新对象),root.shellSuspendCounter(:2740)就是为这个场景准备的计数器。看到 “Maximum update depth exceeded” 时,优先检查:渲染中无条件创建 Promise、use() 读未缓存数据、startTransition 回调里又触发同 lane 更新。

8.19.6 一个可复用的排查清单

症状第一嫌疑源码锚点
transition 永远 pendingasync action 未 resolve,或未缓存 promise 导致反复重挂entangleAsyncAction(:71)、shellSuspendCounter(:2740)
输入卡顿更新走了同步路径shouldTimeSlice(:1168)
loading 闪烁retry 未合并FALLBACK_THROTTLE_MS(:528、:1509)
中间态被提交lane 未纠缠markRootEntangled(ReactFiberLane.js:1028)
追赶渲染不生效includesOnlyNonUrgentLanes 判断或 isRenderingDeferredWorkReactFiberHooks.js:3082、:3014
store 值跳变并发渲染与外部 store 竞争isRenderConsistentWithExternalStores(:1703)
测试里 pending 不结束没 await act,微任务没跑scheduleCallback(ReactFiberRootScheduler.js:630)

8.19.7 enableComponentPerformanceTrack 的埋点清单

ReactFiberPerformanceTrack.js(1686 行以上)是 19.3 性能可观测性的主体。它的埋点分四类,对应 DevTools Performance 面板的四条轨道。先看轨道本身(:52-55、:61-110):

// ReactFiberPerformanceTrack.js:52-55
const COMPONENTS_TRACK = 'Components ⚛';
const LANES_TRACK_GROUP = 'Scheduler ⚛';

let currentTrack: string = 'Blocking'; // Lane

markAllLanesInOrder(:61-110)用 console.timeStamp(name, 0.003, 0.003, track, group, 'primary-light') 在 0.003ms 处给每条轨道打一个“占位标记”,目的是固定轨道顺序(源码注释 :63-66 说明 performance.measure 的顺序是反的,只能用这种 hack)。轨道名就是 lane 分组名(Blocking / Gesture / Transition / Suspense / Idle),来自 getGroupNameOfHighestPriorityLane(lanes)(:58)。

四类埋点:

类别代表函数输出位置关键行号
组件级 renderlogComponentRenderComponents ⚛:221
组件级 effectlogComponentEffectComponents ⚛:531
组件出错logComponentErrored / logComponentEffectErroredComponents ⚛(红色):375 / :461
lane 级阶段logRenderPhase / logInterruptedRenderPhase / logSuspendedRenderPhase / logSuspendedWithDelayPhase / logRecoveredRenderPhase / logErroredRenderPhase对应 lane 轨道:1054 / :1098 / :1142 / :1181 / :1221 / :1285
让出logYieldTime / logSuspendedYieldTime / logActionYieldTimeComponents ⚛ 标 “Blocked”:583 / :614 / :647

logComponentEffect 里有一个体现“性能数据要能一眼看出问题”的细节(:547-555):颜色按 selfTime 分档。

// ReactFiberPerformanceTrack.js:548-555
    const color =
      selfTime < 1
        ? 'secondary-light'
        : selfTime < 100
          ? 'secondary'
          : selfTime < 500
            ? 'secondary-dark'
            : 'error';

<1ms 浅灰、<100ms 中灰、<500ms 深灰、≥500ms 红色。这套阈值同样用在 logYieldTime(:590-598:<5 / <10 / <100 / 其它)和 logComponentRender 上。所以“火焰图里一片红”直接指向 ≥500ms 的组件 effect 或 ≥100ms 的让出(被 CPU 阻塞)。

logComponentEffect 还优先用 fiber._debugTask.run(...) 包裹 console.timeStamp(:556-569)——在 DEV 下这让 Chrome 能正确处理嵌套的 task 归属,把 effect 时间戳挂到触发它的 setState 调用栈上。

// ReactFiberPerformanceTrack.js:556-569(节选)
    const debugTask = fiber._debugTask;
    if (__DEV__ && debugTask) {
      debugTask.run(
        console.timeStamp.bind(
          console,
          name,
          startTime,
          endTime,
          COMPONENTS_TRACK,
          undefined,
          color,
        ),
      );
    } else {
      console.timeStamp(name, startTime, endTime, COMPONENTS_TRACK, undefined, color);
    }

logYieldTime 的注释(:599-602)还解释了一个设计取舍:让出日志会记在 Components ⚛ 轨道上,如果这次渲染最终没有提交,这条 “Blocked” 标记就会孤零零地挂着没有上下文——这是有意为之,因为它恰好是“这段渲染为什么被饿死”的线索:

// ReactFiberPerformanceTrack.js:599-602
    // This get logged in the components track if we don't commit which leaves them
    // hanging by themselves without context. It's a useful indicator for why something
    // might be starving this render though.

8.19.8 Scheduler 的 frameYieldMs 与优先级映射

8.19.3 说“正常情况下应该 ≈5ms”。这个 5ms 就是 frameYieldMs(packages/scheduler/src/SchedulerFeatureFlags.js:11):

// SchedulerFeatureFlags.js:11
export const frameYieldMs = 5;

shouldYieldToHost(Scheduler.js:459-472)就是拿当前时间减去本轮 task 的起点 startTime 去比 frameInterval:

// packages/scheduler/src/forks/Scheduler.js:459-472
function shouldYieldToHost(): boolean {
  if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
    // Yield now.
    return true;
  }
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

注意第一个前置条件:needsPaint 为真时立刻让出。requestPaint()(:474-479)把 needsPaint 置 true;performWorkUntilDeadline(:498-502)每轮开头把它清掉。这意味着“浏览器说我要绘制”可以抢在 5ms 之前让 React 让出——这是 React 19 新增的“响应绘制的让出”(enableRequestPaint)。没有它,shouldYield 只能按固定 5ms 节流,遇到一次 4.9ms 的渲染仍会延迟一帧。

frameInterval 的初值是 frameYieldMs(:456),forceFrameRate(fps)(:481-496)可以改:

// packages/scheduler/src/forks/Scheduler.js:481-496(节选)
function forceFrameRate(fps: number) {
  if (fps < 0 || fps > 125) {
    console['error'](
      'forceFrameRate takes a positive int between 0 and 125, ' +
        'forcing frame rates higher than 125 fps is not supported',
    );
    return;
  }
  if (fps > 0) {
    frameInterval = Math.floor(1000 / fps);
  } else {
    // reset the framerate
    frameInterval = frameYieldMs;
  }
}

上限 125fps 是硬约束(高于 ~8ms 的让出频率没有意义)。forceFrameRate(2) → frameInterval = 500,React 会连续 500ms 不让出——这正是 8.19.3 建议用来复现“低优先级饿死”的实验手段。把它和 8.7 的三层防饥饿机制对照:防止饿死靠的是 lane 过期(markStarvedLanesAsExpired),不是靠 Scheduler 让出;Scheduler 让出只影响“多久切一次”。

优先级到 Scheduler 的映射在 ReactFiberRootScheduler.js:480-498:

// ReactFiberRootScheduler.js:480-498
    let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }

Discrete 和 Continuous 都映射到 UserBlocking,Default 到 Normal,Idle 到 Idle。ImmediatePriority 完全不出现——注释(:482-484)解释了原因:“Scheduler does have an ‘ImmediatePriority’, but now that we use microtasks for sync work we no longer use that. Any sync work that reaches this path is meant to be time sliced.” 这条映射链是“React lane → event priority → Scheduler priority”三级转换的最后一级,调试优先级问题时值得逐级对照。

8.19.9 用 Lane 分组名定位是哪类更新

DevTools 里的轨道名来自 getGroupNameOfHighestPriorityLane(ReactFiberPerformanceTrack.js:58)。而 getLabelForLane(ReactFiberLane.js:127-172)提供了更细的单 lane 名称:

// ReactFiberLane.js:127-172(节选)
export function getLabelForLane(lane: Lane): string | void {
  if (enableSchedulingProfiler) {
    if (lane & SyncHydrationLane) { return 'SyncHydrationLane'; }
    if (lane & SyncLane) { return 'Sync'; }
    // ...
    if (lane & TransitionLanes) { return 'Transition'; }
    if (lane & RetryLanes) { return 'Retry'; }
    // ...
    if (lane & OffscreenLane) { return 'Offscreen'; }
    if (lane & DeferredLane) { return 'Deferred'; }
  }
}

注意它由 enableSchedulingProfiler 守卫,而 8.19.2 已说明该 flag 与 enableComponentPerformanceTrack 互斥(shared/ReactFeatureFlags.js:257-258:enableSchedulingProfiler = !enableComponentPerformanceTrack && __PROFILE__)。也就是说:开 SchedulingProfiler 时用 getLabelForLane 的文字标签,开 ComponentPerformanceTrack 时用新的 console.timeStamp 轨道。两个 profiler 不会同时工作,调试前先确认开了哪个。

排查流程因此可以很具体:

  1. 在 Performance 面板找到时间戳异常的那一段;
  2. 看它落在哪条轨道(Blocking = sync/default,Transition = transition,Suspense = retry,Idle = offscreen/deferred);
  3. 若是 Blocking 且很长 → 检查 shouldTimeSlice 四个条件(8.2);
  4. 若是 Transition 但被切成很多小段 → 正常,说明时间切片生效,问题在别处(可能是组件本身太慢,看 Components ⚛ 轨道里红色的那个);
  5. 段上标 “Blocked”(来自 logYieldTime)→ 这段等待是CPU 被其他任务占用,不是 React 在等数据;标 “Suspended”(logSuspendedYieldTime)→ 是在等数据/资源。

这套映射让“到底是 React 慢、还是数据慢、还是别的任务把主线程占了”三个问题第一次可以分开回答。


8.21 从更新到像素:一次并发更新的完整时序

本章拆了所有零件,最后一节把它们串成一条完整链路。以一个典型场景为例:用户在 tab 栏点击,触发 startTransition(() => setTab('comments')),且目标组件里有一个 useDeferredValue 和一个会挂起的 use(fetchPromise)。

sequenceDiagram
    participant Ev as 事件处理器
    participant H as ReactFiberHooks
    participant RS as ReactFiberRootScheduler
    participant S as Scheduler
    participant W as ReactFiberWorkLoop
    participant T as ReactFiberThrow/Thenable
    participant C as commitRoot
    Ev->>H: startTransition(callback)
    H->>H: ReactSharedInternals.T = transition<br/>dispatchOptimisticSetState(isPending=true)
    H->>W: requestUpdateLane → requestTransitionLane<br/>claimNextTransitionUpdateLane
    H->>W: markRootUpdated(root, TransitionLane)
    W->>RS: ensureRootIsScheduled(root)
    Note over RS: root 入链 + mightHavePendingSyncWork=true<br/>+ scheduleImmediateRootScheduleTask(微任务)
    RS->>RS: 微任务:processRootScheduleInMicrotask
    RS->>RS: scheduleTaskForRootDuringMicrotask<br/>markStarvedLanesAsExpired + getNextLanes
    RS->>S: scheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask)
    S->>W: performWorkOnRootViaSchedulerTask(root, didTimeout)
    W->>W: performWorkOnRoot(root, lanes, forceSync)
    W->>W: shouldTimeSlice 判定(无 blocking/expired → true)
    W->>W: renderRootConcurrent(root, lanes)
    W->>W: prepareFreshStack → createWorkInProgress<br/>初始化 20+ 模块级变量
    W->>W: workLoopConcurrentByScheduler<br/>beginWork/completeUnitOfWork 逐 Fiber
    W->>T: use(promise) 未 resolve → 抛 SuspenseException
    T->>W: handleThrow → SuspendedOnImmediate<br/>workInProgressThrownValue = thenable
    W->>W: resumeOrUnwind:isThenableResolved? 否<br/>thenable.then(onResolution);break outer
    W->>S: 让出主线程(RootInProgress)
    Note over Ev: 期间用户继续输入 → DefaultLane 更新<br/>getNextLanes 不打断 transition(:350-353)
    T-->>W: Promise resolve → onResolution<br/>workInProgressSuspendedReason = AndReadyToContinue<br/>ensureRootIsScheduled
    RS->>S: 重新 scheduleCallback
    S->>W: performWorkOnRootViaSchedulerTask 再次进入
    W->>W: renderRootConcurrent:continuation 分支<br/>(root/lane 相同,不重建栈)
    W->>W: AndReadyToContinue → isThenableResolved = true<br/>replaySuspendedUnitOfWork 原地重放
    W->>W: useDeferredValue:紧急渲染中 requestDeferredLane<br/>markSkippedUpdateLanes,返回 prevValue
    W->>W: work loop 完成 → RootCompleted
    W->>W: finishConcurrentRender → completeRootWhenReady
    W->>W: isViewTransitionEligible? Transition lane → 是<br/>waitForCommitToBeReady
    alt 资源未就绪
        W->>W: root.cancelPendingCommit = subscribe(...)<br/>markRootSuspended,等资源事件
    else 就绪
        W->>C: completeRoot → commitRoot
        C->>C: startViewTransition(:3899)<br/>mutation/layout/passive 各阶段
        C->>C: markRootFinished(root, lanes)<br/>workInProgressRoot = null
    end
    Note over W: deferred lane 后续被 Scheduler 拾起<br/>isRenderingDeferredWork = true → 采用新值<br/>第二次提交

这条链路把本章的关键行都标了出来。如果只能记住三件事:

  1. 入口只有两个:performWorkOnRootViaSchedulerTask(并发)与 performSyncWorkOnRoot(同步,ReactFiberRootScheduler.js:608)。所有渲染都从 performWorkOnRoot(:1137) 分流。
  2. 状态全在模块级:中断 = break outer 返回,恢复 = 重入 + continuation 判断。没有序列化。
  3. 提交也可能等待:completeRootWhenReady 的 suspensey commit 与 markRootSuspended 让“渲染完成”不等于“像素更新”。


8.22 实战:什么时候用什么(跨篇速查)

本节原本排在上篇末尾导致编号倒置,现按内容顺序移到「从更新到像素」之后。

场景用什么为什么(源码依据)
输入框 + 大数据列表筛选useDeferredValue让列表渲染走 TransitionDeferredLanes,不阻塞输入
tab / 路由切换startTransition整棵更新走 TransitionLane,可被随时打断;数据未到不闪 loading
渐进式展示(骨架屏)Suspense + startTransitionRootSuspendedWithDelay 对 transition 不提交占位
需要“必须立即看到结果”默认 setState 或 flushSync走 DefaultLane / SyncLane,不被 transition 打断
异步 Server ActionstartTransition + async actionaction 返回 Promise 时 React 自动保持 pending 直到 settle
读 ContextuseContext 或 use(ctx)两者等价,use 还能读 Promise

一个常见误区:startTransition 不会让渲染变快。它只改变“这次渲染可不可被牺牲”。如果你的组件本身渲染就要 200ms,包在 startTransition 里依然要 200ms,只是期间输入能响应。真正要减少计算量,得靠第 11 章的编译器或第 14 章的 memo 策略。


8.23 中断与恢复的另一视角:callbackNode 与模块级状态重排

8.10 从“挂起状态机”的角度讲了中断/恢复,8.20 从“时序”角度讲了一遍。这里换第三个视角:从 root.callbackNode(调度句柄)和模块级状态的生命周期看,回答“中断时到底丢了什么、恢复时到底捡回了什么”。

8.26.1 callbackNode 是根级的“当前任务凭据”

每个 root 上有一个 callbackNode,指向 Scheduler 返回的任务句柄(或 act 的 fakeActCallbackNode)。它和 callbackPriority 一起构成“这个 root 当前排了什么任务”的唯一真相。scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js:384-509)是唯一的裁决者:

// ReactFiberRootScheduler.js:419-439
  const existingCallbackNode = root.callbackNode;
  if (
    // Check if there's nothing to work on
    nextLanes === NoLanes ||
    // If this root is currently suspended and waiting for data to resolve, don't
    // schedule a task to render it. We'll either wait for a ping, or wait to
    // receive an update.
    //
    // Suspended render phase
    (root === workInProgressRoot && isWorkLoopSuspendedOnData()) ||
    // Suspended commit phase
    root.cancelPendingCommit !== null
  ) {
    // Fast path: There's nothing to work on.
    if (existingCallbackNode !== null) {
      cancelCallback(existingCallbackNode);
    }
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    return NoLane;
  }

三个“不调度”的条件分别对应三种“暂时不能动这个 root”的状态:

  1. nextLanes === NoLanes —— 没事可做;
  2. isWorkLoopSuspendedOnData()(ReactFiberWorkLoop.js:801-806,即 SuspendedOnData/SuspendedOnAction)—— 渲染中挂起等数据。此时不能重排任务,因为一旦 promise resolve,onResolution 会自己 ensureRootIsScheduled(ReactFiberWorkLoop.js:2859-2873)。提前排任务只会空转;
  3. root.cancelPendingCommit !== null —— 提交阶段挂起(8.14.2 的 View Transition / suspensey commit)。这时渲染已完成,等的是宿主资源,排任务同样没意义。

第 2 条尤其重要:它解释了“为什么挂起期间不会看到 Scheduler 反复空转”。workInProgressSuspendedReason 是模块级变量(:461),isWorkLoopSuspendedOnData 直接读它——调度器通过一个模块级变量间接知道 work loop 的状态,这是 React 把“调度”和“渲染”两个子系统粘合起来的最短路径。

// ReactFiberWorkLoop.js:801-806
export function isWorkLoopSuspendedOnData(): boolean {
  return (
    workInProgressSuspendedReason === SuspendedOnData ||
    workInProgressSuspendedReason === SuspendedOnAction
  );
}

注意判定里不含 SuspendedOnImmediate:那个状态只表示“刚抛了异常、还没决定 unwinding 还是 replay”,是瞬态的,不需要阻止调度。

8.26.2 中断:getNextLanes 怎么把 wipLanes 变成“保护伞”

中断的决策点只有一个:getNextLanes 的 wipLanes 保护(ReactFiberLane.js:337-358):

// ReactFiberLane.js:337-358
  if (
    wipLanes !== NoLanes &&
    wipLanes !== nextLanes &&
    // If we already suspended with a delay, then interrupting is fine. Don't
    // bother waiting until the root is complete.
    (wipLanes & suspendedLanes) === NoLanes
  ) {
    const nextLane = getHighestPriorityLane(nextLanes);
    const wipLane = getHighestPriorityLane(wipLanes);
    if (
      // Tests whether the next lane is equal or lower priority than the wip
      // one. This works because the bits decrease in priority as you go left.
      nextLane >= wipLane ||
      // Default priority updates should not interrupt transition updates. The
      // only difference between default updates and transition updates is that
      // default updates do not support refresh transitions.
      (nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
    ) {
      // Keep working on the existing in-progress tree. Do not interrupt.
      return wipLanes;
    }
  }

把这段翻译成“中断矩阵”:

正在渲染 (wipLane)新来的更新 (nextLane)是否中断依据
TransitionSync是SyncLane < TransitionLane(位更小),nextLane >= wipLane 为假
TransitionInputContinuous是同上
TransitionDefault否第二个条件特意豁免
DefaultSync是Sync 位更小
DefaultInputContinuous是同上
DefaultDefault否同优先级时 nextLane >= wipLane 为真
RetryTransition是Transition 位比 Retry 小,nextLane >= wipLane 为假
任意任意(wip 已 suspendedWithDelay)是第一个条件 (wipLanes & suspendedLanes) === NoLanes 为假,整段跳过、直接返回 nextLanes

最后一行是 8.26.1 第 2 条的对偶:已经挂起的渲染没有任何“舍不得丢”的进度(进度都在等数据,不在 CPU),所以谁来都能打断。这也和 pingSuspendedRoot 里“suspendedWithDelay 时总是愿意重启”的判断(ReactFiberWorkLoop.js:5051-5056)一致。

8.26.3 恢复:ping 之后的三档代价

数据到达(ping)后,pingSuspendedRoot(ReactFiberWorkLoop.js:5016-5096)决定“原地重启”还是“只记账”:

// ReactFiberWorkLoop.js:5036-5081(节选)
  if (
    workInProgressRoot === root &&
    isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes)
  ) {
    // Received a ping at the same priority level at which we're currently
    // rendering. We might want to restart this render. ...
    if (
      workInProgressRootExitStatus === RootSuspendedWithDelay ||
      (workInProgressRootExitStatus === RootSuspended &&
        includesOnlyRetries(workInProgressRootRenderLanes) &&
        now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
    ) {
      // Force a restart from the root by unwinding the stack.
      if ((executionContext & RenderContext) === NoContext) {
        prepareFreshStack(root, NoLanes);
      } else {
        workInProgressRootPingedLanes = mergeLanes(
          workInProgressRootPingedLanes,
          pingedLanes,
        );
      }
    } else {
      // Even though we can't restart right now, we might get an
      // opportunity later. So we mark this render as having a ping.
      workInProgressRootPingedLanes = mergeLanes(
        workInProgressRootPingedLanes,
        pingedLanes,
      );
    }
  }

  ensureRootIsScheduled(root);

两条分支:

  • 重启:prepareFreshStack(root, NoLanes) 丢掉当前 WIP 树,从根重来。丢树的代价被接受,因为“挂起等数据的渲染没有可复用的 CPU 成果”。注意传的是 NoLanes——prepareFreshStack 的第二参只用于 workInProgressRootRenderLanes 和 entangledRenderLanes 的初始化,重入 renderRootConcurrent 时又会被真实 lanes 覆盖(:2787 的判断会让它重建)。
  • 记账:workInProgressRootPingedLanes |= pingedLanes。这些 pinged lanes 在 markRootSuspended 里被从 suspendedLanes 中剔除(:1815)——意思是“这些 lane 已经不挂起了,下次 getNextLanes 能选中它们”。8.26.2 里说“ping 之后 getNextLanes 看到的是 freshness”,就靠这一步。

而在 renderRootConcurrent 的 resumeOrUnwind(:2843-2876)里还有第三条更轻的路径:如果 promise 在让出期间就 resolve 了(isThenableResolved(thenable) 为真),直接 replaySuspendedUnitOfWork,不丢树:

// ReactFiberWorkLoop.js:2843-2851
          case SuspendedOnData:
          case SuspendedOnAction: {
            const thenable: Thenable<mixed> = thrownValue as any;
            if (isThenableResolved(thenable)) {
              // The data resolved. Try rendering the component again.
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              replaySuspendedUnitOfWork(unitOfWork);
              break;
            }

这条路径的前提是“渲染还没真正放弃这棵 WIP 树”——也就是 SuspendedOnData/SuspendedOnAction 状态下、work loop 停在 break outer(:2875)之后的那个窗口。

于是恢复有三档,代价从低到高:

flowchart LR
    P[&#34;Promise resolve(ping)&#34;] --> Q1{&#34;work loop 仍停在<br/>SuspendedOnData 暂停点?&#34;}
    Q1 -->|是| R1[&#34;replaySuspendedUnitOfWork(:2850)<br/>原地重放,零丢弃&#34;]
    Q1 -->|否| Q2{&#34;exitStatus 是<br/>RootSuspendedWithDelay?&#34;}
    Q2 -->|是| R2[&#34;prepareFreshStack(root, NoLanes)<br/>丢树重来(:5060)&#34;]
    Q2 -->|否| R3[&#34;workInProgressRootPingedLanes |= lanes<br/>仅记账(:5077),下次 getNextLanes 选中&#34;]
    R1 --> S[&#34;ensureRootIsScheduled(:5095)&#34;]
    R2 --> S
    R3 --> S

还有第四种“恢复”值得一提:retryTimedOutBoundary(:5137-5153)走的是边界级重试而不是工作循环级恢复。它用 enqueueConcurrentRenderForLane + markRootUpdated(root, retryLane) + ensureRootIsScheduled 三件套,把 retry 当成一次普通更新提交——这也是为什么 retry lane 有自己的过期/节流逻辑(8.7)。

8.26.4 与 8.10 的呼应:模块级变量的“重排清单”

prepareFreshStack(:2254-2273)是唯一一处系统性重置模块级状态的地方。把它的重置清单按“谁负责”分类:

变量行号由谁消费
workInProgressRoot / workInProgress:439 / :441整个 work loop
workInProgressRootRenderLanes:443所有 lane 判定
workInProgressSuspendedReason / workInProgressThrownValue:461 / :462resumeOrUnwind
workInProgressRootDidSkipSuspendedSiblings:467finishConcurrentRender(:1457)
workInProgressRootIsPrerendering:471throwAndUnwindWorkLoop(:3291)、requestDeferredLane(:899)
workInProgressRootDidAttachPingListener:476recoverFromConcurrentError(:1366)
workInProgressRootExitStatus:489finishConcurrentRender 的 switch
workInProgressRootSkippedLanes:492renderDidSuspendDelayIfPossible(:2575)
workInProgressRootInterleavedUpdatedLanes:494markRootSuspended(:1818)
workInProgressRootPingedLanes:498markRootSuspended(:1815)
workInProgressDeferredLane:500requestDeferredLane(:885)
workInProgressSuspendedRetryLanes:503pingSuspendedRoot(:5090)
workInProgressRootConcurrentErrors / RecoverableErrors:505 / :509finishConcurrentRender / completeRoot
entangledRenderLanes:486beginWork 的第三参

这张表配合 8.10 的“十种挂起原因”,构成了完整的“中断图鉴”。读源码时如果觉得某个行为莫名其妙,先来这里查“这个变量是不是在中断时被重置了”——十有八九能找到答案。

顺带一个容易踩的细节:prepareFreshStack 会取消已排的 timeout 和 pending commit(:2238-2250):

// ReactFiberWorkLoop.js:2238-2250
  const timeoutHandle = root.timeoutHandle;
  if (timeoutHandle !== noTimeout) {
    // The root previous suspended and scheduled a timeout to commit a fallback
    // state. Now that we have additional work, cancel the timeout.
    root.timeoutHandle = noTimeout;
    cancelTimeout(timeoutHandle);
  }
  const cancelPendingCommit = root.cancelPendingCommit;
  if (cancelPendingCommit !== null) {
    root.cancelPendingCommit = null;
    cancelPendingCommit();
  }

也就是说:“丢树重来”不只丢渲染进度,还会顺带取消挂起的提交。这是正确的——如果新的一轮渲染已经带了新数据,上一轮基于旧数据算出的 suspensey commit 就应该作废。反过来,这也是 8.26.1 第 3 条(cancelPendingCommit !== null 时不调度)的另一半逻辑:一旦有新渲染进来,pending commit 自动失效,root 重新可调度。


8.24 并发下的经典陷阱

本节把并发理论落到最容易踩的坑上。每个坑都给源码依据和正反例。

8.27.1 撕裂(tearing)与 useSyncExternalStore

现象:同一个外部 store 的值,在一次并发渲染的不同时间点读到不同的快照,导致 UI 上出现“一半新、一半旧”的不一致画面。

成因:并发渲染会 break outer 让出主线程(8.10),让出期间用户事件可能修改外部 store。恢复后 React 继续渲染剩余组件,于是“已渲染部分”用的是旧快照,“未渲染部分”用的是新快照。

React 的解法不是禁止外部 store,而是检测并重渲。isRenderConsistentWithExternalStores(ReactFiberWorkLoop.js:1703-1760)遍历完成树里所有带 StoreConsistency flag 的 fiber,逐个重新调 getSnapshot() 与渲染时记录的值比对:

// ReactFiberWorkLoop.js:1725-1734
            try {
              if (!is(getSnapshot(), renderedValue)) {
                // Found an inconsistent store.
                return false;
              }
            } catch (error) {
              // If `getSnapshot` throws, return `false`. This will schedule
              // a re-render, and the error will be rethrown during render.
              return false;
            }

返回 false 时的处理在 performWorkOnRoot(:1222-1244):

// ReactFiberWorkLoop.js:1222-1244(节选)
      const finishedWork: Fiber = root.current.alternate as any;
      if (
        renderWasConcurrent &&
        !isRenderConsistentWithExternalStores(finishedWork)
      ) {
        // ...
        // A store was mutated in an interleaved event. Render again,
        // synchronously, to block further mutations.
        exitStatus = renderRootSync(root, lanes, false);
        // We assume the tree is now consistent because we didn't yield to any
        // concurrent events.
        renderWasConcurrent = false;
        // Need to check the exit status again.
        continue;
      }

注意两个要点:

  1. 只有 renderWasConcurrent 为真才检查。同步渲染不存在撕裂(一跑到底不让出),所以跳过——这是性能优化的常见模式。此处还有一个 TODO(:1218-1221):“It's possible that even a concurrent render may never have yielded to the main thread, if it was fast enough, or if it expired. We could skip the consistency check in that case, too.” 即连并发渲染也可能根本没让出过,这份检查本可以再优化;
  2. 发现不一致就同步重渲。因为“不再让出”是唯一能保证一致的手段。这是一次对并发能力的主动放弃,只在与外部 store 有关时发生。

正确用法是 useSyncExternalStore(或 useSyncExternalStoreWithSelector),它会在 fiber 的 updateQueue.stores 里登记 {getSnapshot, value} 供上面这段检查使用;错误用法是自己 useState + useEffect 订阅,React 无从检查,撕裂就会真实发生。

8.27.2 并发与“外部可变状态”:为什么副作用必须在 effect 里

并发渲染的一大前提是渲染必须是纯的。React 有三层保护,但都不是绝对安全网:

  1. 中断 + 重跑:一次渲染可能被 break outer 中断(8.10),恢复时走 replaySuspendedUnitOfWork,同一个组件函数会被调用多次。渲染里如果有“全局计数器自增”之类副作用,就会被多算;
  2. StrictMode 双调用:DEV 下函数组件会被调用两次(shouldDoubleInvokeUserFnsInHooksDEV),且 hook 的 reducer 也会被双调用(ReactFiberHooks.js:1531-1533 的 reducer(newState, action) 预演);
  3. 错误恢复重试:recoverFromConcurrentError 会用同步模式再跑一遍(8.15),组件函数再次被调用。

这三层加在一起意味着:在渲染中写外部可变状态,你会看到它被执行 1~N 次。写进 useEffect 才能保证“提交后执行一次”。这也是 useMemo/useCallback 的返回值不能作为“副作用载体”的原因——React 明确保留丢弃或重算它们的权利。

一个具体反例:

// ❌ 每次并发渲染都会多算一次
let renderCount = 0;
function Bad() {
  renderCount++;        // 渲染期副作用:中断、重放、双调用各加一次
  window.__analytics.push({t: Date.now()});  // 更糟:外部可变状态
  return <div>{renderCount}</div>;
}

正确写法是把记录放进 useEffect,并接受“它只代表提交次数”这一语义。

8.27.3 flushSync 的逃逸与代价

flushSync 直接把 forceSync 置 true,跳过 shouldTimeSlice 的所有正向条件(ReactFiberWorkLoop.js:1168-1177),走 renderRootSync(root, lanes, true),一跑到底不让出。

// ReactFiberWorkLoop.js:1168-1177
  const shouldTimeSlice =
    (!forceSync &&
      !includesBlockingLane(lanes) &&
      !includesExpiredLane(root, lanes)) ||
    // If we're prerendering, then we should use the concurrent work loop
    // even if the lanes are synchronous, so that prerendering never blocks
    // the main thread.
    checkIfRootIsPrerendering(root, lanes);

forceSync 为真时,除非 checkIfRootIsPrerendering 为真(预渲染能推翻 forceSync,8.16.2),否则必走同步。代价有三:

  1. 阻塞主线程:直到整棵树渲染完,输入、动画全部卡住;
  2. 打断并发渲染:如果此时有 WIP 渲染在进行,getNextLanes 会按 lane 优先级把它换掉(flushSync 影响的是姿态,不是优先级——需要配合 getNextLanesToFlushSync,ReactFiberLane.js:363);
  3. 不允许让出:renderRootSync 的 shouldYieldForPrerendering 为 false,连预渲染逃逸都没有。

flushSync 的正当用途极少:把 DOM 状态同步暴露给第三方库(如需要在 setState 后立即读取布局)。同一事件里多次 flushSync 是灾难——每次都会跑一遍完整 render+commit。如果只是为了“让状态立刻生效再读”,优先考虑 useLayoutEffect。

8.27.4 useDeferredValue 的过度使用

useDeferredValue 不是免费午餐。每次 defer 都会:

  • 调 requestDeferredLane() 申请一条 TransitionDeferredLanes(ReactFiberLane.js:738,只有 4 条,会循环复用);
  • markSkippedUpdateLanes 记账(ReactFiberWorkLoop.js:2539);
  • 触发一次完整的二次渲染(deferred 追赶)。

4 条 deferred lane 循环复用意味着:如果同时有 >4 个独立的 deferred 更新在飞,它们会开始共享 lane,互相牵连。更常见的过度使用是“给所有列表都套一层 useDeferredValue”——结果是每次输入都产生 N 次渲染,总 CPU 工作量翻倍(先渲染旧值一遍,再追赶渲染新值一遍),只是把开销挪到了让出点之后。

判断标准很简单:只有当“渲染成本高到会阻塞输入”时才用。用之前先测 Components ⚛ 轨道里该组件的 selfTime;如果是 2ms,加 useDeferredValue 只会让它变成 2ms×2。真正的优化是减少渲染量(第 11 章的编译器、第 14 章的 memo 策略),而不是把渲染推迟。

8.27.5 忘记 await 的 async action

startTransition(async () => {
  await saveData();          // ✅ 后续 setState 会留在 action scope 里
});

对比:

startTransition(() => {
  saveData().then(() => setState(x));  // ❌ setState 不在 transition 里
});

后者的问题在 8.18.2 讲过了:startTransition 的回调同步返回后 ReactSharedInternals.T 就恢复了,.then 里的 setState 拿到的是当前事件默认优先级(requestUpdateLane 走 eventPriorityToLane,ReactFiberWorkLoop.js:863-867),不是 transition lane。正确写法要么让回调返回 Promise(React 19 会 entangleAsyncAction),要么在 .then 里再包一层 startTransition。

这条坑对应的 DEV 检测是 ReactSharedInternals.asyncTransitions 计数器(ReactFiberHooks.js:3185-3189)——但注意计数器只告诉你“当前有多少 action 在跑”,不会检测“.then 里漏包 transition”。这类问题只能靠人眼或 lint 规则发现。

8.27.6 在渲染期无条件创建 Promise

// ❌ 每次渲染都是新 promise → 每次都挂起 → ping → 再渲染 → 又是新 promise
function Bad() {
  const data = use(fetch('/api').then(r => r.json()));
  return <div>{data}</div>;
}

fetch(...) 每次都返回新 Promise,React 把它当作“未缓存的 thenable”。于是:渲染 → 挂起 → 数据到达 ping → 恢复 → 组件重放 → fetch 又造一个新 Promise → 又挂起……形成 8.19.5 讲的“未缓存 promise 无限 ping 循环”,最终被 shellSuspendCounter(:2739)配合 enableInfiniteRenderLoopDetection 拦住。

正确做法是把 Promise 提到组件外(或用 cache()、框架的数据层缓存),保证同一个逻辑数据请求在多次渲染间是同一个 Promise。这条坑在 SSR 与 Client 上表现不同:SSR 下每次渲染都是独立的(没有“重新渲染”的概念),所以问题不出现;一到客户端就会爆发,是很典型的“SSR 正常、hydration 后卡死”类 bug 的元凶。


8.25 本章源码索引表

主题文件函数 / 行号
同步/并发判定ReactFiberWorkLoop.jsshouldTimeSlice(:1168)、performWorkOnRoot(:1137)
并发渲染主函数ReactFiberWorkLoop.jsrenderRootConcurrent(:2779)
同步渲染ReactFiberWorkLoop.jsrenderRootSync(:2623)
三种 work loopReactFiberWorkLoop.jsworkLoopSync(:2772)、workLoopConcurrent(:3056)、workLoopConcurrentByScheduler
挂起状态机ReactFiberWorkLoop.jsresumeOrUnwind switch(:2830)
退出状态常量ReactFiberWorkLoop.jsRootErrored(:430)、RootSuspended(:431)、RootSuspendedWithDelay(:432)、RootCompleted(:434)
渲染收尾ReactFiberWorkLoop.jsfinishConcurrentRender(:1420)
retry 节流ReactFiberWorkLoop.jsFALLBACK_THROTTLE_MS(:1510)
transition lane 申请ReactFiberLane.jsclaimNextTransitionUpdateLane(:726)、claimNextRetryLane(:747)
中断保护ReactFiberLane.jsgetNextLanes(:249)
饥饿过期ReactFiberLane.jsmarkStarvedLanesAsExpired(:541)、computeExpirationTime(:477)
useTransitionReactFiberHooks.jsmountTransition(:3425)、updateTransition(:3443)、startTransition(:3117)
useDeferredValueReactFiberHooks.jsmountDeferredValueImpl(:3028)、updateDeferredValueImpl(:3056)、requestDeferredLane
use / useThenableReactFiberHooks.jsuse(:1159)、useThenable(:1098)
模块级渲染状态ReactFiberWorkLoop.jsworkInProgress(:441)、workInProgressRoot(:439)、workInProgressSuspendedReason(:461)、workInProgressThrownValue(:462)、workInProgressRootExitStatus(:489)、workInProgressDeferredLane(:500)、workInProgressTransitions(:537)
新渲染初始化ReactFiberWorkLoop.jsprepareFreshStack(:2023,重置清单见 :2254-2273)
中断栈丢弃ReactFiberWorkLoop.jsresetWorkInProgressStack(:1979)、resetSuspendedWorkLoopOnUnwind(:2295)
异常分类ReactFiberWorkLoop.jshandleThrow(:2302)、renderDidSuspend(:2546)、renderDidSuspendDelayIfPossible(:2552)、renderDidError(:2598)
挂起常量ReactFiberWorkLoop.jsNotSuspended~SuspendedOnAction 十种(:446-455)
内部哨兵异常ReactFiberThenable.jsSuspenseException(:57)、SuspenseyCommitException(:67)、SuspenseActionException(:72)、isThenableResolved(:108)、getSuspendedThenable(:378)
重放与展开ReactFiberWorkLoop.jsreplaySuspendedUnitOfWork(:3125)、replayBeginWork(:3144)、throwAndUnwindWorkLoop(:3234)
挂起中重启ReactFiberWorkLoop.jspingSuspendedRoot(:5016)、attachPingListener(:4970)、retryTimedOutBoundary(:5137)
根调度入口ReactFiberRootScheduler.jsperformWorkOnRootViaSchedulerTask(:513)、performSyncWorkOnRoot(:608)、scheduleTaskForRootDuringMicrotask(:384)、flushSyncWorkAcrossRoots_impl(:185)、requestTransitionLane(:698)
lane 纠缠ReactFiberLane.jsmarkRootEntangled(:1028)、getEntangledLanes(:427)、upgradePendingLanesToSync(:1059)、getTransitionsForLanes(:1228)
lane 谓词ReactFiberLane.jsincludesOnlyTransitions(:632)、includesOnlyRetries(:622)、includesOnlyNonUrgentLanes(:625)、includesBlockingLane(:684)、includesExpiredLane(:696)、includesOnlyViewTransitionEligibleLanes(:668)
deferred laneReactFiberWorkLoop.jsrequestDeferredLane(:884)、peekDeferredLane(:983);ReactFiberLane.js claimNextTransitionDeferredLane(:738)
bailout 标记ReactFiberBeginWork.jsmarkWorkInProgressReceivedUpdate(:3800)、checkIfWorkInProgressReceivedUpdate(:3804)
错误恢复ReactFiberWorkLoop.jsrecoverFromConcurrentError(:1327)、queueRecoverableErrors(:1408)、panicOnRootError(:3352)、throwForcedInfiniteRenderLoopError(:5201);ReactFiberLane.js getLanesToRetrySynchronouslyOnError(:596)
异常派发ReactFiberThrow.jsthrowException(:364,renderDidSuspendDelayIfPossible 调用点 :417/:535)
store 一致性ReactFiberWorkLoop.jsisRenderConsistentWithExternalStores(:1703)
预渲染ReactFiberLane.jscheckIfRootIsPrerendering(:412);ReactFiberWorkLoop.js workInProgressRootIsPrerendering(:471)
提交等待ReactFiberWorkLoop.jscompleteRootWhenReady(:1578)、completeRoot(:3511)、commitRoot(:3725)
View TransitionReactFiberWorkLoop.jsstartViewTransition 调用(:3895);ReactFiberViewTransitionComponent.js getViewTransitionName(:28)、getViewTransitionClassName(:79)
transition 类型ReactTransitionType.jsaddTransitionType(:19);ReactFiberTransitionTypes.js queueTransitionTypes(:16)、claimQueuedTransitionTypes(:64)
手势 transitionReactFiberWorkLoop.jspingGestureRoot(:5098)、restartGestureRoot(:5108)、applyGestureOnRoot(:4439);ReactFiberLane.js isGestureRender(:718)
async actionReactFiberAsyncAction.jsentangleAsyncAction(:71)、pingEngtangledActionScope(:103)、chainThenableValue(:141)、peekEntangledActionLane(:189)
公开 API 层ReactStartTransition.jsstartTransition(:45)、startGestureTransition(:120)、Transition 类型(:30)
让出判据scheduler/src/forks/Scheduler.jsshouldYieldToHost(:459)、frameInterval(:456)、forceFrameRate(:481)
性能埋点ReactFiberWorkLoop.jslogSuspendedYieldTime 等(:1151-1161)、logInterruptedRenderPhase(:2061)、logSuspendedRenderPhase(:1448)
act / 测试ReactFiberRootScheduler.jsscheduleCallback(:626,actQueue 分支 :630)、fakeActCallbackNode(:624);ReactFiberWorkLoop.js act 分支(:3007)
循环检测ReactFiberWorkLoop.jsmarkRootUpdated(:1768)、markRootPinged(:1783)、throwIfInfiniteUpdateLoopDetected(:5222)、shellSuspendCounter(:2740)

8.26 本章 FAQ

Q1:并发渲染是多线程吗? A:不是。全部在主线程,靠“分段 + 让出”实现协作式调度。没有 Web Worker。

Q2:startTransition 里的更新一定会被延迟吗? A:不一定。如果当前没有更高优先级的工作,它会被正常渲染。它只是“可被牺牲”,不是“必然延迟”。

Q3:为什么 React 要 10 条 TransitionLane? A:为了让多个并存的 transition 互不干扰。若只有一条,两个独立的过渡更新会互相牵连。

Q4:useDeferredValue 和 useTransition 有什么区别? A:useTransition 是“我知道这次更新不急,请降低它的优先级”;useDeferredValue 是“这个值我希望慢半拍”。前者包裹更新,后者派生值。底层都落到 TransitionLanes,但 useDeferredValue 用的是独立的 TransitionDeferredLanes。

Q5:为什么 transition 挂起时 React 不显示 loading? A:见 finishConcurrentRender 的 RootSuspendedWithDelay 分支——如果 lane 只含 transitions/retries,就 fallthrough 到 RootSuspendedAtTheShell,markRootSuspended 后直接 return,不提交占位。

Q6:Suspense 的 fallback 会闪烁吗? A:会,所以 React 加了 retry 节流(FALLBACK_THROTTLE_MS)。多个边界的数据在短时间内陆续到达时,React 会合并成一次提交。

Q7:flushSync 会发生什么? A:forceSync = true,shouldTimeSlice 直接为 false,走 renderRootSync,一跑到底不让出。会阻塞主线程,慎用。

Q8:为什么我在 startTransition 里 await 之后 setState 就失效了? A:因为 lane 上下文是同步设置的,回调返回后上下文已恢复。React 19 的 async action 支持是另一套机制(Promise 由 React 接管)。

Q9:怎么判断一次渲染走了并发还是同步? A:看 shouldTimeSlice 四个条件。最常见导致“意外同步”的是:flushSync、过期 lane(被饿太久)、以及含 SyncLane 的混合更新。

Q10:并发渲染会让我的应用变快吗? A:会让它“感觉更快”(响应性提升),但不会减少计算总量。减少总量要靠减少不必要的渲染(第 11、14 章)。


8.27 本章小结

  1. 并发的本质是同步渲染 + 一套状态机。renderRootConcurrent 与 renderRootSync 共享 beginWork/completeWork,差别在循环与异常处理。
  2. shouldTimeSlice 的四个条件(forceSync / blocking lane / expired lane / prerendering)决定执行姿态,核心问题是“能不能安全暂停”。
  3. 挂起状态机有六个分支(SuspendedOnError / OnData / OnAction / OnImmediate / OnInstance / AndReadyToContinue),数据就绪后通过 ensureRootIsScheduled + 状态位实现恢复,不需要序列化——状态全在模块级变量里。
  4. 五种退出状态决定后续动作,其中 transition 挂起不提交占位、retry 有节流,是“并发改善体验”的具体落地。
  5. startTransition 与 useDeferredValue 是 lane 的封装:前者申请 TransitionLane1..10,后者使用独立的 TransitionDeferredLanes 以实现“可被紧急更新打断的追赶渲染”。
  6. 三层防饥饿机制:中断保护(只有严格更高优先级能打断)、过期强制同步、retry lane 特殊处理。

下一章进入挂起机制的消费端:Suspense 与懒加载——React 如何用“抛出一个 Promise”这一招,把异步数据流变成声明式的 UI 描述。