React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解

React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解

本章全部结论均落到 react-19.3.1 仓库的真实源码,引用的行号来自 19.3.1 版本。 若本章叙述与官方文档或你已有的印象冲突,以本仓库源码为准,文内会明确标注语义差异。

0. 阅读前必读:本章在系列中的位置

在动手读 React 19 任何一个新 API 之前,请先明确一个核心事实:React 19 的"新特性"几乎全部不是凭空发明的语法糖,而是第 4、5、6、7、8、9、10 章所剖析的底层设施的"上层封装"与"默认打通"。

  • 第 4 章(协调算法)解释了 Fiber 节点如何挂载/更新、props 如何在 beginWork/completeWork 之间流转;React 19 的 ref 作为普通 prop 正是把原先被单独抽离的 ref 字段重新还给了 props。
  • 第 5 章(调度器)与第 7、8 章(Lane 优先级 / 并发渲染)解释了"可中断渲染"与"过渡(Transition)"的能力;React 19 的 Actions、useOptimistic、useActionState、useTransition 全部建立在 Transition + Lane + Suspense 之上。
  • 第 9 章(Suspense 与 Suspend)解释了 useThenable / trackUsedThenable 如何让组件在等待 Promise 时挂起;React 19 的 use 原语正是把"读取 Context"和"读取 Promise"统一成了同一个入口。
  • 第 10 章(事件系统)解释了从原生事件到 React 合成事件的桥接;React 19 的 <form action> 就是在事件系统里新增了一个 FormActionEventPlugin,把表单提交事件直接翻译成一次 Action。

因此,本章的写法是:先讲清楚每个特性"调用了哪些底层设施",再讲"它如何在底层设施之上加了一层薄薄的语义封装"。这样你读完之后不会觉得 React 19 是"一堆新概念",而是"同一套引擎的更顺手的挡位"。


1. 动机与背景:为什么是 2019 年的"Action 范式"

1.1 三个长期痛点

React 团队协作的工程师在 RFC 中反复提到三个痛点,它们共同催生了 React 19 的 Action 范式:

痛点 A:异步状态管理的样板代码爆炸。 在 React 18 及以前,一个"提交表单 → 等待 → 成功/失败 → 回滚"的流程,需要开发者手动维护 isPending、error、submit 三个状态,手动在 try/finally 里开关 isPending,手动处理乐观更新与回滚。这三个状态彼此强耦合,却散落在组件各处。

痛点 B:乐观更新(Optimistic UI)的回滚极其脆弱。 乐观更新的本质是"先把界面改成期望结果,等真实结果回来再校正"。一旦失败,必须精确地把状态还原到提交前。在并发渲染下,因为有可中断渲染和"被更高优先级打断"的情况,手动 setState(旧值) 的回滚经常与并发更新交错,产生 race condition。

痛点 C:与服务器组件(RSC)的割裂。 在 RSC 模型中,组件可以在服务器端执行、返回一个"动作"(Action)给客户端调用。但客户端侧的调用约定(如何传参、如何等待、如何表达 pending)在 18 中没有统一抽象。

1.2 React 19 的统一答案

React 19 给出的答案不是三个独立 API,而是一组围绕"Transition(过渡)"这个既有原语建立的语义层:

  1. 把"提交一个可能异步的动作"统一表达为一次 Transition(第 7、8 章)。
  2. 把 isPending 的追踪交给 useTransition 的同一个底层状态机(HostTransitionContext + useHostTransitionStatus)。
  3. 把"乐观更新 + 自动回滚"封装进 useOptimistic,其回滚机制直接复用了 Transition 的 revertLane。
  4. 把"状态 + dispatch + pending"打包成 useActionState,内部用一条串行 Action 队列 + 一个可悬挂(suspend)的 thenable 实现。
  5. 把"读取 Context 或 Promise"统一为 use 原语。

一句话心智模型(后面会反复用到):

React 19 的 Action 特性 = Transition(并发内核)+ Suspense(挂起内核)+ 一条串行队列(顺序保证)+ 自动 revertLane(回滚保证)。

下面这张 mermaid 给出宏观结构。

flowchart TB subgraph 用户代码 A[&#34; / useActionState / useOptimistic / use(...)&#34;] end subgraph React19特性层 B[&#34;useActionState → 串行 Action 队列&#34;] C[&#34;useOptimistic → 乐观更新 + revertLane&#34;] D[&#34;use → 读取 Context / thenable&#34;] E[&#34; → FormActionEventPlugin&#34;] end subgraph 底层内核(前序章节) F[&#34;Transition (ReactStartTransition)&#34;] G[&#34;Lane 优先级 (第7章)&#34;] H[&#34;Suspense 挂起 (第9章)&#34;] I[&#34;HostTransitionContext&#34;] J[&#34;Fiber 协调 (第4章)&#34;] end A --> B A --> C A --> D A --> E B --> F C --> F C --> G D --> H E --> F F --> I F --> G F --> J

2. 核心数据结构全景

在逐个拆解 API 之前,先把本章会贯穿始终的几个数据结构摆出来,后面讲到任何实现细节你都能对号入座。

2.1 ThenableState 与 thenableIndexCounter(支撑 use / 挂起)

use 读取 Promise 时,React 需要记录"这个组件在第几个 use 调用处、对哪个 thenable 产生了依赖",以便挂起后重放(replay)。相关模块级变量在 packages/react-reconciler/src/ReactFiberHooks.js:287:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:287
let thenableIndexCounter: number = 0;
let thenableState: ThenableState | null = null;
  • thenableIndexCounter:当前组件渲染过程中,use(thenable) 被调用的顺序序号。每次进入渲染被重置(:704、:818、:979 等处都有 thenableIndexCounter = 0)。
  • thenableState:保存"本 fiber 渲染期间所有挂起的 thenable 及其结果缓存"的结构,由 createThenableState() 创建。

2.2 ActionStateQueue 与 ActionStateQueueNode(支撑 useActionState)

useActionState 不是简单的 useState,它内部维护一条串行队列 ,保证多个 Action 按顺序执行、前一个完成才执行下一个。定义见 packages/react-reconciler/src/ReactFiberHooks.js:2068:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2068
type ActionStateQueue<S, P> = {
  state: Awaited<S>,                  // 最近一次 action 返回的最新 state
  dispatch: Dispatch<P>,              // 稳定的 dispatch 方法
  action: ((Awaited<S>, P) => S) | null, // 当前渲染的 action 实现;为 null 表示已出错
  pending: ActionStateQueueNode<S, P> | null, // 环形链表,记录排队中的 action
};

每个被 dispatch 的 action 会创建一个节点 ActionStateQueueNode(:2084):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2084
type ActionStateQueueNode<S, P> = {
  payload: P,
  action: (Awaited<S>, P) => S,
  next: ActionStateQueueNode<S, P>,   // 环形链表
  isTransition: boolean,              // 本次 dispatch 是否在 transition 内
  then: (listener: () => void) => void, // 实现 Thenable 接口
  status: 'pending' | 'rejected' | 'fulfilled',
  value: any,
  reason: any,
  listeners: Array<() => void>,
};

注意第 2099 行的 then 方法------每个 action 节点本身就是一个 thenable 。这意味着 useActionState 返回的 state 可以通过 use(thenable) 机制被"悬挂"读取,直到该 action 完成。这是把并发、Suspense 与 Action 缝合在一起的关键一针。

2.3 Update.revertLane(支撑 useOptimistic 自动回滚)

乐观更新的队列节点(Update)上多了一个 revertLane 字段,定义见 packages/react-reconciler/src/ReactFiberHooks.js:169 附近的 Update 类型:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:169 (Update 类型节选)
  revertLane: Lane,

当一次乐观更新被提交后,它不会像普通更新那样"永久生效",而是被标记为"在某个 revertLane 代表的 Transition 完成时被撤销"。回滚逻辑在 updateReducerImpl 中(:1482 起),后面 4.2 节会逐行讲。

2.4 HostTransitionContext(支撑 useFormStatus)

useFormStatus 底层其实只是读取了一个 Context。这个 Context 叫 HostTransitionContext,在 packages/react-reconciler/src/ReactFiberHooks.js:3474:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3473
function useHostTransitionStatus(): TransitionStatus {
  return readContext(HostTransitionContext);
}

HostTransitionContext 的值在 ReactFiberHostContext.js:110 处由 form 这个 host 组件在渲染时写入,因此任意后代组件都能读到"当前 form 是否正在提交、用的是什么 method、data 是什么"。

2.5 Fiber.refCleanup(支撑 ref 清理函数)

React 19 允许函数式 ref 返回一个清理函数。实现上,commit 阶段在挂载 ref 时把返回值存到了 fiber 新增的 refCleanup 字段(packages/react-reconciler/src/ReactFiberCommitEffects.js:798):

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:798
finishedWork.refCleanup = ref(instanceToUse);

卸载/替换时再调用它(:850 起)。这就是 React 19 的"ref 清理函数"特性在运行时的落脚点。


3. Actions 与 <form action>:从事件到 Transition

3.1 入口:FormActionEventPlugin

React 19 在事件系统中新增了 FormActionEventPlugin(packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js)。它的职责只有一件:把表单的提交(submit)事件,翻译成一次 Action 调用,并以 Transition 的方式执行。

插件先从 form 节点上取出 action prop(:71):

js 复制代码
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:71
let action = coerceFormActionProp(
  (getFiberCurrentPropsFromNode(form) as any).action,
);

coerceFormActionProp(:27)的规则很明确:

js 复制代码
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:27
function coerceFormActionProp(actionProp: mixed) {
  if (
    actionProp == null ||
    typeof actionProp === 'symbol' ||
    typeof actionProp === 'boolean'
  ) {
    return undefined; // 不是函数也不是字符串 URL → 当作普通表单(默认提交行为)
  } else if (typeof actionProp === 'function') {
    return actionProp as any;       // 函数 → 客户端 Action
  } else {
    // 字符串 → 同构 Action / 服务器 Action 的 URL,做转义后作为提交地址
    checkAttributeStringCoercion(actionProp, 'action');
    return enableTrustedTypesIntegration ? actionProp : '' + (actionProp as any);
  }
}

这里出现了一个关键分支:action 是函数时,走"客户端 Action";是字符串时,走"同构/服务器 Action 的提交地址"。这两种路径在浏览器端都由同一个插件统一接管,区别只是前者立即调用函数、后者发网络请求。

3.2 提交即 Transition:startHostTransition

当 action 是函数时,插件会调用 startHostTransition(packages/react-reconciler/src/ReactFiberHooks.js:3266)------注意它在 react-reconciler 包里,说明这是与渲染器无关的内核逻辑:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3266
export function startHostTransition<F>(
  formFiber: Fiber,
  pendingState: TransitionStatus,
  action: (F => mixed) | null,
  formData: F,
): void {
  if (formFiber.tag !== HostComponent) {
    throw new Error('Expected the form instance to be a HostComponent...');
  }
  const stateHook = ensureFormComponentIsStateful(formFiber);
  const queue: UpdateQueue<...> = stateHook.queue;
  startHostActionTimer(formFiber);
  startTransition(
    formFiber,
    queue,
    pendingState,
    NoPendingHostTransition,
    action === null
      ? noop
      : () => {
          requestFormReset(formFiber);  // 完成后自动重置表单
          return action(formData);
        },
  );
}

请把这段代码和第 8 章的 startTransition 对照着看,它直接复用了 startTransition(:3117)。也就是说:点一次 <form action>,本质上就是自动帮你包了一次 startTransition。这就是为什么表单提交天然是非阻塞、可中断、带 pending 的------它走的就是并发内核。

3.3 把 host 组件"伪装"成有状态组件

ensureFormComponentIsStateful(:3309)是个很巧妙的实现:form 是一个 HostComponent(<form> 对应的原生 fiber),它本身没有 hooks 列表。但 React 需要给 form 一个 isPending 状态。于是它直接给这个 host fiber 手工挂载一个 state hook (:3335 起的 stateHook),并把它同时写进 formFiber.memoizedState 和 alternate.memoizedState(:3368),让协调器以为"这个 form 从一开始就有 hook"。

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3335 (节选)
const stateHook: Hook = {
  memoizedState: NoPendingHostTransition,
  baseState: NoPendingHostTransition,
  baseQueue: null,
  queue: newQueue,
  next: null,
};
// 第二个 hook 仅用于"表单是否需要被 reset"
const resetStateHook: Hook = { ... };
stateHook.next = resetStateHook;
formFiber.memoizedState = stateHook;
const alternate = formFiber.alternate;
if (alternate !== null) {
  alternate.memoizedState = stateHook;
}

这种"运行时给原生组件注入 hook"的手法,是 React 内部"鸭子类型"哲学的极致体现:只要 fiber 上有 memoizedState 指向一个合法的 hook 链表,协调器就会把它当函数组件一样处理更新。它也为我们理解"host 组件如何拥有 React 状态"打开了一个窗口。

3.4 自动重置表单:requestFormReset

注意 :3304 的 requestFormReset(formFiber)。它在 action 函数返回之后 被调用,意味着:表单字段会在动作完成后自动清空。这是"受控表单"体验的一大改进------以前你得在 onSubmit 里手动 e.target.reset()。

requestFormReset 的实现(:3377)同样复用了底层更新机制,但它有个重要约束:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3378
export function requestFormReset(formFiber: Fiber) {
  const transition = requestCurrentTransition();
  if (transition === null) {
    if (__DEV__) {
      console.error(
        'requestFormReset was called outside a transition or action. ' +
        'To fix, move to an action, or wrap with startTransition.',
      );
    }
  }
  ...
}

源码明确告诉我们:requestFormReset 必须在 transition / action 内部调用 。如果你在 await 之后、脱离了 transition 作用域的地方调用它,DEV 模式下会报错。这与 dispatchOptimisticSetState 对"乐观更新必须处于 transition 内"的约束(:3737 起)是同一套设计哲学------所有"带着自动回滚语义的乐观操作"都被强制绑定到 Transition 上,否则回滚无法被精确关联到对应的车道。

特别注意:第 10 章讲事件系统时会提到,浏览器原生 reset 事件不会触发 React 的 requestFormReset;它是 React 内部在 action 完成回调里主动调用的,与 DOM 的 form.reset() 是两条路径。

3.5 useFormStatus:只是读一个 Context

useFormStatus 听起来很神奇,但源码极其朴素(packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:67):

js 复制代码
// packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:67
export function useFormStatus(): FormStatus {
  const dispatcher = resolveDispatcher();
  return dispatcher.useHostTransitionStatus();
}

而 useHostTransitionStatus 我们已经在 2.4 节看到,就是 readContext(HostTransitionContext)。所以 useFormStatus 等价于"读取 form 这个 host 组件写入的 Transition 状态 Context"。这意味着:

  • 它只能读到离它最近的那个正在提交的 <form> 的状态(Context 语义,就近匹配,见第 9 章 Context 部分)。
  • 它返回的对象是 { pending, data, method, action }(:16 起的类型定义)。
  • 当没有 form 在提交时,它返回的是一个被 Object.freeze 的共享常量 NotPending(:41),所以比较 status.pending === false 是稳定引用。

3.6 底层串联:一次 <form action> 的完整调用链

把上面所有线索串起来,一次点击"提交按钮"引发的全链路如下(同时也是你要记住的调用链):

sequenceDiagram participant U as 用户点击 submit participant P as FormActionEventPlugin participant H as startHostTransition participant T as startTransition(内核) participant F as form fiber (HostComponent) participant C as HostTransitionContext participant S as useFormStatus 后代 U->>P: submit 事件 P->>P: coerceFormActionProp(action) P->>H: startHostTransition(form, action, formData) H->>T: startTransition(form, queue, pending, ...) T->>F: dispatchOptimisticSetState 写入 pending F->>C: HostTransitionContext._currentValue = pending C-->>S: useFormStatus 读到 pending=true T->>H: 执行 action(formData) H->>H: requestFormReset(form) // action 返回后 T->>F: revertLane 回滚 pending → false F->>C: HostTransitionContext = NotPending

这一节是理解后续所有"Action 类钩子"的基础:它们都在复用同一条 Transition 通道,区别只在于"状态存在哪、谁负责回滚、谁负责顺序"。


4. useOptimistic:乐观更新与 revertLane 自动回滚

4.1 公开 API 与最简心智模型

useOptimistic 的公开签名(packages/react/src/ReactHooks.js:226):

js 复制代码
// packages/react/src/ReactHooks.js:226
export function useOptimistic<S, A>(
  passthrough: S,
  reducer: ?(S, A) => S,
): [S, (A) => void] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useOptimistic(passthrough, reducer);
}

它返回 [optimisticState, addOptimistic]。心智模型:

passthrough 是"真实源状态"(通常来自服务端或 useState);addOptimistic(action) 会在当前渲染"叠加"一个乐观结果;一旦底层的 Transition 完成,所有未确认的乐观更新会按 revertLane 自动被撤销,界面回退到 passthrough;随后真实状态更新到达,界面平滑切换到真实结果。

4.2 mount 阶段:一个没有 eager 优化的队列

mountOptimistic(packages/react-reconciler/src/ReactFiberHooks.js:1974):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1974
function mountOptimistic<S, A>(
  passthrough: S,
  reducer: ?(S, A) => S,
): [S, (A) => void] {
  const hook = mountWorkInProgressHook();
  hook.memoizedState = hook.baseState = passthrough;
  const queue: UpdateQueue<S, A> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: null,   // 关键:乐观更新不做 eager 优化
    lastRenderedState: null,
  };
  hook.queue = queue;
  const dispatch: A => void = dispatchOptimisticSetState.bind(
    null, currentlyRenderingFiber, true, queue,
  ) as any;
  queue.dispatch = dispatch;
  return [passthrough, dispatch];
}

注意 lastRenderedReducer: null。普通 useState 会在 dispatch 时同步 算出 eager state(避免一次额外渲染),但乐观更新刻意不做这件事------因为乐观更新迟早要被回滚,提前计算结果没有意义,反而可能在回滚逻辑上引入复杂度。源码注释(:1984)也直接写了:Optimistic state does not use the eager update optimization.

4.3 update 阶段:每帧把 baseState 重置为 passthrough

这是 useOptimistic 最核心、也最容易被误解的一点。updateOptimisticImpl(:2013):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2013
function updateOptimisticImpl<S, A>(
  hook: Hook, current: Hook | null,
  passthrough: S, reducer: ?(S, A) => S,
): [S, (A) => void] {
  // 每帧都把 baseState 重置为 passthrough!
  hook.baseState = passthrough;

  const resolvedReducer: (S, A) => S =
    typeof reducer === 'function' ? reducer : (basicStateReducer as any);

  return updateReducerImpl(hook, currentHook as any as Hook, resolvedReducer);
}

为什么必须每帧 重置 baseState = passthrough?原因在于 React 的 reducer 更新模型(第 7 章 updateReducerImpl):baseState 是"上一次确认状态的基准",所有待处理的更新都叠加在它之上。对于乐观更新,我们不希望上一次的乐观结果成为下一个乐观结果的基准 ------因为乐观结果随时会被回滚。所以每次渲染都从"真实源 passthrough"重新叠加,保证:

  • 如果 passthrough 没变、也没有新的乐观 action,返回的就是 passthrough 本身("passthrough"这个名字即"透传"之意)。
  • 如果有多个乐观 action 排队,它们会按序叠加在 passthrough 之上。

源码在 :2019 的注释把这一点说得很直白:Optimistic updates are always rebased on top of the latest value passed in as an argument... If there are no pending updates, it will be returned as-is.

4.4 dispatch:乐观更新是如何被"挂"上去的

addOptimistic 实际指向 dispatchOptimisticSetState(:3728)。它的关键逻辑是给更新打上 revertLane:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3773 (节选)
const transition = requestCurrentTransition();
...
// 乐观更新提交是同步的(SyncLane),但回滚车道跟它绑定的 transition
const lane =
  enableGestureTransition && transition !== null && transition.gesture
    ? GestureLane
    : SyncLane;
const update: Update<S, A> = {
  lane: lane,
  // 提交后,用与 transition 相同的车道"撤销"这次乐观更新
  revertLane: requestTransitionLane(transition),
  ...
};

注意第 3773 行附近的注释:For regular Transitions an optimistic update commits synchronously. 即:乐观更新本身是 SyncLane 同步提交的 ,这样用户能立刻看到"乐观结果";而它的"撤销"被推迟到 revertLane 对应的 transition 完成时。

4.5 回滚的真相:revertLane 在 reducer 里被跳过

那么"回滚"到底是怎么发生的?秘密在 updateReducerImpl 处理更新队列时(:1482):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1482
if (isSubsetOfLanes(renderLanes, revertLane)) {
  // 该乐观更新绑定的 transition 已完成 → 假装这个更新不存在,跳过它
  update = update.next;
  if (revertLane === peekEntangledActionLane()) {
    didReadFromEntangledAsyncAction = true;
  }
  continue;
} else {
  // transition 还没完成 → 应用这个乐观更新,但把它留在队列里以便将来回滚或 rebase
  const clone: Update<S, A> = {
    lane: NoLane,
    revertLane: update.revertLane, // 保留 revertLane,以便后续判断何时撤销
    ...
  };
  ...
}

这段逻辑是整章最关键的几行之一,务必读透:

  1. 当一次渲染的 renderLanes 包含了该乐观更新的 revertLane(即:绑定的 transition 已经结束),React 直接 continue 跳过这个更新 ------相当于"这个乐观结果作废"。界面因此回到 passthrough(或 passthrough 之上新的真实更新)。
  2. 当 transition 还没完成,renderLanes 不包含 revertLane,则应用乐观更新,但把它的 revertLane 保留在"克隆体"里,留在队列中。等将来某次渲染 renderLanes 包含了 revertLane,它就会被上面的 isSubsetOfLanes 分支跳过------这就是"自动回滚"的实现:不是主动 setState 还原,而是"在 transition 完成的那次渲染里,不再应用这个更新"。

这种"用车道(Lane)做时间锚点"的手法,是 React 并发模型里最优雅的设计之一:回滚不需要额外的撤销队列,只需要一个车道标记 。它天然解决了"并发打断"下的回滚难题------无论中途被什么优先级打断,revertLane 始终精确指向"绑定的 transition 何时结束"。

4.6 与 startTransition 的关系

你可能会问:如果 useOptimistic 的 addOptimistic 必须包在 transition 里,那它和 useTransition 是什么关系?

答案在 dispatchOptimisticSetState 开头(:3734):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3734
const transition = requestCurrentTransition();
if (__DEV__) {
  if (transition === null) {
    if (peekEntangledActionLane() !== NoLane) {
      // 有一个挂起的 async action,不报警(React 会把它关联到该 action)
    } else {
      console.error(
        'An optimistic state update occurred outside a transition or action...',
      );
    }
  }
}

即:addOptimistic 必须在 startTransition 或 action 内部调用 ,否则 DEV 报警。当你用 <form action> 或 useActionState 时,它们内部已经帮你包好了 transition,所以你"免费"获得了乐观更新的回滚保证;当你手动用 useOptimistic 时,必须自己用 startTransition 包裹,否则回滚车道无法确定(revertLane 会落到默认车道,行为不符合预期)。

4.7 一个完整的并发回滚时序

下面这张 mermaid 把 4.4~4.6 串成时序,建议在理解源码后对照阅读:

sequenceDiagram participant Dev as 开发者 participant Opt as useOptimistic dispatch participant Q as Update 队列(revertLane=T) participant R as updateReducerImpl participant T as Transition T Dev->>Opt: startTransition(() => addOptimistic(A)) Opt->>Q: 压入 update(lane=Sync, revertLane=T) Opt->>T: 进入 transition T R->>Q: 渲染 renderLanes 不含 T → 应用乐观值 Note over R: 用户立即看到乐观结果 T->>T: action 异步完成 R->>Q: 新一轮渲染 renderLanes 含 T R->>Q: isSubsetOfLanes(renderLanes, revertLane) 为真 → continue 跳过 Note over R: 乐观更新被"不应用"=自动回滚 R->>R: 真实状态接管,界面切到 passthrough

5. useActionState:[state, dispatch, isPending] 的实现

5.1 一个名字,两个入口

先澄清一个源码事实(你 prompt 里也提到了):useActionState 在源码里也叫 useFormState,两者指向同一个实现。

在 packages/react/src/ReactHooks.js:234 中,公开导出的是 useActionState:

js 复制代码
// packages/react/src/ReactHooks.js:234
export function useActionState<S, P>(
  action: (Awaited<S>, P) => S,
  initialState: Awaited<S>,
  permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useActionState(action, initialState, permalink);
}

但在 dispatcher 表里,useActionState 和 useFormState 都映射到了 mountActionState (packages/react-reconciler/src/ReactFiberHooks.js:4839 起):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:4839 (DispatcherOnMount 节选)
useFormState<S, P>(action, initialState, permalink) {
  ...
  return mountActionState(action, initialState, permalink); // 与 useActionState 同函数
},
useActionState<S, P>(action, initialState, permalink) {
  ...
  return mountActionState(action, initialState, permalink);
},

所以你在源码里搜 useFormState 找不到"独立实现",因为它就是 useActionState 的曾用名/别名。这一点在写任何 React 19 文章时都值得点明,避免读者在源码里迷路。

5.2 三个返回值,三个内部钩子

mountActionState(:2380)内部不是用一个状态变量,而是组合了三个独立机制:

  1. state hook:存放真实的 action 返回值(可能是一个 thenable)。
  2. pending hook :一个 mountStateImpl(false) 的乐观 pending 状态(:2425),复用 useTransition 的 pending 表达。
  3. action queue hook :存放共享的 ActionStateQueue(:2440),这是串行的核心。
js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2380 (节选)
function mountActionState<S, P>(action, initialStateProp, permalink) {
  ...
  // 1) state hook:state 被存成一个 thenable,由 use 算法在渲染时解包
  const stateHook = mountWorkInProgressHook();
  stateHook.memoizedState = stateHook.baseState = initialState;
  const stateQueue = { pending: null, lanes: NoLanes,
    dispatch: null, lastRenderedReducer: actionStateReducer,
    lastRenderedState: initialState };
  stateHook.queue = stateQueue;
  const setState = dispatchSetState.bind(null, currentlyRenderingFiber, stateQueue);

  // 2) pending hook:像 transition 的 pending 一样被乐观地追踪
  const pendingStateHook = mountStateImpl(false as Thenable<boolean> | boolean);
  const setPendingState = dispatchOptimisticSetState.bind(
    null, currentlyRenderingFiber, false, pendingStateHook.queue,
  );

  // 3) action queue hook:所有实例共享,串行执行
  const actionQueueHook = mountWorkInProgressHook();
  const actionQueue: ActionStateQueue<S, P> = {
    state: initialState, dispatch: null, action, pending: null,
  };
  actionQueueHook.queue = actionQueue;
  const dispatch = (dispatchActionState as any).bind(
    null, currentlyRenderingFiber, actionQueue, setPendingState, setState,
  );
  actionQueue.dispatch = dispatch;

  // 把 action 函数暂存在 memoizedState,用于探测 action 变化
  actionQueueHook.memoizedState = action;
  return [initialState, dispatch, false];
}

注意第 2402 行注释:The state is stored in a thenable which is then unwrapped by the use algorithm during render. 这意味着 useActionState 返回的 state 之所以能"在 action 完成前是旧值、完成时变新值",是因为它把 state 存储成一个 thenable,交给 use 的挂起机制去解包。

5.3 串行队列:为什么需要它

useActionState 的核心承诺是:多个 dispatch 按顺序执行,后一个拿到的 state 是前一个的结果 (因为 action 签名是 (prevState, payload) => nextState)。如果多个 dispatch 并发进来了怎么办?答案就是 2.2 节的环形链表 ActionStateQueue.pending。

dispatchActionState(:2106)把新 action 挂到环形链表的尾部:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2158
const last = actionQueue.pending;
if (last === null) {
  // 没有排队的 action,这是第一个,立即运行
  actionNode.next = actionQueue.pending = actionNode;
  runActionStateAction(actionQueue, actionNode);
} else {
  // 已有 action 在跑,加入队列尾部
  const first = last.next;
  actionNode.next = first;
  actionQueue.pending = last.next = actionNode;
}

而 runActionStateAction(:2172)运行时,如果当前处于 transition 内(node.isTransition),会重建一个 transition 上下文 再执行 action(:2186 起),并通过 onStartTransitionFinish 把这次更新的"完成"与 React 的 transition 收尾钩子关联起来。action 返回的若是 thenable,则由 handleActionReturnValue(:2270)监听其 then,在 resolves 时调用 onActionSuccess(:2312):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2289
thenable.then(
  (nextState) => { onActionSuccess(actionQueue, node, nextState); },
  (error)   => onActionError(actionQueue, node, error),
);

onActionSuccess 做两件事(:2312):

  • 把 node.status 置为 'fulfilled' 并 notifyActionListeners(通知 use 的挂起等待者"可以醒了")。
  • 更新 actionQueue.state = nextState,然后从环形链表里弹出自己,运行下一个 pending 节点(:2338)。

错误处理 也在这里:一旦某个 action 抛错,onActionError(:2343)会把后续所有节点都标记为 rejected,并把 actionQueue.action = null------这样后续 dispatch 会被 dispatchActionState 开头检查到 currentAction === null 而直接 return (:2118),实现"一个失败,整条队列熔断"。

5.4 渲染期:用 use 解包 thenable 来悬挂

更新时(updateActionStateImpl,:2481),state 可能是个 thenable:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2496
let state: Awaited<S>;
if (typeof actionResult === 'object' && actionResult !== null &&
    typeof actionResult.then === 'function') {
  try {
    state = useThenable(actionResult); // 悬挂直到 action 完成
  } catch (x) {
    if (x === SuspenseException) {
      throw SuspenseActionException; // 标记这是 Action 类型的 Suspense(用于 Profiler)
    } else {
      throw x;
    }
  }
} else {
  state = actionResult;
}

这里有一个很关键的设计细节:useActionState 让组件在 action 未完成时"挂起" 。也就是说,如果你在 useActionState 的作用域里渲染 state,而 action 还没返回,整个组件(或其最近的 Suspense 边界)会显示为 fallback。这看起来"激进",但它是刻意的------它让"表单提交期间界面停留在旧值"这件事,由 Suspense 统一表达,而不是让你去手动判断 isPending 来决定渲染什么。

5.5 监听 action 变化:一个被动 effect

useActionState 还处理了一个边界情况:如果 action 函数本身变了(比如父组件传了新的函数引用),需要在 commit 阶段更新队列里的 actionQueue.action。源码在 :2524:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2524
const prevAction = actionQueueHook.memoizedState;
if (action !== prevAction) {
  currentlyRenderingFiber.flags |= PassiveEffect;
  pushSimpleEffect(
    HookHasEffect | HookPassive, createEffectInstance(),
    actionStateActionEffect.bind(null, actionQueue, action), null,
  );
}

actionStateActionEffect(:2539)只在 effect 里做一件事:actionQueue.action = action。这就是把"最新 action 引用"同步进共享队列------不影响正在排队的旧 action(旧 action 节点已固化了当时的 action 引用,见 :2125 注释"概念上就像把 action 发到了远程 worker")。

5.6 useActionState 与 useTransition 的异同

维度 useTransition useActionState
公开返回值 [isPending, startTransition] [state, dispatch, isPending]
pending 来源 内部 state hook 内部 pending hook(同机制)
是否能携带返回值 否(仅副作用) 是(state 由 thenable 解包)
串行保证 无(多次 start 各自独立) 有(环形队列串行)
悬挂行为 不悬挂,仅 pending 渲染 state 时悬挂
底层 startTransition + HostTransitionContext startTransition + 共享队列 + use 解包

6. use 原语:统一读取 Context 与 Promise

6.1 公开 API

js 复制代码
// packages/react/src/ReactHooks.js:207
export function use<T>(usable: Usable<T>): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.use(usable);
}

use 是 React 19 第一个允许在条件语句、循环里调用 的"类 hook"------因为它本质上不是 hook,而是"读取器"。源码在 ReactFiberHooks.js:1159:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1159
function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
      // 这是一个 thenable → 走 useThenable
      const thenable: Thenable<T> = usable as any;
      return useThenable(thenable);
    } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
      // 可恢复错误,直接穿过(Fiber 是最终渲染器,无需恢复子树)
      return undefined as any;
    } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
      // 这是一个 Context → 走 readContext
      const context: ReactContext<T> = usable as any;
      return readContext(context);
    }
  }
  throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

三种分支:

  1. thenable (usable.then 是函数)→ useThenable(挂起读取)。
  2. REACT_RECOVERABLE_TYPE → 直接返回 undefined(一种错误恢复机制,第 9 章之外的边角,本文不展开)。
  3. REACT_CONTEXT_TYPE → readContext(读取 Context,与 useContext 等价)。

6.2 useThenable:挂起、缓存位置、重放

useThenable(:1098)是 use 读取 Promise 的核心:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1098
function useThenable<T>(thenable: Thenable<T>): T {
  const index = thenableIndexCounter;   // 记录这是本组件第几个 use(thenable)
  thenableIndexCounter += 1;
  if (thenableState === null) {
    thenableState = createThenableState();
  }
  const result = trackUsedThenable(
    thenableState, thenable, index,
    __DEV__ ? currentlyRenderingFiber : null,
  );
  ...
  return result;
}

trackUsedThenable(packages/react-reconciler/src/ReactFiberWorkLoop.js 或 ReactFiberSuspense 相关,第 9 章详述)的行为是:

  • 若 thenable 已 fulfilled,直接返回 .value。
  • 若 pending,则抛出一个 SuspenseException 并附带一个"唤醒函数",让当前渲染挂起,等待 Suspense 边界接管(第 9 章核心机制)。
  • 若 rejected,抛出 .reason。

重放(replay)逻辑 是 useThenable 后半段(:1124 起)最精妙的部分。注释原文解释了:当某个 use 挂起导致组件被重放时,React 会先用"re-render" dispatcher 重新执行组件;如果挂起的 use 之后还有别的 hook,它们在上一次尝试里还没被处理,所以重放后需要从 re-render dispatcher 切回 mount/update dispatcher。源码通过检查"当前 hook 是否还有剩余"来决定是否切换(:1133):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1133
if (nextWorkInProgressHook !== null) {
  // 上一次尝试还有剩余的 hook 没处理
} else {
  // 没有剩余 hook 了,退出 re-render 模式,切回正常 mount/update dispatcher
  const currentFiber = workInProgressFiber.alternate;
  if (__DEV__) {
    if (currentFiber !== null && currentFiber.memoizedState !== null) {
      ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
    } else {
      ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
    }
  } else {
    ReactSharedInternals.H =
      currentFiber === null || currentFiber.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
}

这段逻辑保证了:即使组件在 use(thenable) 之后还有 useState、useEffect 等普通 hook,重放也不会破坏 hook 调用顺序的一致性 。这是 use 能够"安全地出现在条件/循环里"的底层支撑------因为即使它挂起,React 也记得"后面还有几个 hook、分别是什么类型"。

6.3 use 与 useContext 的关系

use(Context) 和 useContext(Context) 在功能上等价,都走到 readContext(:1173)。区别是:

  • useContext 在 ReactHooks.js:56 有一个 DEV 检查,禁止你传 Context.Consumer(会报警)。
  • use 走的是更通用的 use 分派,支持 thenable 与 Context 二选一。

这印证了本章开头的论断:use 是"读取器"的统一入口,而 useContext 是它的一个特化别名。

6.4 use 的悬挂与 Suspense 的联动(呼应第 9 章)

useActionState 在 5.4 节调用 useThenable 时,如果 thenable 还没完成,会抛出 SuspenseException(被 updateActionStateImpl 捕获后重新以 SuspenseActionException 抛出,:2511)。这正是 use 与第 9 章 Suspense 的接口契约:use(thenable) 不直接渲染 fallback,而是把"挂起"这件事交给最近的 Suspense 边界去决定。


7. ref 作为普通 prop:forwardRef 为何不再必需

7.1 公开事实

React 19 最大的"写法简化"之一:你不再需要用 forwardRef 就能把 ref 传给函数组件。React.forwardRef 在 19 中仍然可用(向后兼容),但不再是必需。

公开导出仍在 packages/react/src/ReactForwardRef.js:12,内部只是把 render 函数包成一个 $$typeof: REACT_FORWARD_REF_TYPE 的元素类型(:51)。它之所以还工作,是因为协调器对 ForwardRef 节点做了特殊分支(见下)。

7.2 协调器如何处理 ref prop:updateForwardRef

关键变化在 packages/react-reconciler/src/ReactFiberBeginWork.js:404 的 updateForwardRef:它会在把 props 传给 render 函数之前,把 ref 从 props 里摘出来,单独作为第二个参数传给旧的 forwardRef render:

js 复制代码
// packages/react-reconciler/src/ReactFiberBeginWork.js:404
function updateForwardRef(current, workInProgress, Component, nextProps, renderLanes) {
  ...
  const ref = workInProgress.ref;
  let propsWithoutRef;
  if ('ref' in nextProps) {
    // ref 现在就是 props 的一个普通字段,但 forwardRef 期望它不出现在 props 里
    propsWithoutRef = {};
    for (const key in nextProps) {
      if (key !== 'ref') {
        propsWithoutRef[key] = nextProps[key];
      }
    }
  } else {
    propsWithoutRef = nextProps;
  }
  ...
  // 单独把 ref 作为第二参数传入
  nextChildren = renderWithHooks(current, workInProgress, render, propsWithoutRef, ref, renderLanes);
}

这段代码的存在本身就是一个"考古证据":React 19 已经把 ref 当作普通 prop 了 ('ref' in nextProps 成立),只是为了让老的 forwardRef 组件不报错,才在 updateForwardRef 里额外做了一次"把 ref 摘出来单独传"的兼容处理。

那么普通函数组件呢?普通函数组件的 nextProps 里就直接带着 ref,updateFunctionComponent(:1453)把它原样传进你的组件函数,于是你的组件函数签名 (props) 里 props.ref 就能拿到了。没有额外的拆分逻辑------这就是"ref 作为普通 prop"的本质:JSX 转换后 ref 不再被 createElement 单独抽走,而是留在 props 中,协调器也原样透传。

7.3 commit 阶段:ref 挂载与清理(ref cleanup 函数)

挂载 ref 的逻辑在 commitAttachRef(packages/react-reconciler/src/ReactFiberCommitEffects.js:757)。React 19 在这里新增了对"函数 ref 返回清理函数"的支持:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:794
if (typeof ref === 'function') {
  if (shouldProfile(finishedWork)) {
    try {
      startEffectTimer();
      finishedWork.refCleanup = ref(instanceToUse); // 返回值存为 refCleanup
    } finally {
      recordEffectDuration(finishedWork);
    }
  } else {
    finishedWork.refCleanup = ref(instanceToUse);
  }
} else {
  if (__DEV__) {
    if (typeof ref === 'string') {
      console.error('String refs are no longer supported.'); // string ref 已移除!
    } else if (!ref.hasOwnProperty('current')) {
      console.error('Unexpected ref object provided...');
    }
  }
  ref.current = instanceToUse; // 对象 ref 仍写 .current
}

两个值得注意的事实:

  1. 函数 ref 现在可以返回一个清理函数 ,返回值被存进 finishedWork.refCleanup(:798)。这与 useEffect 的"返回清理函数"在概念上完全对称。
  2. String refs 在 React 19 已彻底移除 ::810 的 console.error('String refs are no longer supported.') 表明,如果你在 JSX 里写 ref="myRef",DEV 下会直接报错而不是静默降级。

清理发生在 safelyDetachRef(:842):

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:846
const ref = current.ref;
const refCleanup = current.refCleanup;
if (ref !== null) {
  if (typeof refCleanup === 'function') {
    try {
      ...
      refCleanup(); // 调用函数 ref 返回的清理函数
    } ...
  } else if (typeof ref === 'function') {
    ref(null); // 没有返回清理函数时,仍按旧语义传 null
  } else {
    ref.current = null; // 对象 ref 置空
  }
}

7.4 useImperativeHandle 的清理语义同步升级

useImperativeHandle 的内部 effect imperativeHandleEffect(:2817)也相应支持返回清理函数:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2821
if (typeof ref === 'function') {
  const refCallback = ref;
  const inst = create();
  const refCleanup = refCallback(inst);
  return () => {
    if (typeof refCleanup === 'function') {
      refCleanup(); // 支持 ref 清理函数
    } else {
      refCallback(null); // 旧语义兜底
    }
  };
}

这证明"ref 清理函数"是 React 19 在全链路(原生 ref、函数 ref、imperative handle)统一引入的能力,而非零散修补。

7.5 向后兼容结论

  • 用 forwardRef :仍然工作,但 updateForwardRef 会多一次"摘 ref"的兼容处理(:427 起)。
  • 不用 forwardRef,直接 props.ref:React 19 推荐写法,零成本。
  • String ref:彻底移除,DEV 报错。
  • 对象 ref({current}) :仍完全支持,写 .current。
  • 函数 ref 返回清理函数:React 19 新增,与 effect 清理对称。

8. 文档元数据:在组件里渲染 <title>/<meta>/<link>

8.1 现象与心智模型

React 19 允许你在任意组件里直接写:

jsx 复制代码
function Page() {
  return (
    <div>
      <title>我的页面标题</title>
      <meta name="description" content="..." />
      <link rel="stylesheet" href="/a.css" />
      <h1>正文</h1>
    </div>
  );
}

即使 <title> 写在 <div> 里面,React 也会在 DOM 提交时把它自动提升到 document.head 。这套机制在源码里称为 hoisting(提升) 与 host singletons(宿主单例)。

8.2 单例宿主:head / html / body

html、head、body 在 React DOM 里被标记为"宿主单例"(SingletonType,packages/react-dom-bindings/src/client/ReactDOMComponent.js:1498)。它们的处理逻辑与普通元素不同:整个文档只有一个 <head>,React 会直接复用 document.head 而不是创建新节点。

getHoistableRoot / isHostSingleton(在 ReactFiberConfigDOM.js:4947):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4947
function isHostSingleton(type: string): boolean {
  return type === 'html' || type === 'head' || type === 'body';
}

而 head 这个单例的"真实 DOM 节点"就是 ownerDocument.head(ReactFiberConfigDOM.js:4978):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4978
case 'head': {
  const head = ownerDocument.head;
  if (!head) {
    throw new Error(
      'React expected a <head> element (document.head) to exist...',
    );
  }
  return head;
}

8.3 元数据节点的识别与排除

当 React 在 head 中做 hydration 或匹配已有节点时,需要区分"哪些 <meta>/<link>/<style>/<script> 是 React 自己提升上去的、哪些是第三方脚本/浏览器扩展注入的"。源码在 ReactFiberConfigDOM.js:4133 起对每种标签做了启发式判断:

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4137
case 'meta': {
  // 唯一"退出提升"的方式是给 meta 加 itemprop 属性
  if (!element.hasAttribute('itemprop')) {
    break; // 当作 hoistable,可复用/提升
  }
  return element; // 有 itemprop 则视为用户自定义,不提升
}
case 'link': {
  const rel = element.getAttribute('rel');
  if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) {
    break; // 样式资源
  } else if (
    rel !== anyProps.rel ||
    element.getAttribute('href') !== (anyProps.href == null || anyProps.href === '' ? null : anyProps.href) ||
    element.getAttribute('crossorigin') !== (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) ||
    element.getAttribute('title') !== (anyProps.title == null ? null : anyProps.title)
  ) {
    break; // 用高熵属性(rel/href/crossOrigin/title)区分不同 link
  }
  return element;
}

注释(:4138、:4149)解释了设计权衡:React 无法 100% 区分"第三方注入的 link"和"自己要提升的 link",于是采用 rel + href + crossOrigin + title 这类高熵属性做近似匹配------绝大多数情况下能正确区分,极少数边缘情况(如扩展在 body 前插入 div)仍可能 hydration 不匹配,但那是历史既有的边界。

8.4 提升的提交时机

元数据节点的"提升"发生在 commit 阶段(第 4 章 commit 的 host 副作用里)。React 19 把这些特殊节点标记为 HostHoistable(commitAttachRef 的 switch 里就包含 HostHoistable 分支,:762)。配合 head 单例,React 在把 <title> 等节点挂载到 fiber 树的同时,把它们插入到真实 document.head,而不是它们在 JSX 里所处的父节点之下。

8.5 hydration 下的处理

SSR 场景下,React 19 的 Fizz 服务端渲染会在 HTML 里直接输出 <title>/<meta>/<link> 到 <head>(见 ReactFizzConfigDOM.js 相关 precedence 处理)。客户端 hydration 时:

  • 对 head 单例,React 调用 clearHead(ReactFiberConfigDOM.js:4042)在必要时清理已有子节点。
  • 对元数据节点,用 8.3 节的启发式匹配已有 DOM,避免重复插入或 hydration mismatch。

源码注释(:4135)特别说明 title 默认全部视为可匹配 :因为文档里通常只有一个 title,且如果 <title> 在 <svg> 上下文或带 itemProp,那它本来就不是文档标题。这种"默认信任 + 少量例外"的策略,让绝大多数应用的 SSR/CSR 一致性无缝。

8.6 与第 4、9 章的呼应

元数据提升复用了第 4 章的"host 组件提交"通道,只是目标父节点从 fiber 父节点换成 document.head;它的 hydration 匹配逻辑又复用了第 9 章 Suspense/hydration 的"节点复用一个已有 DOM"机制。再次印证:React 19 的新特性都在复用既有内核,而非另起炉灶。


9. <Context> 直接作为 Provider:语法糖的真相

9.1 现象

React 19 允许:

jsx 复制代码
const ThemeContext = createContext('light');
// 旧写法
<ThemeContext.Provider value="dark"><App /></ThemeContext.Provider>
// 新写法(等价)
<ThemeContext value="dark"><App /></ThemeContext.Provider>

9.2 源码真相:Provider 就是 Context 自己

打开 packages/react/src/ReactContext.js:35:

js 复制代码
// packages/react/src/ReactContext.js:35
context.Provider = context;   // Provider 直接指向 context 自身
context.Consumer = {
  $$typeof: REACT_CONSUMER_TYPE,
  _context: context,
};

context.Provider === context 。这意味着 <ThemeContext.Provider> 和 <ThemeContext> 在 JSX 层面对应的是完全相同的 React 元素类型 ($$typeof: REACT_CONTEXT_TYPE,且 _context 通过元素类型的 .Provider/_context 链可达)。协调器在 beginWork 里遇到 REACT_CONTEXT_TYPE 时,走的永远是同一套 Provider 逻辑(写入 context._currentValue、建立 Context 栈),根本不在乎你写的是 .Provider 还是直接写 Context 对象。

9.3 为什么这样设计是安全的

因为 createContext 返回的 context 对象的 $$typeof 就是 REACT_CONTEXT_TYPE,而 <Context> 这种 JSX 写法在 JSX 运行时(ReactJSXElement.js)里就是 jsx(ThemeContext, {value})------第一个参数正是 context 对象本身。协调器从元素类型上无法区分"这是 Context 还是 Context.Provider",因为它们是同一个引用。这是个非常干净的"对象即 Provider"设计,不需要任何额外的解析分支。

9.4 仍保留 .Provider 的原因

向后兼容。大量存量代码、类型定义(如 React.Provider 类型)、以及需要显式表达"这是 Provider"的语义场景,仍然依赖 .Provider 写法。源码让它俩指向同一引用,零成本地同时支持两种写法。


10. effect 返回清理函数:真实语义澄清(避免编造)

你 prompt 里提到"useEffect 支持异步""effect 回调返回清理函数"。这里必须以源码为准做精确澄清,因为这两点容易与社区误传混淆。

10.1 "返回清理函数"不是新东西,且只能是同步函数

effect 回调返回一个清理函数,从 React 16.8 起就支持。ReactFiberCommitEffects.js:175 把 create 的返回值直接存为 inst.destroy:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:175
destroy = create();
inst.destroy = destroy;

而在 unmount 时(:266)调用 inst.destroy。React 19 在此处没有任何新语义。

10.2 异步 effect 仍然不被支持(源码明确拦截)

社区常误传"React 19 支持了 async useEffect"。源码明确反对 。在 DEV 下,如果清理函数不是函数(包括你返回了一个 Promise/thenable),会报错(:188、:204):

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:204
} else if (typeof destroy.then === 'function') {
  addendum =
    '\n\nIt looks like you wrote ' + hookName +
    '(async () => ...) or returned a Promise. Instead, write the async ' +
    'function inside your effect and call it immediately...';
  ...
  console.error(
    '%s must not return anything besides a function, which is used for clean-up.%s',
    n, a,
  );
}

也就是说:effect 回调本身仍是同步执行的;你不能在 useEffect(async () => {...}) 里直接 await,也不能让 effect 返回一个 Promise 当作清理函数。如果你需要异步,标准写法是在 effect 内部定义并立即调用一个 async 函数(源码 addendum 给出的正是这个模板)。

10.3 React 19 真正与 effect 相关的新语义:清理顺序与 StrictMode

React 19 在 StrictMode 下对 effect 的"双调用"(mount → unmount → mount)行为更严格,目的是暴露"忘记清理"的 bug。mountEffectImpl 在 DEV + StrictEffectsMode 下会多加 MountPassiveDevEffect 等 flag(:2701 起),确保开发期能完整演练"挂载-清理-挂载"周期。这是行为强化,不是语法新增。

10.4 与"ref 清理函数"的区别要分清

不要把"ref 清理函数"(第 7.3 节,React 19 新增)与"effect 清理函数"(一直都有)混为一谈:

  • ref 清理函数 :函数式 ref (node) => {...; return () => cleanup} 返回的清理,是 React 19 新增。
  • effect 清理函数 :useEffect(() => { return () => cleanup }),一直都有,且清理函数必须是同步函数。

11. 其它破坏性变更与 flushSync 行为

11.1 flushSync 行为

flushSync(packages/react-dom-bindings/src/shared/ReactDOMFlushSync.js)用于在同步时机强制刷新更新。React 19 下它的语义基本延续:在 flushSync 回调内触发的更新会被同步、紧急地提交,不参与并发可中断调度。需注意的是:flushSync 内的更新会跳出并发模型的优先级保护,应仅用于"必须立刻反映到 DOM"的极少数场景(如测量、第三方库集成)。

它在 ReactDOMRoot.js:166 被 createRoot 的产物间接调用(flushSyncWork),属于 react-dom 渲染器对内核调度(flushSyncWork 来自调度器)的桥接。

11.2 函数组件移除 defaultProps

在 React 19 之前,函数组件可以像类组件一样写 MyComponent.defaultProps = {...}。React 19 移除了这一支持。证据在 updateFunctionComponent(packages/react-reconciler/src/ReactFiberBeginWork.js:1453):整段函数里不再有对 Component.defaultProps 的解析调用 ------nextProps 被原样传给 renderWithHooks(:1525)。对比早期版本(会在 mount 阶段调用 resolveDefaultProps),19.3.1 的源码里函数组件路径已无此分支。

官方建议:用默认参数 (function C({x = 1}) {})或解构默认值 替代 defaultProps。类组件的 defaultProps 仍保留(类组件路径不同,本文不展开)。

11.3 移除 string ref(已在 7.3 节给出源码证据)

:810 的 console.error('String refs are no longer supported.') 是铁证。任何 <div ref="name" /> 写法在 React 19 都会 DEV 报错。

11.4 移除 legacy Context(contextTypes)

packages/react-reconciler/src/ReactFiberBeginWork.js:1494 的报错信息:

js 复制代码
// packages/react-reconciler/src/ReactFiberBeginWork.js:1494
console.error(
  '%s uses the legacy contextTypes API which was removed in React 19. ' +
  'Use React.createContext() with React.useContext() instead.',
  componentName,
);

即 React 19 移除了 contextTypes / childContextTypes 这套老 Context API,统一走到 createContext + useContext(亦即第 7 章的 readContext)。

11.5 移除 module 模式工厂组件等

React 19 还移除了一些更早期的边缘 API(如 ReactDOM.render 旧入口已由 createRoot 取代,已在第 1 章铺垫)。这些不在本章核心,但都属于"清理历史包袱、让内核更一致"的同一波动作。


12. 与前序章节的呼应:一切都建立在 Fiber/Lane/并发/Hooks 之上

本章特性 依赖的底层(章节) 具体落点
<form action> 事件系统(10)、Transition(7/8) FormActionEventPlugin → startHostTransition → startTransition
useOptimistic Lane(7)、reducer 更新(6) revertLane + updateReducerImpl 跳过逻辑
useActionState Transition(7/8)、Suspense(9)、Hooks(3) 串行队列 + useThenable 悬挂 + pending hook
use Suspense(9)、Context(9) useThenable/readContext + 重放 dispatcher 切换
ref 作为 prop 协调(4)、commit(4) updateForwardRef 摘 ref;commitAttachRef 写 refCleanup
文档元数据 协调/commit(4)、hydration(9) head 单例 + HostHoistable + 启发式匹配
<Context> 作 Provider Context(9) context.Provider = context
effect/ref 清理函数 commit 副作用(4) inst.destroy / refCleanup

这张表就是本章的核心论点:React 19 没有发明新引擎,它把第 4~10 章的引擎零件,按"Action 范式"重新拼装成了更顺手的 API。


13. 边界场景与坑

13.1 useOptimistic 必须包在 transition 里

如果你在事件处理函数里直接调用 addOptimistic 而不包 startTransition,DEV 会报"optimistic state update occurred outside a transition or action"(:3764)。后果是 revertLane 落到默认车道,回滚时机不可预期。正确写法:

js 复制代码
// 推荐
function handleClick() {
  startTransition(() => {
    addOptimistic(action);
    saveToServer();
  });
}
// 或用 useActionState 的 dispatch / <form action>,它们自带 transition

13.2 useActionState 会让组件悬挂

因为 state 是 thenable 解包(5.4 节),action 未完成时组件会挂起。如果你不想要悬挂行为,应在渲染时基于 isPending 自行决定展示内容,并把 state 的使用限制在"已确认数据"区域,或就近包裹 <Suspense fallback={...}>。

13.3 串行队列的熔断

一旦某个 action 抛错,actionQueue.action 被置 null(:2362),后续所有 dispatch 都会被 :2118 的 if (currentAction === null) return; 静默丢弃。坑 :熔断后即使你换了新 action 函数,也得等 effect(actionStateActionEffect,:2539)把 actionQueue.action 重新置回才恢复------而那个 effect 在"队列已为空"时才会跑。因此错误恢复需要显式处理(如重新挂载组件,或 React 内部机制复位)。

13.4 ref 清理函数的调用时机

refCleanup 在 safelyDetachRef(:846)里、即 ref 被替换/卸载时调用,与 effect 清理同属 commit 副作用阶段。注意:函数 ref 返回非函数 (如返回 undefined)时,refCleanup 为 undefined,走旧语义 ref(null)(:855 的 else 分支)。所以"返回清理函数"和"不返回"两种写法可以混用,但不要返回 Promise(没有特殊处理,会被忽略且不符合契约)。

13.5 文档元数据提升与第三方脚本冲突

8.3 节已说明,React 用启发式匹配 head 里的 <link>/<style>/<script>。如果第三方扩展在 <head> 顶部注入同名 <link>,可能出现 hydration 时匹配错位。规避:给 React 管理的资源显式加 data-precedence(样式)或用稳定高熵属性(href/crossOrigin)。

13.6 use 不能替代 useEffect 获取数据

use(promise) 只适合"promise 已经存在、需要在渲染期读取"的场景(如 RSC 下由父级传入的 promise)。它不能 用来"在组件里 fetch 然后 use"------因为每次渲染都会创建新 promise,导致无限挂起/重渲染。数据获取请继续用 useEffect + state(或 use(读取已稳定的 promise) + Suspense)。

13.7 StrictMode 双调用对 Action 的影响

StrictMode 下 effect 双调用(10.3 节)同样适用于 useActionState 的 actionStateActionEffect 等被动 effect。但 Action 队列本身是"事件驱动"而非"渲染驱动",双调用不会重复触发 action 执行(action 在 dispatch 时入队,不在 effect 里)。这一区分很重要:不要把"effect 双调用"与"action 重放"混为一谈。


14. 性能影响

14.1 useOptimistic 的每帧 baseState 重置成本

updateOptimisticImpl 每帧执行 hook.baseState = passthrough(:2025)是 O(1),开销可忽略。真正成本在 updateReducerImpl 遍历更新队列,但因为乐观更新在 transition 完成后会被 continue 跳过(:1486),队列长度通常很短。

14.2 useActionState 的 thenable 解包与 Suspense

useThenable 每次渲染都会 trackUsedThenable(:1105),对已完成的 thenable 是 O(1) 命中缓存;对挂起的 thenable 触发挂起------这是有意的"等待成本" ,换来的是"界面在 action 期间自然停留"。若你高频 dispatch 且每个 action 都触发悬挂,可能产生较多 Suspense 边界切换;此时应评估是否真的需要"渲染 state 时悬挂",或改用纯 useTransition + 手动 state。

14.3 revertLane 的空间成本

每个乐观更新节点多存一个 revertLane(一个 Lane 位掩码,通常是 31 位整数),空间开销极小。但它让 React 能在不引入额外撤销栈的情况下实现回滚,是"用位运算换复杂度"的典型优化。

14.4 文档元数据提升的 DOM 操作

提升发生在 commit 阶段,与常规 host 挂载同批进行,不会额外触发 layout。但因 head 是共享单例,多个并发子树同时提升 <title> 时,最终以 fiber 树提交顺序的"最后一个胜出"为准------这正是"单一 title"语义期望的行为。

14.5 总体结论

React 19 这些特性在性能上**几乎都是"免费"或"极低开销"**的,因为它们复用既有内核、避免引入新的全局状态。主要性能关注点是:useActionState 的悬挂频率(业务层可控)与并发场景下 action 队列长度(通常很短)。


15. 用法与最佳实践(结合源码解释"为什么这样写才对")

15.1 表单提交:优先 <form action>

jsx 复制代码
// 正解:自动获得 transition、pending、提交后重置、可中断
function Form({action}) {
  return (
    <form action={action}>
      <input name="name" />
      <SubmitButton />
    </form>
  );
}

源码依据:startHostTransition(:3266)自动包 transition + requestFormReset(:3304)。你不需要手写 isPending 或 e.preventDefault()。

15.2 跨组件读表单状态:useFormStatus

useFormStatus(:67)只读 Context,零成本、就近匹配。适合做"提交中"按钮禁用态:

jsx 复制代码
function SubmitButton() {
  const {pending} = useFormStatus();
  return <button disabled={pending}>提交</button>;
}

15.3 乐观 UI:useOptimistic 包在 transition 内

jsx 复制代码
function Thread({messages}) {
  const [optimistic, addOptimistic] = useOptimistic(
    messages,
    (state, newMsg) => [...state, newMsg],
  );
  function send() {
    startTransition(() => {            // 必须包 transition!
      addOptimistic(draft);
      postMessage(draft);
    });
  }
  return optimistic.map(...);
}

源码依据::3737 强制 transition;:2025 每帧 rebase 到 messages(真实源)。

15.4 需要 state + dispatch + pending:用 useActionState

jsx 复制代码
const [state, dispatch, isPending] = useActionState(
  async (prev, formData) => { ...; return next; },
  initialState,
);

源码依据::2380 的三钩子组合;:2506 用 useThenable 解包 state。

15.5 读取 Context 或已存在的 Promise:用 use

jsx 复制代码
const theme = use(ThemeContext);          // 等价 useContext
const data = use(resourcePromise);        // 仅在 promise 已存在时

注意 use 可在条件/循环里调用(6.2 节重放机制保证 hook 顺序一致)。

15.6 ref 新写法

jsx 复制代码
function Child({ref}) {            // 直接当 prop 拿,无需 forwardRef
  return <div ref={ref} />;
}
// 函数 ref 返回清理
<div ref={(node) => {
  if (node) register(node);
  return () => unregister();       // React 19 新支持
}} />;

源码依据::798 存 refCleanup,:846 调用。

15.7 反模式清单

  • ❌ useEffect(async () => { await ... }) ------ 源码会报错(:204)。
  • ❌ 在事件里裸调 addOptimistic 不包 transition ------ DEV 报错(:3764)。
  • ❌ ref="string" ------ 移除(:810)。
  • ❌ 函数组件 defaultProps ------ 已在 19 移除(:1453 无解析)。
  • ❌ 在组件内 use(fetch(...)) ------ 每次渲染新 promise,无限挂起(见 13.6)。

15A. ref 作为普通 prop 的完整源码链路:从 JSX 到 commit

§7 已经给出了结论和两三个落点。本节把「ref 变成普通 prop」这条链从 JSX 运行时一路追到 commit,说明每一步在源码里到底发生了什么 ,以及为什么 forwardRef 还活着。这一节是对第 4 章(协调)与第 6 章(commit 副作用)的直接延伸。

15A.1 结论先行

一句话:在 19.3.1 里,ref 就是 props 上的一个普通字段;element.ref 只是为兼容保留的、访问即报错的「影子属性」。 整条链路一共四站:

(完整链路:JSX 运行时 → coerceRef 写入 fiber.ref → beginWork 透传 → commit 的 commitAttachRef。)

四站分别对应四个模块:react/src/jsx、react-reconciler/ReactChildFiber、react-reconciler/ReactFiberBeginWork、react-reconciler/ReactFiberCommitEffects。只要这四站串起来,"为什么不需要 forwardRef"就不再是玄学。

15A.2 第一站:JSX 运行时不再「抽走」ref

很多人以为「ref 曾经被 JSX 抽走」是 createElement 干的。历史上确实如此,但 19.3.1 已经改了。看 jsxProd(packages/react/src/jsx/ReactJSXElement.js:291)里构造 props 的那段:

js 复制代码
// packages/react/src/jsx/ReactJSXElement.js:322
  let props;
  if (!('key' in config)) {
    props = config;
  } else {
    // We need to remove reserved props (key, prop, ref). ...
    props = {};
    for (const propName in config) {
      // Skip over reserved prop names
      if (propName !== 'key') {
        props[propName] = config[propName];
      }
    }
  }

注释里明明写着 "remove reserved props (key, prop, ref)",但代码只跳过了 key。也就是说 ref 已经不再是「保留字段」,它会被原样复制进 props。注释是历史遗留,源码才是事实(写作规范第六条:以源码为准)。这种「注释与代码不一致」的地方,恰好是读源码最有价值的收获。

createElement 也一样(ReactJSXElement.js:668):过滤名单是 key / __self / __source------里面没有 ref 。注释「we don't want them to appear as props」值得咀嚼:__self/__source 仍然要过滤,ref 却留下了,恰好说明 ref 的地位从「保留字段」降格成了「普通 prop」。顺带说一句,__self/__source 之所以要过滤,是因为旧 JSX 转换(Babel 6 时代)会把它们塞进 config,而现代运行时不再需要它们。

15A.3 ReactElement:ref 成为「影子属性」

ReactElement 工厂(:170)不再给 ref 单独的语义:

js 复制代码
// packages/react/src/jsx/ReactJSXElement.js:170
function ReactElement(type, key, props, owner, debugStack, debugTask) {
  // Ignore whatever was passed as the ref argument and treat `props.ref` as
  // the source of truth. ...
  const refProp = props.ref;
  const ref = refProp !== undefined ? refProp : null;

关键句:props.ref 是唯一事实来源(source of truth) ,element.ref 只是由它派生出来的兼容字段。DEV 下这个字段被定义成一个会报警的 getter(:201):

js 复制代码
// packages/react/src/jsx/ReactJSXElement.js:143
function elementRefGetterWithDeprecationWarning() {
  if (__DEV__) {
    console.error('Accessing element.ref was removed in React 19. ref is now a ' +
      'regular prop. It will be removed from the JSX Element type in a future release.');
    const refProp = this.props.ref;
    return refProp !== undefined ? refProp : null;
  }
}

于是三种访问的行为被精确区分开:

  • 读 props.ref:正常,19 的推荐写法。
  • 读 element.ref:DEV 报错(按组件名去重,只报一次),生产静默返回派生值,将来会彻底删除。
  • DEV 下 element.ref 不可枚举 (:202):Jest 用 getOwnPropertyDescriptors 比较对象时不会误触发警告(例如 expect(<div ref={ref} />).toEqual(<span ref={ref} />))。

生产环境则干脆把 ref 当作普通属性(:225):

(生产环境下 ref 就是 element 上的普通属性,见 packages/react/src/jsx/ReactJSXElement.js:225 的 { $$typeof, type, key, ref, props }。)

15A.4 第二站:创建 Fiber 时从 props.ref 取值

真正决定「ref 挂在哪个 fiber 上」的,是协调子节点时的 coerceRef(packages/react-reconciler/src/ReactChildFiber.js:291):

js 复制代码
// packages/react-reconciler/src/ReactChildFiber.js:291
function coerceRef(workInProgress: Fiber, element: ReactElement): void {
  // TODO: This is a temporary, intermediate step. ...
  const refProp = element.props.ref;
  workInProgress.ref = refProp !== undefined ? refProp : null;
}

两处 TODO 说得很坦率:这是「临时/过渡」实现 ------理想状态下 ref 应该在对应组件自己的 begin 阶段解析(HostComponent / ClassComponent 各自处理),而不是在创建 fiber 时统一 coerceRef。19.3.1 选了后者,但关键点在于:它读的是 element.props.ref(props!),不是 element.ref。这再一次证明「props.ref 才是事实来源」。

注意 coerceRef 只在 createFiberFromElement(ReactFiber.js:739)等创建路径上被调用。更新已有 fiber 时不会重新 coerceRef,而是由 createWorkInProgress(ReactFiber.js:412)把 workInProgress.ref = current.ref、workInProgress.refCleanup = current.refCleanup 直接继承。fiber 上这两个字段在初始化时是 null(ReactFiber.js:154、:155)------refCleanup 是 fiber 的新字段,专门为 §7.3 的「ref 清理函数」服务。

15A.5 第三站:beginWork 如何对待 props.ref

分两条路。

(1)普通函数组件:原样透传。 updateFunctionComponent(packages/react-reconciler/src/ReactFiberBeginWork.js:1453)把 nextProps 直接交给 renderWithHooks(:1529、:1539),中间没有任何摘 ref 的动作 。于是你的组件函数 function C({ref}) {} 能直接从 props 里拿到它------这正是「不再需要 forwardRef」的实现基础。

(2)forwardRef 组件:把 ref 摘出去,作为第二参数。 updateForwardRef(:404)里那段「新建 propsWithoutRef、逐个复制非 ref 字段」的循环,与 §7.2 引用的同源代码完全一致(见 ReactFiberBeginWork.js:424)。

注意 :428 的注释:ref is just a prop now(ref 现在就是个 prop)。所以 forwardRef 的兼容不是「另一套机制」,而是「把 props 里的 ref 再摘出去一次 」,以维持旧的 (props, ref) 双参数签名。而 'ref' in nextProps 这个判断本身也说明:在 19 里 ref 通常会出现在 props 上。

小结:forwardRef 没有消失,也没有被改造,它只是多了一次「抵消新行为」的兼容处理。用 forwardRef 的组件行为不变,不用的组件也直接能拿 ref。 这也是为什么 forwardRef 的公开实现(packages/react/src/ReactForwardRef.js:12)几乎一字未改,只在 DEV 里多了一条「forwardRef render 函数不支持 defaultProps」的检查(:42):

(这条报错在 packages/react/src/ReactForwardRef.js:42,__DEV__ 下当 render.defaultProps != null 时触发。)

15A.6 第四站:commit 阶段写 ref、存 cleanup

commitAttachRef(packages/react-reconciler/src/ReactFiberCommitEffects.js:757)先按 fiber 类型取「要挂载的实例」:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:761
    switch (finishedWork.tag) {
      case HostHoistable:
      case HostSingleton:
      case HostComponent:
        instanceToUse = getPublicInstance(finishedWork.stateNode);
        break;
      // ... ViewTransitionComponent / Fragment 两个分支略
      default:
        instanceToUse = finishedWork.stateNode;
    }

同一个 switch 里同时出现 HostHoistable(被提升的 <link>/<meta> 等)与 HostSingleton(html/head/body),正好呼应 §8 的文档元数据------能被 ref 引用的,正是这些「特殊 tag」的公共实例(public instance) 。这也解释了为什么 <title ref={...}> 这类写法在 19 里是有意义的。

然后是三种 ref 形态的分流(此段与 §7.3 的 ReactFiberCommitEffects.js:794 同源):

三件事:

  1. 函数 ref :调用它,把返回值 存进 fiber.refCleanup------这就是 React 19 新增的「ref 清理函数」。
  2. string ref:DEV 报「String refs are no longer supported.」(§7.3、§11.3)。
  3. 对象 ref :写 .current;若连 current 都没有,给出「Unexpected ref object」提示。

卸载/替换走 safelyDetachRef(:842),逻辑是一棵优先级从高到低的 if-else(骨架见 §7.3 的 ReactFiberCommitEffects.js:846)。

务必注意 finally 里那两行:调用完 cleanup 后,把 current.refCleanup 和 alternate.refCleanup 都置 null,目的是「prevent double invocation」------在 StrictMode 的双调用或并发打断重放中,防止同一个 cleanup 被执行两次。这类「用完即清」的写法在 React 内核里很常见。

useImperativeHandle 也同步升级(packages/react-reconciler/src/ReactFiberHooks.js:2817):

(imperativeHandleEffect 的完整代码见 §7.4 与 packages/react-reconciler/src/ReactFiberHooks.js:2817,此处不再重复。)

它本身就是一个 layout effect 的 create 函数,返回值(清理函数)被 effect 机制收走(§10),从而让「imperative handle 的 ref 清理」和「effect 清理」共用同一条通道。

15A.7 兼容矩阵与常见误用

写法 19.3.1 行为 源码落点
function C({ref}) {} ✅ 推荐,ref 是普通 prop updateFunctionComponent ReactFiberBeginWork.js:1529
forwardRef((p, ref) => ...) ✅ 兼容,多一次「摘 ref」 updateForwardRef ReactFiberBeginWork.js:427
element.ref 读取 ⚠️ DEV 报错,将来删除 ReactJSXElement.js:149
props.ref 读取 ✅ ReactJSXElement.js:175
对象 ref {current} ✅ 写 .current ReactFiberCommitEffects.js:821
函数 ref 返回 cleanup ✅ React 19 新增 ReactFiberCommitEffects.js:798
ref="string" ❌ DEV 报错 ReactFiberCommitEffects.js:810
forwardRef 上挂 defaultProps ❌ DEV 报错 ReactForwardRef.js:44

一个高频误区:「函数 ref 返回 cleanup」与「useEffect 返回 cleanup」虽然写法对称,但调用时机不同 。ref cleanup 在 safelyDetachRef(ref 被替换/卸载/解绑)时调用,属于 layout 阶段;effect cleanup 则按 useEffect(被动)或 useLayoutEffect(layout)各自的通道走。两者不要混为一谈(详见 §10.4)。


15B. Actions 全链路再剖析:FormData、串行队列与表单重置的提交时机

§3 给出了调用链的骨架。这一节把「骨」填满:事件插件到底怎么判断该不该阻止默认行为、FormData 在什么时候被构造、pending 状态怎么流到后代、以及 requestFormReset 为什么必须在 commit 的最后才执行。

15B.1 事件入口的完整判断

FormActionEventPlugin.extractEvents(packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:52)只在两个条件下介入:事件是 submit(:61),且 fiber 与原生 target 一致(:64):

js 复制代码
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:61
  if (domEventName !== 'submit') { return; }
  if (!maybeTargetInst || maybeTargetInst.stateNode !== nativeEventTarget) {
    // If we're inside a parent root that itself is a parent of this root, then
    // its deepest target won't be the actual form that's being submitted.
    return;
  }

然后解析 action,并处理「提交按钮覆盖表单 action」的规则:

js 复制代码
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:71
  let action = coerceFormActionProp(
    (getFiberCurrentPropsFromNode(form) as any).action,
  );
  let submitter = (nativeEvent as any).submitter;
  let submitterAction;
  if (submitter) {
    const submitterProps = getFiberCurrentPropsFromNode(submitter);
    submitterAction = submitterProps
      ? coerceFormActionProp((submitterProps as any).formAction)
      : (submitter.getAttribute('formAction') as any as string | null);
    if (submitterAction !== null) {
      // The submitter overrides the form action.
      action = submitterAction;
      submitter = null;
    }
  }

两个容易被忽略的点:

  1. 提交按钮的 formAction 会覆盖 <form action> (:84)------与 HTML 规范一致。
  2. 当提交按钮的 action 是函数时,submitter 被置 null(:89),这样 new FormData(form, submitter) 就不会带上按钮自身的 name=value:那是给服务器 判断「哪个按钮提交的」用的,客户端 action 不需要(ReactDOMComponent.js:182 的 DEV 警告印证了这点)。

15B.2 三种提交分支

submitForm(:101)的 if/else 就是全部策略:

js 复制代码
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:101
  function submitForm() {
    if (nativeEvent.defaultPrevented) {
      if (didCurrentEventScheduleTransition()) {
        // 只设 pending,不执行 action
        startHostTransition(formInst, pendingState, /* action */ null, new FormData(form, submitter));
      }
    } else if (typeof action === 'function') {
      // A form action was provided. Prevent native navigation.
      event.preventDefault();
      startHostTransition(formInst, pendingState, action, new FormData(form, submitter));
    } else {
      // 字符串 action:交给浏览器原生提交
    }
  }

于是可以精确归纳出三种情形:

  • 函数 action :React preventDefault(),走 transition,自动 pending。preventDefault 只发生在 action 是函数时。
  • 字符串 action:React 不介入,浏览器原生提交------这就是 MPA / 渐进增强路径。
  • 已被 preventDefault 但本次事件调度过 transition :只设 pending、传 action = null,兼容用户自己 startTransition 的写法。

FormData 只在这两处各构造一次,随后沿着 startHostTransition 一路传下去。pendingState 在 DEV 下被 Object.freeze(:117、:146),防止用户代码意外改动它。

还有一个公开入口 dispatchReplayedFormAction(:169)复用了同一套逻辑:它用于「表单在 hydration 期间被重放提交」的场景,把 pendingState({pending: true, data: formData, method: form.method, action})连同 formData 一并交给 startHostTransition(签名见 FormActionEventPlugin.js:169)。

15B.3 从 startHostTransition 到 startTransition

startHostTransition(ReactFiberHooks.js:3266)只做三件事:确保 form fiber「有状态」、取它的 state queue、用 startTransition 跑 action:

(startHostTransition 的完整代码见 §3.2 与 packages/react-reconciler/src/ReactFiberHooks.js:3266,此处不再重复。)

这里有一个值得纠正的常见误解:requestFormReset(formFiber) 是在 action 被调用之前就执行的 (:3303)。它做的事只是「排一次状态更新,把 resetState 换成新对象」,真正的 form.reset() 发生在 commit(15B.7)。所以「action 完成后自动清空」这个体感是对的,但 reset 的请求发生在更早。

15B.4 渲染期:form 是怎么「变成有状态组件」的

ensureFormComponentIsStateful(:3309)在 form fiber 上手工挂两条 hook:一条存 transition/pending 状态,一条存 resetState 这个空对象。两条 hook 同时写进 fiber.memoizedState 和 alternate.memoizedState(:3368、:3371),伪装成「这个 form 一直都有 hook」:

(ensureFormComponentIsStateful 的完整代码见 §3.3 与 packages/react-reconciler/src/ReactFiberHooks.js:3335,此处不再重复。)

渲染时,updateHostComponent 会检查 memoizedState(ReactFiberBeginWork.js:1996):

js 复制代码
// packages/react-reconciler/src/ReactFiberBeginWork.js:1996
  // 唯一会让 host fiber 变有状态的原因,就是 form action
  if (workInProgress.memoizedState !== null) {
    const newState = renderTransitionAwareHostComponentWithHooks(current, workInProgress, renderLanes);
    // isPrimaryRenderer ? HostTransitionContext._currentValue = newState
    //                    : HostTransitionContext._currentValue2 = newState
  }

而 renderTransitionAwareHostComponentWithHooks(ReactFiberHooks.js:859)只是把 TransitionAwareHostComponent(:874)当成一个普通函数组件来跑(renderWithHooks):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:874
export function TransitionAwareHostComponent(): TransitionStatus {
  const dispatcher: any = ReactSharedInternals.H;
  const [maybeThenable] = dispatcher.useState();
  // pending 是 thenable 时会悬挂
  const nextState = typeof maybeThenable.then === 'function'
    ? useThenable(maybeThenable) : maybeThenable;

  // The "reset state" is an object. If it changes, that means something
  // requested that we reset the form.
  const [nextResetState] = dispatcher.useState();
  const prevResetState = currentHook !== null ? currentHook.memoizedState : null;
  if (prevResetState !== nextResetState) {
    currentlyRenderingFiber.flags |= FormReset;
  }
  return nextState;
}

这段是整个表单特性的枢纽:

  1. pending 状态本质是一个 useState,用 useThenable 解包------所以异步 action 期间 form 会悬挂。
  2. reset 是一次 useState 的对象身份比较(prevResetState !== nextResetState),触发时打上 FormReset flag。

关于 FormReset 这个 flag 有个细节(packages/react-reconciler/src/ReactFiberFlags.js:50):

js 复制代码
// packages/react-reconciler/src/ReactFiberFlags.js:50
export const FormReset = Snapshot;

FormReset 复用了 Snapshot 位 。这在后面 commit 时有重要影响:正因为这个位是「一址多用」的,recursivelyResetForms 不能简单地靠 subtreeFlags & FormReset 判断子树里有没有 form(可能会误判),所以 React 用一个模块级变量 needsFormReset 单独记账(见 15B.7)。

15B.5 pending 如何流到后代:HostTransitionContext

pushHostContext(packages/react-reconciler/src/ReactFiberHostContext.js:96)在 form fiber 上把 memoizedState 当作 transition 状态推入 Context:

js 复制代码
// packages/react-reconciler/src/ReactFiberHostContext.js:96
function pushHostContext(fiber: Fiber): void {
  const stateHook: Hook | null = fiber.memoizedState;
  if (stateHook !== null) {
    // Propagate the current state to all the descendents.
    // We use Context as an implementation detail for this.
    const transitionStatus: TransitionStatus = stateHook.memoizedState;
    if (isPrimaryRenderer) {
      HostTransitionContext._currentValue = transitionStatus;
    } else {
      HostTransitionContext._currentValue2 = transitionStatus;
    }
    push(hostTransitionProviderCursor, fiber, fiber);
  }
}

popHostContext 时把值重置回 NotPendingTransition(:155)。这就是为什么 useFormStatus 只能读到「最近的、正在提交的那个 form」 ------它就是 Context 的就近语义;也解释了源码注释反复强调「form 不允许嵌套」(:150):因为 Context 只有一份 _currentValue,嵌套时会互相覆盖。

HostTransitionContext 本身在 ReactFiberConfigDOM.js:7013 被定义成一个「裸」的 Context 对象(注意它的 Provider/Consumer 都是 null,因为它只被内核通过 _currentValue 直接读写,从不走 JSX):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7012
export const NotPendingTransition: TransitionStatus = NotPending;
export const HostTransitionContext: ReactContext<TransitionStatus> = {
  $$typeof: REACT_CONTEXT_TYPE,
  Provider: null as any,
  Consumer: null as any,
  _currentValue: NotPendingTransition,
  _currentValue2: NotPendingTransition,
  _threadCount: 0,
};

这正好和 §9 的 <Context> 直接作 Provider 形成对照:createContext 造出来的 Context 里 Provider === context;而 HostTransitionContext 是手工拼的,Provider 留 null。

15B.6 requestFormReset 的两层三跳

requestFormReset 在源码里有两个同名函数,别混淆。

  1. react-dom-bindings 的公共门面 (packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:81):实现只有一行 ReactDOMSharedInternals.d.r(form),委托给当前渲染器注册的 r 方法。

  2. DOM 渲染器的实现 (packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5236):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5236
function requestFormReset(form: HTMLFormElement) {
  const formInst = getInstanceFromNodeDOMTree(form);
  if (formInst !== null && formInst.tag === HostComponent && formInst.type === 'form') {
    requestFormResetOnFiber(formInst);
  } else {
    // This form was either not rendered by this React renderer. Try the next one.
    previousDispatcher.r(/* requestFormReset */ form);
  }
}

这里有一个很实用的知识点 :当你调用公开的 ReactDOM.requestFormReset(formElement) 时,React 需要从 DOM 元素反查它属于哪个 React 树 (getInstanceFromNodeDOMTree)。如果这个 <form> 根本不是 React 渲染的,它会沿着渲染器链往外找------这是多渲染器(iframe / 微前端)共存时的兼容设计。

  1. 内核侧 (ReactFiberHooks.js:3377):先校验必须处于 transition(否则 DEV 报错 :3391),然后同样通过 ensureFormComponentIsStateful + dispatchSetStateInternal 把 resetState 换成 {}:

(requestFormReset 的完整代码见 §3.4 与 packages/react-reconciler/src/ReactFiberHooks.js:3377,此处不再重复。)

:3407 那段「Hack alert」很关键:如果传进来的 formFiber 是 workInProgress,它的 stateHook.next 可能是中间态 ,这时要去 alternate(当前树)上取 hook------这正是第 3 章双缓冲体系带来的「同一 fiber 两个版本」问题在内核里的具体表现。

15B.7 reset 为什么在 commit 的最后才执行

FormReset flag 在 mutation 阶段被收集(packages/react-reconciler/src/ReactFiberCommitWork.js:2316):

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:2316
        if (flags & FormReset) {
          needsFormReset = true;
          if (__DEV__) {
            if (finishedWork.type !== 'form') {
              console.error('Unexpected host component type. Expected a form. ...');
            }
          }
        }

真正执行在 HostRoot 分支处理完所有 mutation 之后 (:2407):

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:2407
      if (needsFormReset) {
        // A form component requested to be reset during this commit. We do this
        // after all mutations in the rest of the tree so that `defaultValue`
        // will already be updated. ...
        // Theoretically we could check finishedWork.subtreeFlags & FormReset,
        // but the FormReset bit is overloaded with other flags.
        needsFormReset = false;
        recursivelyResetForms(finishedWork);
      }

注释把原因写得非常清楚:必须在整棵树的所有 DOM 更新之后才 reset,这样 action 从服务端带回的新 defaultValue 已经写进了 DOM,reset 才能把输入框恢复到「新默认值」而不是「旧默认值」。这正是「用服务端返回的数据更新表单」这个常见模式的实现基础。

recursivelyResetForms(:2866)→ resetFormOnFiber(:2876)→ resetFormInstance:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:2866
function recursivelyResetForms(parentFiber: Fiber) {
  if (parentFiber.subtreeFlags & FormReset) {
    let child = parentFiber.child;
    while (child !== null) {
      resetFormOnFiber(child);
      child = child.sibling;
    }
  }
}

function resetFormOnFiber(fiber: Fiber) {
  recursivelyResetForms(fiber);
  if (fiber.tag === HostComponent && fiber.flags & FormReset) {
    resetFormInstance(fiber.stateNode);
  }
}

最终落到 packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7023:

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7022
export type FormInstance = HTMLFormElement;
export function resetFormInstance(form: FormInstance): void {
  ReactBrowserEventEmitterSetEnabled(true);
  form.reset();
  ReactBrowserEventEmitterSetEnabled(false);
}

这个「启用合成事件 → 调原生 form.reset() → 再关掉」的三明治值得咀嚼:form.reset() 会触发原生 reset 事件,React 临时开启事件系统只是为了让 onReset 监听器能收到 (第 10 章的 ReactBrowserEventEmitterSetEnabled)。React 没有为 reset 写自己的实现,它调的就是浏览器原生 form.reset() ------因此 reset 只对非受控表单域(defaultValue/defaultChecked)真正有意义;完全受控的 input 在 reset 后仍会被 state 覆盖回去。

15B.8 完整时序

sequenceDiagram participant U as 用户提交 participant P as FormActionEventPlugin participant T as startTransition participant C as commit U->>P: submit 事件 P->>P: coerceFormActionProp + submitter 覆盖;new FormData P->>T: startHostTransition(pending, action, formData) T->>T: dispatchOptimisticSetState(pending) // SyncLane T->>T: cb: requestFormReset(); action(formData) T->>T: 渲染期 HostTransitionContext._currentValue = pending T->>T: action 完成 → revertLane 回滚;resetState 变 → FormReset C->>C: HostRoot mutation 结束后 form.reset()

15C.1 三条 hook,各司其职

mountActionState(:2380)挂三条 hook,顺序不能乱:stateHook(存 action 返回值,可能是 thenable ,reducer 为 actionStateReducer,:2376)、pendingStateHook(mountStateImpl(false),pending 走乐观更新)、actionQueueHook(queue 位存共享的 ActionStateQueue,memoizedState 位暂存 action 以探测变化)。dispatch 由 dispatchActionState(:2106)bind 而成(:2448)。

15C.2 isPending 的真相

关键在 dispatchActionState(packages/react-reconciler/src/ReactFiberHooks.js:2142):

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2142
  const prevTransition = ReactSharedInternals.T;
  if (prevTransition !== null) {
    // Optimistically update the pending state, similar to useTransition.
    // This will be reverted automatically when all actions are finished.
    setPendingState(true);
    setState(actionNode);
  } else {
    actionNode.isTransition = false;
    setState(actionNode);
  }

三点必须记住:

  1. setPendingState(true) 只在 ReactSharedInternals.T !== null(处在 transition 中)时执行 ;T 就是「当前 transition 上下文」(第 8 章)。
  2. setPendingState = dispatchOptimisticSetState(fiber, false, pendingStateHook.queue)(:2426):isPending 不是普通 state,而是一个乐观更新。
  3. pending 的回滚不需要手动写 setPendingState(false) :revertLane 绑定在这次 transition 上(§4.5),transition 结束时 updateReducerImpl 跳过它,state 自动回 false。

由此可以回答一个高频疑问:「在 action 里 await 之后 isPending 还是 true 吗?」------是 。因为 isPending 的回落由 transition 的 revertLane 决定,而不是由你的 await 决定;只要 transition 没结束,它就是 true。同时因为 state 是 thenable(§5.4 用 useThenable 解包),渲染期读 state 会悬挂,最近的 Suspense 边界显示 fallback。

15C.3 串行队列与熔断

useActionState 的 action 签名是 (prevState, payload) => nextState,后一次必须拿到前一次的结果 ,所以多个 dispatch 不能并发。实现是一条环形链表(ActionStateQueueNode,:2084):

(环形链表入队代码见 §5.3 与 packages/react-reconciler/src/ReactFiberHooks.js:2158,此处不再重复。)

onActionSuccess(:2312)负责「弹出自己、推进下一个」:若 first === last(链表只剩一个节点)则 actionQueue.pending = null,否则 last.next = next 后 runActionStateAction(actionQueue, next)(:2338)。runActionStateAction(:2172)在 node.isTransition 为真时重建 transition 上下文 (fork startTransition,:2190),并用 onStartTransitionFinish 把这次 action 的 Promise 并入 transition 完成条件(:2217)。

错误则熔断(:2343):onActionError 把队列里所有节点标 rejected 并通知监听者,最后 actionQueue.action = null(:2362);于是后续 dispatchActionState 开头(:2118)if (currentAction === null) return; 会静默丢弃。恢复要靠 actionStateActionEffect(:2539)在 commit 时写回 actionQueue.action(仅在 action !== prevAction 时才 push,:2526)。

15C.4 permalink:客户端不消费、服务端才用

permalink 的结论必须精确:客户端 reconciler 里它全程不被读取 ------mountActionState(:2380)、updateActionStateImpl(:2481)、rerenderActionState(:2546)都只在签名里接收它;grep permalink packages/react-reconciler 只命中形参声明与 dispatcher 透传。真正消费它的是服务端 Fizz (packages/react-server/src/ReactFizzHooks.js:689):

js 复制代码
// packages/react-server/src/ReactFizzHooks.js:670
function createPostbackActionStateKey(
  permalink: string | void, componentKeyPath: KeyNode | null, hookIndex: number,
): string {
  if (permalink !== undefined) {
    // Don't bother to hash a permalink-based key since it's already short.
    return 'p' + permalink;
  } else {
    const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
    return 'k' + createFastHash(JSON.stringify(keyPath));
  }
}

并且在 $$FORM_ACTION 里直接改写表单提交地址 (:766):metadata.action = permalink。所以 permalink 的语义是:给服务端 Action 提供稳定的、可分享的提交 URL(MPA 渐进增强用) ,同时让 SSR 端据此生成/识别 postback 状态键。纯客户端 action 下这个参数没有任何运行时作用。


15D. useOptimistic 深解:每帧 rebase 与 revertLane 的精确语义

§4 已给出 mountOptimistic(:1974)、updateOptimisticImpl(:2013)、dispatchOptimisticSetState(:3728)。本节补三处最容易读错的地方。

15D.1 「每帧重置 baseState」与 rerender 路径

rerenderOptimistic(:2034)处理「渲染期更新导致的 re-render」,它的注释解释了为什么 useOptimistic 不能像 useState 那样用 forked rerender:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:2038
  // Unlike useState, useOptimistic doesn't support render phase updates.
  // Also unlike useState, we need to replay all pending updates again in case
  // the passthrough value changed.

即:因为 passthrough 可能每帧都变,所有 pending 的乐观更新必须在新 base 上重放 ,所以 rerender 复用 mount/update 的实现,而不是另写一套。配合 updateOptimisticImpl 的 hook.baseState = passthrough(:2025),就形成了「每帧 rebase」的完整语义。

15D.2 revertLane 的精确语义

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:3773
  // For regular Transitions an optimistic update commits synchronously.
  const lane = enableGestureTransition && transition !== null && transition.gesture
    ? GestureLane : SyncLane;
  const update: Update<S, A> = {
    lane: lane,
    // After committing, the optimistic update is "reverted" using the same
    // lane as the transition it's associated with.
    revertLane: requestTransitionLane(transition),
    ...
  };
  • lane = SyncLane:乐观更新同步提交,用户立刻看到结果。
  • revertLane = requestTransitionLane(transition):回滚车道取自当前 transition。

回滚判定在 updateReducerImpl(:1477):

(revertLane 跳过逻辑的完整分支见 §4.5 与 packages/react-reconciler/src/ReactFiberHooks.js:1477,此处不再重复。)

三个务必记住的点:

  1. 回滚不是 setState(旧值),而是「在 transition 完成的那次渲染里,不再应用这条更新」。
  2. 未回滚时会把更新克隆 进 baseQueue,lane: NoLane 但保留 revertLane------这是「将来还能被回滚」的凭证。
  3. 当 revertLane === peekEntangledActionLane() 时置 didReadFromEntangledAsyncAction,把「乐观更新回滚」与「等异步 action 完成」串起来(ReactFiberAsyncAction.js,第 8 章)。

15D.3 与 startTransition 的强制配对

dispatchOptimisticSetState 开头的 DEV 检查(:3736)区分了两种「没有 transition」的情况:

(这段 DEV 警告见 §4.6 与 packages/react-reconciler/src/ReactFiberHooks.js:3759,此处不再重复。)

  • 有挂起的 async action :不报警,React 会把这次乐观更新关联到该 action 的 lane------所以 await 之后再 addOptimistic 也是合法的。
  • 完全没有:报警。

另外 :3791 还处理渲染期调用:startTransition 在渲染期调用只警告(因为兼容性,:3796 注释明说这是 breaking change 才需要警告),而 addOptimistic(throwIfDuringRender = true)直接 throw new Error('Cannot update optimistic state while rendering.')------源码里明确区分了两种严格程度。


15E. use 原语深解:两条读路径、thenableState 重放与「条件调用」的源码根因

15E.1 两条读路径与 useContext 的差异

use(ReactFiberHooks.js:1159)只有三个分支:thenable → useThenable;REACT_RECOVERABLE_TYPE → 返回 undefined;REACT_CONTEXT_TYPE → readContext。所以 use(Context) 与 useContext(Context) 等价。差异在公开层:useContext(packages/react/src/ReactHooks.js:53)在 DEV 会拒绝 Context.Consumer:

js 复制代码
// packages/react/src/ReactHooks.js:53 ------ DEV 下拒绝把 Context.Consumer 传给 useContext
if (Context.$$typeof === REACT_CONSUMER_TYPE) {
  console.error('Calling useContext(Context.Consumer) is not supported and will cause bugs. ' +
    'Did you mean to call useContext(Context) instead?');
}
return dispatcher.useContext(Context);

use 不做这个检查,因为它压根不处理 REACT_CONSUMER_TYPE(会走到最后的 throw new Error('An unsupported type was passed to use()'),:1178)。

15E.2 thenableState 与「位置」索引

useThenable(:1098)用一个序号 定位 thenable(thenableIndexCounter,:287 定义),而不是 hook 链表:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1100
  const index = thenableIndexCounter;
  thenableIndexCounter += 1;
  if (thenableState === null) {
    thenableState = createThenableState();
  }
  const result = trackUsedThenable(
    thenableState, thenable, index,
    __DEV__ ? currentlyRenderingFiber : null,
  );

createThenableState(packages/react-reconciler/src/ReactFiberThenable.js:95)在 DEV 下是 {didWarnAboutUncachedPromise, thenables: []},生产下就是一个数组(:41 的 ThenableStateProd)。trackUsedThenable(:125)的核心:

js 复制代码
// packages/react-reconciler/src/ReactFiberThenable.js:134
  const trackedThenables = getThenablesFromState(thenableState);
  const previous = trackedThenables[index];
  if (previous === undefined) {
    trackedThenables.push(thenable);
  } else {
    if (previous !== thenable) {
      // Reuse the previous thenable, and drop the new one. We can assume
      // they represent the same value, because components are idempotent.
      ...
    }
  }

这一句「Reuse the previous thenable, and drop the new one 」就是「不要在组件里 use(fetch(...))」的源码根据:第二次渲染若拿到新的 Promise,React 会丢掉新 Promise、复用第一次的 ,于是你会一直悬挂或读到旧结果。DEV 还会给一次 uncached promise 警告(:145 起)。

状态机(:212)三种走向:fulfilled → 返回 thenable.value(同步解包,零成本 );rejected → throw thenable.reason;其他 → 打 status 为 pending、挂 then 监听器,然后抛 SuspenseException(:317、:338)。SuspenseException(:57)是一条伪装成 Error 的哨兵 ,专门用于中断渲染;SuspenseActionException(:72)是它的 useActionState 专用变体(§5.4)。

15E.3 重放与 dispatcher 回切

useThenable 后半段(:1112--:1155)处理的是:挂起后组件以 re-render dispatcher 被重放,若这次 use 之后还有 hook 没被处理过,就必须从 rerender dispatcher 切回 mount/update dispatcher,否则后面的 hook 会走错分支:

js 复制代码
// packages/react-reconciler/src/ReactFiberHooks.js:1126
  const nextWorkInProgressHook = workInProgressHook === null
    ? workInProgressFiber.memoizedState : workInProgressHook.next;
  if (nextWorkInProgressHook === null) {
    // 没有剩余 hook:退出 "re-render" 模式,把 dispatcher 切回 mount/update
    const currentFiber = workInProgressFiber.alternate;
    ReactSharedInternals.H = currentFiber === null || currentFiber.memoizedState === null
      ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
  }

15E.4 为什么 use 能在条件里调用

在源码层面有三条支撑,缺一不可:

  1. use 不往 hook 链表里写任何东西 :它不走 mountWorkInProgressHook/updateWorkInProgressHook,所以调用次数与位置变化不破坏「hook 顺序一致性」校验(第 7 章)。
  2. 它的「位置」由 thenableIndexCounter 单独维护 ,挂在 fiber 的 thenableState 上------只要「执行到的那些 thenable」按顺序一致即可。
  3. 重放时按 index 复用 (trackedThenables[index]),不依赖 hook 链表。

但别误读成「随便写都没问题」:19.3.1 里存在 enableConditionalUseWarning 与专属警告页 https://react.dev/warnings/conditional-use-of-use(ReactFiberThenable.js:334),也就是说------换个位置的「条件 use」是允许的,但「这次渲染调了、下次没调」仍会在 DEV 被记录并警告。

15E.5 客户端 vs 服务端的 use

服务端(packages/react-server)也导出 use,但实现完全不同:Fizz 的 use 服务于流式渲染,没有客户端意义上的「挂起-重放」。这正是本章要强调的「条件导出」概念------packages/react/src/ReactClient.js 与 ReactServer.js 的导出表并不相同:

  • 客户端 (ReactClient.js:76)导出全套 hook:useState/useEffect/useOptimistic/useActionState/useSyncExternalStore/useTransition... 外加 captureOwnerStack。
  • 服务端 (ReactServer.js:43)只导出 use、useId、useCallback、useDebugValue、useMemo 与 createElement/forwardRef/memo/cache 等;useState/useEffect 在 react-server 下根本不存在。
js 复制代码
// packages/react-server/src/ReactFizzHooks.js:662
function useOptimistic<S, A>(
  passthrough: S,
  reducer: ?(S, A) => S,
): [S, (A) => void] {
  resolveCurrentlyRenderingComponent();
  return [passthrough, unsupportedSetOptimisticState];
}

这些差异是「RSC 里不能写 useState」这条规则的源码落点,而不是什么约定俗成。


15F. 资源提示 API:preload / preinit / preconnect / preloadModule / preinitModule / prefetchDNS

这几个 API 在 19.3.1 确实存在 (不是传言也不是新提案),源码在 packages/react-dom/src/shared/ReactDOMFloat.js,公开入口是 react-dom(packages/react-dom/index.js:11 就导出了 prefetchDNS / preconnect / preload / preloadModule / preinit / preinitModule)。

15F.1 六个函数的签名与行号

| 函数 | 签名(节选) | 行号 |
|---------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|-----------------------|--------|
| prefetchDNS(href) | DNS 预取,只接受一个参数 | ReactDOMFloat.js:24 |
| preconnect(href, {crossOrigin}) | 预连接 | :58 |
| preload(href, {as, crossOrigin, integrity, nonce, type, fetchPriority, referrerPolicy, imageSrcSet, imageSizes, media}) | 预加载资源 | :89 |
| preloadModule(href, {as, crossOrigin, integrity, nonce, fetchPriority}) | 预加载 ES 模块 | :154 |
| `preinit(href, {as: 'style' | 'script', precedence, crossOrigin, integrity, fetchPriority, nonce})` | 预初始化(会真的插入并执行) | :211 |
| preinitModule(href, {as?: 'script', ...}) | 预初始化模块 | :268 |

15F.2 它们是「乐观的、静默失败」的

每个函数的末尾注释都写着同一句(ReactDOMFloat.js:53):

js 复制代码
// packages/react-dom/src/shared/ReactDOMFloat.js:53
  // We don't error because preconnect needs to be resilient to being called in a variety of scopes
  // and the runtime may not be capable of responding. The function is optimistic and not critical
  // so we favor silent bailout over warning or erroring.

所以在生产环境里,参数类型不对时它们什么都不做 ,不会抛错;只有 DEV 会 console.error 给出提示。这一设计理念和 preload 的定位一致:资源提示是「锦上添花」,绝不该因为一个拼错的 href 让整个应用崩掉。

15F.3 真正的落点:渲染器 dispatcher

这些函数本身只做参数校验 + 转发(ReactDOMFloat.js:123):

js 复制代码
// packages/react-dom/src/shared/ReactDOMFloat.js:123
    ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */
      .L(/* preload */ href, as, { ... });

d 这个 dispatcher 在 ReactFiberConfigDOM.js:5208 注册:

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5208
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */ = {
  f /* flushSyncWork */: disableLegacyMode ? flushSyncWork : previousDispatcher.f,
  r: requestFormReset,
  D /* prefetchDNS */: prefetchDNS,
  C /* preconnect */: preconnect,
  L /* preload */: preload,
  m /* preloadModule */: preloadModule,
  X /* preinitScript */: preinitScript,
  S /* preinitStyle */: preinitStyle,
  M /* preinitModuleScript */: preinitModuleScript,
};

注意 f 与 r 和资源函数注册在同一个对象上 :f 是 flushSync 走的 flushSyncWork(packages/react-dom/src/shared/ReactDOMFlushSync.js:39),r 是 requestFormReset(15B.6)。一张 dispatcher 表同时服务「同步刷新」「表单重置」「资源预加载」,这是渲染器无关接口(ReactFiberConfig)的一种轻量落地方式。

15F.4 去重:两张全局表

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5174
const preloadPropsMap: Map<string, PreloadProps | PreloadModuleProps> =
  new Map();
const preconnectsSet: Set<string> = new Set();

preload / preloadModule 用 Map 去重(同一 href 只发一次 <link rel="preload">),preconnect / prefetchDNS 用 Set 去重。这也解释了它们对外承诺的「幂等」------你可以在多个组件里重复调用 preload(href, {...}),不会产生重复标签。

15F.5 preinit 与文档元数据的同源性

preinit(href, {as:'style'}) 最终走到 preinitStyle,它与 §8 的 <link rel="stylesheet" precedence> 共用同一套插入逻辑 ------insertStylesheet(ReactFiberConfigDOM.js:6111):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:6111
function insertStylesheet(instance: Element, precedence: string, root: HoistableRoot): void {
  const nodes = root.querySelectorAll(
    'link[rel="stylesheet"][data-precedence],style[data-precedence]',
  );
  const last = nodes.length ? nodes[nodes.length - 1] : null;
  let prior = last;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    const nodePrecedence = node.dataset.precedence;
    if (nodePrecedence === precedence) { prior = node; }
    else if (prior !== last) { break; }
  }
  if (prior) {
    (prior.parentNode as any as Node).insertBefore(instance, prior.nextSibling);
  } else {
    const parent = root.nodeType === DOCUMENT_NODE
      ? ((root as any as Document).head as any as Element)
      : (root as any as ShadowRoot);
    parent.insertBefore(instance, parent.firstChild);
  }
}

这段代码定义了 precedence 的插入语义 :所有带 data-precedence 的样式节点构成「有序组」,同 precedence 的插在最后一个同组节点之后;找不到同名组就插到 head 最前面。这就是 React 19 里「CSS precedence」不是「比较名字大小」而是真实的 DOM 顺序的实现依据------顺序决定层叠(cascade),而不是名字。

stylesheetPropsFromRawProps(:5893)还做了一次属性改写:把 precedence 搬到 'data-precedence',并把原 precedence 置 null------这样它既能参与 DOM 选择器匹配(insertStylesheet 靠 data-precedence 找组),又不会被当作普通 HTML 属性透传到浏览器(precedence 不是标准属性)。

15G. captureOwnerStack 深解:owner 栈的来源与 DEV-only 语义

15G.1 公开面

captureOwnerStack 在客户端与服务端都导出(packages/react/src/ReactClient.js:136、ReactServer.js:66),实现只有十几行(packages/react/src/ReactOwnerStack.js:12):

js 复制代码
// packages/react/src/ReactOwnerStack.js:12
export function captureOwnerStack(): null | string {
  if (__DEV__) {
    const getCurrentStack = ReactSharedInternals.getCurrentStack;
    if (getCurrentStack === null) return null;
    // current stack 在这里就是 owner stack
    return getCurrentStack();
  }
  return null;
}

生产环境永远返回 null------它是个纯 DEV 工具。这一点必须说清楚,否则容易误以为可以在生产里用它做日志埋点。

15G.2 owner 栈从哪来

ReactSharedInternals.getCurrentStack 由渲染器设置。ReactCurrentFiber.js:84:

js 复制代码
// packages/react-reconciler/src/ReactCurrentFiber.js:84
export function setCurrentFiber(fiber: Fiber | null) {
  if (__DEV__) {
    ReactSharedInternals.getCurrentStack =
      fiber === null ? null : getCurrentFiberStackInDev;
    isRendering = false;
  }
  current = fiber;
}

而 getCurrentFiberStackInDev(:32)返回的正是 owner 栈 :它调用 getOwnerStackByFiberInDev(current)。current 是「当前正在渲染/提交的 fiber」,由 runWithFiberInDEV(:46)在调用用户代码前后 setCurrentFiber 维护。于是:

  • 在组件渲染期 调用 captureOwnerStack():current 是正在渲染的组件,拿到的是它的 owner 链(「谁创建了我」)。
  • 在 commit 的用户回调 (ref、effect)里:runWithFiberInDEV 会把 current 设成对应 fiber,同样能拿到 owner 栈。
  • 在渲染之外 (普通事件、定时器里):resetCurrentFiber(:76)已把 getCurrentStack 置回 null,因此返回 null。

15G.3 与 DevTools 的关系

owner 栈是 React DevTools 组件面板里「是谁渲染了我」那一栏 的数据来源,也是错误堆栈里 at Component (file:line) 帧的来源。captureOwnerStack 把这个内部能力开放给用户代码,让自定义校验、日志、测试断言能打印出同样风格的组件栈。它不依赖 DevTools 是否安装 ------只要在 DEV 构建里就能用;但在生产构建里它是死代码(__DEV__ 为 false 时直接被压缩掉)。


15H. 破坏性变更的源码证据(补强 §11)

§11 已给出 defaultProps / string ref / legacy context 的证据。这里补齐其余几项,并纠正一处常见的过度简化。

15H.1 legacy 模式整体关闭

js 复制代码
// packages/shared/ReactFeatureFlags.js:205
export const disableLegacyMode: boolean = true;

这是 19.3.1 里一条「一票否决」的开关。它的影响面在 beginWork 的 switch 里最清楚:IncompleteClassComponent(ReactFiberBeginWork.js:4416)与 IncompleteFunctionComponent(:4434)在 disableLegacyMode 为真时直接 break (落到报错),因为这两个 tag 只在 legacy 模式下使用。flushSync 也依赖它(ReactDOMFlushSync.js:61):legacy 构建里它被替换成直接抛错的版本。

15H.2 ReactDOM.render / unmountComponentAtNode / findDOMNode

诚实的源码结论分两半:

  • 公开 react-dom 入口已不再导出它们 (导出列表只剩 browser / createPortal / flushSync / prefetchDNS / preconnect / preload / preloadModule / preinit / preinitModule / requestFormReset / useFormState / useFormStatus / version),调用 ReactDOM.render(...) 得到的是 TypeError。
  • 带报错文案的实现只在 FB 变体 (ReactDOMRootFB.js:396 / :440):ReactDOM.render was removed in React 19... 与 unmountComponentAtNode was removed in React 19...。
js 复制代码
// packages/react-dom/src/client/ReactDOMRootFB.js:396
        'ReactDOM.render was removed in React 19. Use createRoot instead.',
// packages/react-dom/src/client/ReactDOMRootFB.js:440
        'unmountComponentAtNode was removed in React 19. Use root.unmount() instead.',

findDOMNode 在新代码里也只挂在 internals 上 (ReactDOMClient.js:43 定义,:50 处 Internals.findDOMNode = findDOMNode;),并不作为公开 API 导出。

所以准确表述是 :「React 19 移除了这些 API」------对 OSS 构建而言,是从导出表里删除;FB 构建里额外给了一条报错文案。不要写成「调用时会抛 'was removed in React 19'」------OSS 构建不会,那是 FB 变体的行为。

15H.3 ReactDOMServer.renderToNodeStream

renderToNodeStream / renderToStaticNodeStream 在整个仓库里搜不到实现 (grep 只命中 prerenderToNodeStream 这类新名字)。19.3.1 的流式方案是 renderToReadableStream(Web Stream)与 prerenderToNodeStream / resumeAndPrerenderToNodeStream(Node)。renderToString / renderToStaticMarkup 仍保留在 legacy server 入口(ReactDOMLegacyServerBrowser.js:42),但不支持 Suspense。

15H.4 react-test-renderer 弃用

packages/react-test-renderer/README.md 第一行就是 # react-test-renderer (DEPRECATED)。运行时警告在 packages/react-test-renderer/src/ReactTestRenderer.js:486:

js 复制代码
// packages/react-test-renderer/src/ReactTestRenderer.js:486
      console.error(
        'react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer',
      );

而且 react-test-renderer/shallow 已被硬删除(packages/react-test-renderer/npm/shallow.js):构造 ReactShallowRenderer 直接 throw ... has been removed。官方推荐迁移到 @testing-library/react(见 README)。

15H.5 需要纠正的一条:createElement 仍会解析 defaultProps

§11.2 说「函数组件的 defaultProps 已移除」。这句话在协调器 层面是对的(updateFunctionComponent 不再调 resolveDefaultProps),但有一个容易被忽略的例外 ------createElement 自己仍然会合并 type.defaultProps(ReactJSXElement.js:703):

js 复制代码
// packages/react/src/jsx/ReactJSXElement.js:703
  // Resolve default props
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

这段是无条件的。所以更精确的结论是:

  • 用 <C />(JSX 运行时 → jsxProd)且 C 是函数组件:defaultProps 不会 被 jsxProd 处理(jsxProd 里没有这段),也不会被协调器处理 → 失效。
  • 用 React.createElement(C) 手工调用:defaultProps 仍会被合并 (因为这段在 createElement 里)。
  • 类组件的 defaultProps 两条路径都保留(协调器里走 resolveClassComponentProps,ReactFiberClassComponent.js:1204)。

另外,isSimpleFunctionComponent(ReactFiber.js:308)里有一句 type.defaultProps === undefined------defaultProps 的存在仍会影响 fiber 的 tag 选择 (带 defaultProps 的函数组件不会走 SimpleMemoComponent 快路径)。


16. 源码索引表(本章关键文件 + 函数 + 行号速查)

文件 函数 / 符号 行号 作用
packages/react/src/ReactHooks.js use 207 公开导出 use
packages/react/src/ReactHooks.js useOptimistic 226 公开导出
packages/react/src/ReactHooks.js useActionState 234 公开导出(源码别名 useFormState)
packages/react/src/ReactContext.js createContext 35 context.Provider = context 关键一行
packages/react/src/ReactForwardRef.js forwardRef 12 仍可用,向后兼容
packages/react-reconciler/src/ReactFiberHooks.js use 1159 use 分派:thenable/context/recoverable
packages/react-reconciler/src/ReactFiberHooks.js useThenable 1098 挂起读取 thenable + 重放
packages/react-reconciler/src/ReactFiberHooks.js thenableIndexCounter/thenableState 287 挂起位置追踪
packages/react-reconciler/src/ReactFiberHooks.js mountOptimistic 1974 乐观更新挂载
packages/react-reconciler/src/ReactFiberHooks.js updateOptimisticImpl 2013 每帧 baseState=passthrough
packages/react-reconciler/src/ReactFiberHooks.js ActionStateQueue/Node 2068/2084 串行队列结构
packages/react-reconciler/src/ReactFiberHooks.js dispatchActionState 2106 入队 + 熔断检查
packages/react-reconciler/src/ReactFiberHooks.js runActionStateAction 2172 执行 action(重建 transition)
packages/react-reconciler/src/ReactFiberHooks.js handleActionReturnValue 2270 监听 thenable 结果
packages/react-reconciler/src/ReactFiberHooks.js onActionSuccess/onActionError 2312/2343 成功推进队列 / 错误熔断
packages/react-reconciler/src/ReactFiberHooks.js mountActionState 2380 三钩子组合
packages/react-reconciler/src/ReactFiberHooks.js updateActionStateImpl 2481 渲染期 useThenable 解包
packages/react-reconciler/src/ReactFiberHooks.js startTransition 3117 内核 transition(支持 async thenable)
packages/react-reconciler/src/ReactFiberHooks.js startHostTransition 3266 表单提交即 transition
packages/react-reconciler/src/ReactFiberHooks.js ensureFormComponentIsStateful 3309 给 host fiber 注入 hook
packages/react-reconciler/src/ReactFiberHooks.js requestFormReset 3377 表单自动重置(须 transition 内)
packages/react-reconciler/src/ReactFiberHooks.js mountTransition 3425 useTransition 挂载
packages/react-reconciler/src/ReactFiberHooks.js useHostTransitionStatus 3473 读 HostTransitionContext
packages/react-reconciler/src/ReactFiberHooks.js dispatchOptimisticSetState 3728 乐观更新 + revertLane
packages/react-reconciler/src/ReactFiberHooks.js updateReducerImpl(revertLane) 1482 回滚跳过逻辑
packages/react-reconciler/src/ReactFiberHooks.js imperativeHandleEffect 2817 ref 清理函数支持
packages/react-reconciler/src/ReactFiberAsyncAction.js entangleAsyncAction 71 并发 async action 纠缠
packages/react-reconciler/src/ReactFiberAsyncAction.js peekEntangledActionLane 189 当前纠缠车道
packages/react-reconciler/src/ReactFiberBeginWork.js updateForwardRef 404 摘 ref 兼容处理
packages/react-reconciler/src/ReactFiberBeginWork.js updateFunctionComponent 1453 函数组件无 defaultProps 解析
packages/react-reconciler/src/ReactFiberCommitEffects.js commitAttachRef 757 挂载 ref + refCleanup
packages/react-reconciler/src/ReactFiberCommitEffects.js safelyDetachRef 842 调用 refCleanup
packages/react-reconciler/src/ReactFiberCommitEffects.js effect destroy 校验 188/204 清理函数须为函数、禁 Promise
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js useFormStatus 67 实为 useHostTransitionStatus
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js requestFormReset 81 委托给 ReactDOMCurrentDispatcher
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js coerceFormActionProp 27 action 函数/URL 区分
packages/react-dom-bindings/src/client/ReactDOMComponent.js SingletonType 1498 html/head/body 单例
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js head 匹配/启发式 4133/4978 元数据提升与 hydration
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js clearHead 4042 head 清理
packages/react/src/jsx/ReactJSXElement.js elementRefGetterWithDeprecationWarning 143 element.ref 读取即报错(15A.3)
packages/react/src/jsx/ReactJSXElement.js createElement 合并 defaultProps 703 手工 createElement 仍解析 defaultProps(15H.5)
packages/react-reconciler/src/ReactFiber.js isSimpleFunctionComponent 308 type.defaultProps === undefined 影响 tag(15H.5)
packages/react-reconciler/src/ReactFiberCommitWork.js recursivelyResetForms / resetFormOnFiber 2866/2876 全树 DOM 更新后再重置(15B.7)
packages/react-reconciler/src/ReactFiberFlags.js FormReset = Snapshot 50 flag 复用(一址多用)(15B.4)
packages/react-reconciler/src/ReactFiberHostContext.js pushHostContext 96 form 状态压入 HostTransitionContext(15B.5)
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js requestFormReset 5236 从 DOM 反查 fiber 再委托(15B.6)
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js resetFormInstance 7023 三明治包裹原生 form.reset()(15B.7)
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js insertStylesheet 6111 data-precedence 决定真实 DOM 顺序(15F.5)
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js preloadPropsMap / preconnectsSet 5174 资源去重两张全局表(15F.4)
packages/react-dom/src/shared/ReactDOMFloat.js preconnect / preload / preinit 58/89/211 资源提示公开实现(15F.1)
packages/react-reconciler/src/ReactFiberHooks.js rerenderOptimistic 2038 每帧 rebase 重放(15D.1)
packages/react-reconciler/src/ReactFiberThenable.js trackUsedThenable 复用旧 thenable 134 「禁止 use(fetch())」的根据(15E.2)
packages/react-server/src/ReactFizzHooks.js createPostbackActionStateKey 670 permalink 仅服务端消费(15C.4)
packages/react/src/ReactOwnerStack.js captureOwnerStack 12 DEV-only owner 栈(15G.1)
packages/react-reconciler/src/ReactCurrentFiber.js setCurrentFiber 84 owner 栈来源(15G.2)

17. 自测 FAQ

Q1:useActionState 和 useFormState 是什么关系? A:同一个实现的两个名字。源码(ReactFiberHooks.js:4839 起)的 dispatcher 表里,useFormState 和 useActionState 都映射到 mountActionState。useFormState 是早期命名,useActionState 是 React 19 正式公开名。

Q2:useOptimistic 的"自动回滚"是真的 setState(旧值) 吗? A:不是。它给乐观更新打上 revertLane(:3783),当绑定 transition 完成时,updateReducerImpl 在 :1482 通过 isSubsetOfLanes(renderLanes, revertLane) 判定"该更新作废"并 continue 跳过------即"不应用"而非"主动还原"。这是用车道做时间锚点的优雅设计。

Q3:为什么 useOptimistic 的 addOptimistic 必须包 transition? A:因为回滚依赖 revertLane 精确关联某个 transition 的完成时机(:3737)。无 transition 时 revertLane 落到默认车道,回滚时机不可预期,DEV 会报错。

Q4:useActionState 会让界面"卡住"吗? A:action 未完成时,useThenable 会让组件挂起(:2506),最近的 <Suspense> 显示 fallback。这是刻意设计。若不想悬挂,用 useTransition + 手动 state 替代,或就近包 Suspense。

Q5:React 19 支持 async function useEffect() 吗? A:不支持 。源码(:204)明确拦截"返回 Promise 的清理函数"并报警。effect 仍须同步,异步逻辑应写在 effect 内部立即调用的 async 函数里。

Q6:函数式 ref 返回清理函数是什么? A:React 19 新增。函数 ref (node) => { ...; return () => cleanup } 的返回值会被存入 fiber.refCleanup(:798),在 ref 替换/卸载时调用(:846)。这与 effect 清理函数概念对称,但是 React 19 新增能力(effect 清理一直都有)。

Q7:<Context> 直接作 Provider 是怎么实现的? A:createContext 里 context.Provider = context(:35)。两者是同一引用,协调器对 REACT_CONTEXT_TYPE 走同一套 Provider 逻辑,与写 .Provider 完全等价。

Q8:string ref 在 React 19 还能用吗? A:不能。源码(:810)DEV 下 console.error('String refs are no longer supported.')。

Q9:函数组件 defaultProps 还能用吗? A:不能。React 19 移除。updateFunctionComponent(:1453)不再解析 Component.defaultProps,nextProps 原样传入。请用默认参数/解构默认值。

Q10:use(promise) 能用来发请求吗? A:不能。每次渲染创建新 promise 会导致无限挂起/重渲染(见 13.6)。use 只适合读取"已存在且稳定"的 promise(如 RSC 传入的)。

Q11:<form action={fn}> 提交后为什么表单会自动清空? A:startHostTransition(:3304)在 action 返回后调用 requestFormReset(formFiber),复用底层更新机制重置表单字段。

Q12:requestFormReset 为什么必须在 transition/action 内调用? A:同乐观更新(:3378 起),脱离 transition 作用域时 DEV 报错,因为 reset 的更新车道需要关联当前 transition 才能保证时序正确。

Q13:多个 useActionState 的 dispatch 并发会乱序吗? A:不会。ActionStateQueue 是环形链表串行队列(:2084、:2158),前一个完成才跑下一个,保证 (prevState, payload) => nextState 的顺序语义。

Q14:某个 action 抛错会怎样? A:队列熔断。onActionError(:2343)把所有后续节点标 rejected 并置 actionQueue.action = null;之后 dispatchActionState(:2118)检测到 null 直接 return,后续 dispatch 被丢弃。

Q15:文档元数据 <title> 写在 <div> 里为何跑到 <head>? A:React 19 把 html/head/body 视为宿主单例(:1498),元数据节点在 commit 阶段被提升(HostHoistable)插入 document.head(:4978),hydration 时用启发式匹配避免重复(:4133)。

Q16:<Context> 与 <Context.Provider> 完全等价吗? A:等价。createContext 内部执行 context.Provider = context(ReactContext.js:35),二者是同一引用。协调器对 REACT_CONTEXT_TYPE 走同一套 Provider 逻辑(ReactFiberBeginWork.js:3719 的 updateContextProvider),没有第二份实现。这也是为什么 19 可以直接写 <Theme value={...}>。

Q17:forwardRef 组件里的 defaultProps 还有效吗? A:无效,且 DEV 会报错。ReactForwardRef.js:42 在 __DEV__ 下检查 render.defaultProps != null 并报警。这与函数组件 defaultProps 失效同源(§11.2、15H.5):请改用参数默认值。

Q18:preload(href, opts) 重复调用会产生多个 <link> 吗? A:不会。渲染器用两张全局表去重------preloadPropsMap(Map,键为 href)管 preload/preloadModule,preconnectsSet(Set)管 preconnect/prefetchDNS(ReactFiberConfigDOM.js:5174)。因此这些 API 对外承诺幂等:同 href 只发一次标签。

Q19:preinit(href, {as:'style'}) 与渲染 <link rel="stylesheet" precedence> 有什么区别? A:底层同一套插入逻辑(都走 insertStylesheet,ReactFiberConfigDOM.js:6111),但 preinit 是命令式 的:不渲染任何 React 节点,直接在 document.head 插入并立即加载/执行 (ReactDOMFloat.js:211),因此更适合「不在 JSX 渲染树里表达」的资源;<link precedence> 则是声明式、参与 HostHoistable 提升(§8)。

Q20:captureOwnerStack() 能在生产环境里拿来做错误上报吗? A:不能。它只在 __DEV__ 为真时可用,生产构建里直接 return null 并被压缩成死代码(ReactOwnerStack.js:12)。它面向的是开发期自定义校验、日志与测试断言;生产环境的错误栈请依赖渲染器本身。


18. 本章要点小结

  1. React 19 的 Action 特性是既有内核的封装:全部建立在 Transition(第 7/8 章)、Lane、Suspense(第 9 章)、Fiber 协调(第 4 章)之上,没有发明新引擎。
  2. <form action> = 自动包 transition :FormActionEventPlugin → startHostTransition → startTransition,并提交后自动 reset。
  3. useOptimistic 回滚靠 revertLane :每帧把 baseState 重置为 passthrough(:2025),transition 完成时 updateReducerImpl 跳过该更新(:1482)实现"自动回滚"。
  4. useActionState = 三钩子 + 串行队列 + use 解包 :state 以 thenable 存储,渲染期 useThenable 悬挂;队列保证顺序,错误熔断。
  5. use 统一读取 Context 与 thenable :useContext 是它的特化别名;挂起重放机制(:1124)保证 hook 顺序一致。
  6. ref 成为普通 prop :updateForwardRef 仅做兼容摘取(:427);commitAttachRef 支持 ref 清理函数(:798),String ref 移除(:810)。
  7. 文档元数据自动提升 :head 等宿主单例 + HostHoistable + 启发式匹配。
  8. <Context> 即 Provider :context.Provider = context(:35)。
  9. effect 清理函数一直都有,且仍须同步 ;异步 effect 仍被拦截(:204);新增的是 ref 清理函数。
  10. 破坏性变更 :函数组件 defaultProps 移除、String ref 移除、legacy Context 移除------都在源码中有明确落点。

本章所有行号均取自 react-19.3.1 的 19.3.1 版本。若你阅读的版本不同,请以实际文件为准;凡与官方文档或社区传言冲突处,本章以源码为准。

相关推荐
wflynn1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
git·开源·go·github·agent·coding-agents
donk13141 小时前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly2 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制
前端·vue.js·开源
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令
前端·vue.js·开源
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译
前端·vue.js·开源