React 19 内核探秘
第8章 React 19 新 Hooks 与 API
第8章 React 19 新 Hooks 与 API
本章要点
- use Hook 的革命性设计:在条件语句和循环中调用的第一个 Hook
- useActionState 的工作机制:表单状态与异步 Action 的桥梁
- useFormStatus 的实现原理:跨组件读取表单提交状态
- useOptimistic 的乐观更新模型:即时反馈与最终一致性
- Actions 的内核机制:Transition 与异步函数的融合
- ref 作为 prop 的变革:forwardRef 的终结
- React 19 API 变更的源码级解读
React 19 是自 Hooks 引入以来最重大的一次 API 更新。它不仅增加了 use、useActionState、useFormStatus、useOptimistic 等新 Hook,还从根本上改变了 React 处理异步操作和表单交互的方式。
如果说 React 16.8 的 Hooks 解决了"函数组件如何拥有状态"的问题,那么 React 19 的新 API 则瞄准了一个更大的目标:如何优雅地处理数据变更(mutation)。在此之前,React 一直擅长的是数据展示——从 state 到 UI 的单向流动。而数据的写入、提交、乐观更新等操作,长期以来都需要开发者自行搭建脚手架。React 19 将这些模式内建到了框架核心中。
8.1 use:打破 Hook 规则的 Hook
use 是 React 19 引入的最具颠覆性的 API。它打破了 Hooks 系统自诞生以来最核心的规则——它可以在条件语句和循环中被调用。
8.1.1 use 的两种模式
use 有两种截然不同的使用方式:读取 Promise 和读取 Context。
// 模式 1:读取 Promise
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
// 如果 Promise 还没 resolve,会挂起组件(触发 Suspense)
const comments = use(commentsPromise);
return comments.map(c => <p key={c.id}>{c.body}</p>);
}
// 模式 2:读取 Context(可以在条件语句中)
function ThemeButton({ showIcon }: { showIcon: boolean }) {
if (showIcon) {
const theme = use(ThemeContext); // ✅ 在条件中调用 use
return <Icon color={theme.primary} />;
}
return <button>Click me</button>;
}
8.1.2 use(Promise) 的内核实现
当 use 接收一个 Promise 时,它的行为与 Suspense 紧密耦合:
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof (usable as Thenable<T>).then === 'function') {
// Promise 路径
const thenable = (usable as Thenable<T>);
return useThenable(thenable);
} else if ((usable as ReactContext<T>).$$typeof === REACT_CONTEXT_TYPE) {
// Context 路径
const context = (usable as ReactContext<T>);
return readContext(context);
}
}
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}
useThenable 是核心实现,它实现了"同步化异步"的魔法:
// ReactFiberHooks.js(v19.0.0,精简)
function useThenable<T>(thenable: Thenable<T>): T {
// 用独立计数器记录"这是本次渲染第几个 use"——不是 Hook 链表
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
// pending 时会在这里 throw(见 §8.1.5);fulfilled 时同步返回值
const result = trackUsedThenable(thenableState, thenable, index);
// 挂起后重放组件用的是 "re-render" dispatcher。
// 如果这个 use 之后还有上次没走到的 Hook,就要切回 mount / update dispatcher
const nextWorkInProgressHook =
workInProgressHook === null
? currentlyRenderingFiber.memoizedState
: workInProgressHook.next;
if (nextWorkInProgressHook === null) {
const currentFiber = currentlyRenderingFiber.alternate;
ReactSharedInternals.H =
currentFiber === null || currentFiber.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}
return result;
}
// ReactFiberThenable.js(v19.0.0,精简:DEV 警告与 shellSuspendCounter 检查见 §8.1.6 / §8.1.7)
function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number
): T {
const trackedThenables = getThenablesFromState(thenableState);
const previous = trackedThenables[index];
if (previous === undefined) {
// 第一次在这个位置遇到 thenable
trackedThenables.push(thenable);
} else if (previous !== thenable) {
// 同一位置拿到了新的 Promise:丢掉新的、沿用旧的(组件被假定幂等)
thenable.then(noop, noop); // 给被丢弃的 Promise 挂空监听,避免 unhandled rejection
thenable = previous;
}
// 用 expando 属性 status / value / reason 追踪结果,才能"同步解包"
switch (thenable.status) {
case 'fulfilled':
return thenable.value;
case 'rejected':
throw thenable.reason;
default: {
if (typeof thenable.status === 'string') {
// 已被别人(如 Flight)instrument 过、仍在 pending:只挂个空监听
thenable.then(noop, noop);
} else {
// 没见过的原生 Promise:打上 pending 标记,settle 时回写 status
const pendingThenable = thenable as PendingThenable<T>;
pendingThenable.status = 'pending';
pendingThenable.then(
(fulfilledValue) => {
if (thenable.status === 'pending') {
const fulfilledThenable = thenable as FulfilledThenable<T>;
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = fulfilledValue;
}
},
(error) => {
if (thenable.status === 'pending') {
const rejectedThenable = thenable as RejectedThenable<T>;
rejectedThenable.status = 'rejected';
rejectedThenable.reason = error;
}
}
);
}
// 再查一次:可能同步 resolve 了
switch (thenable.status) {
case 'fulfilled': return thenable.value;
case 'rejected': throw thenable.reason;
}
// 仍在 pending:挂起。注意真实抛出的是 SuspenseException 而不是 thenable 本身,
// thenable 通过模块级变量 suspendedThenable 交给 work loop(见 §8.1.5)
suspendedThenable = thenable;
throw SuspenseException;
}
}
}
sequenceDiagram
participant C as Component
participant U as use(promise)
participant S as Suspense Boundary
participant R as React Scheduler
C->>U: use(fetchComments())
U->>U: 检查 promise.status
alt status === 'pending'
U-->>S: throw SuspenseException(挂起,thenable 存入 suspendedThenable)
S->>S: 显示 fallback
Note over R: Promise resolve 后
R->>C: 重新渲染组件
C->>U: use(同一个 promise)
U->>U: status === 'fulfilled'
U-->>C: 返回解析值
else status === 'fulfilled'
U-->>C: 直接返回值
end
图 8-1:use(Promise) 的挂起与恢复流程
8.1.3 为什么 use 可以在条件语句中调用
传统 Hook 不能在条件语句中调用,因为它们依赖 Hook 链表的顺序索引(见第 7 章 7.9 节)。use 之所以能突破这个限制,是因为它使用了完全不同的存储机制:
// 传统 Hook:通过链表顺序索引
// Hook 1 → Hook 2 → Hook 3
// 每次渲染必须以相同顺序遍历链表
// use(Context):直接读取 Context 的 _currentValue
// 不需要链表,不依赖调用顺序
// use(Promise):通过 thenableState 数组 + index 追踪
// index 基于 use 在当前渲染中的调用计数,而不是所有 Hook 的调用计数
对于 use(Context),它直接调用 readContext,与 useContext 的实现完全相同——readContext 本身就不依赖 Hook 链表(见第 7 章 7.8 节)。
对于 use(Promise),它使用独立的 thenableState 数组和 thenableIndexCounter,与 Hook 链表是分离的追踪系统。这意味着即使 use 在条件分支中被调用了不同次数,也不会影响 Hook 链表的完整性。
8.1.4 readContext:use(Context) 为什么不用链表
上面讲"use(Context) 不依赖 Hook 链表顺序"时跳过了一个问题:React 到底怎么追踪"这个 fiber 订阅了哪些 context"的? 答案在 ReactFiberNewContext.js 的 readContextForConsumer,值得展开。
这个函数的核心结构是两步:第一步读值——直接访问 context._currentValue(或 _currentValue2 作为副 renderer 备份);第二步登记订阅——把 context 作为一个 contextItem 追加到当前 fiber 的 dependencies 链表上:
// React 19.0.0:readContextForConsumer 已无 lastFullyObservedContext 短路
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2;
const contextItem = {
context,
memoizedValue: value,
next: null,
};
// ...追加到 consumer.dependencies.firstContext
历史上 React 18 曾在 readContextForConsumer 里留过 lastFullyObservedContext === context 短路——看起来像"同一 context 只登记一次"的去重,但那是给未上线的 context selectors 预留的死分支(lastFullyObservedContext 从未被赋过非 null 值,短路永不触发)。React 19 已删除该变量及相关短路,路径一律走"追加 contextItem"。实际行为仍是:同一次渲染里 N 次 useContext(Ctx)/use(Ctx) 会往 dependencies 链表追加 N 条重复的 contextItem——无害,只是 propagation 时多比较几次。订阅条目存在 fiber.dependencies 链表上,而不是 hook 链表上——这才是 use(Context) 能在 if 分支里调用的根本原因。
这也解释了 §8.1.3 提到的"即使 use 在不同分支调用次数不同也不影响"为什么成立——dependencies 链表每次 render 都从头重建(prepareToReadContext 清空 firstContext),不做任何位置匹配:这一帧读了哪些 context 就登记哪些,条数、顺序都无所谓。Hook 链表按序号寻址(易受条件影响),context dependencies 按"本帧重建 + 顺序无关"组织(天然支持条件)——两种索引策略的差别决定了能不能在 if 里调用。
8.1.5 use 抛出的不是 Promise 本身——SuspenseException 的真实机制
很多教学材料把 use 的挂起写成 "throw thenable"。真实 React 19 的实现要精细一级——8.1.2 节代码的最后一行是 throw SuspenseException。打开 packages/react-reconciler/src/ReactFiberThenable.js,能看到一个模块级常量:
// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
// detect this is caught by userspace, we'll log a warning in development.
export const SuspenseException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `use` to interrupt the current render. You must either ' +
'rethrow it immediately, or move the `use` call outside of the ' +
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
);
真实抛出的不是 thenable,而是这个固定的 SuspenseException Error 实例;被挂起的 thenable 另外被存进一个模块级变量 suspendedThenable,等工作循环通过 getSuspendedThenable() 取走:
// ReactFiberThenable.js:trackUsedThenable 的 default 分支结尾
// Suspend.
// Throwing here is an implementation detail that allows us to unwind the
// call stack. But we shouldn't allow it to leak into userspace. Throw an
// opaque placeholder value instead of the actual thenable. If it doesn't
// get captured by the work loop, log a warning, because that means
// something in userspace must have caught it.
suspendedThenable = thenable;
if (__DEV__) {
needsToResetSuspendedThenableDEV = true;
}
throw SuspenseException;
为什么要拆成"抛出 SuspenseException + 侧信道存 thenable"两步?这段注释讲得很清楚——"抛出"只是借用 JS 的异常机制实现栈展开(unwind),它是"如何回到最近的 Suspense 边界"的实现细节,不应该泄漏到用户空间。若真把 thenable 直接抛出去,用户写 try { use(p) } catch (e) { ... } 时就会拿到一个 Promise 对象,语义上怪异且难解释;而抛出一个带自描述消息的 Error,哪怕被错误捕获了,报错也会告诉开发者"请让它重新抛出或者把 use 移出 try/catch"——这是一种工程温度。
这个设计与第 9 章(并发模式)讲的 Fiber work loop 的 throwException 路径是同一脉络:React 把所有"用异常栈展开到边界"的地方都做成可识别的常量(SuspenseException、SuspenseyCommitException、还有 noopSuspenseyCommitThenable),render 阶段 work loop 的 handleThrow 与 throwException 按引用比对这些常量,决定挂起原因以及是否显示 fallback(SuspenseyCommitException 由 completeWork 里的 suspendCommit 抛出)。如果抛的是业务层自己的 Error,走 Error Boundary 路径;抛的是 SuspenseException,走 Suspense 路径——识别位不是靠 try/catch,而是靠引用相等。
8.1.6 shellSuspendCounter > 100:async Client Component 的红线
trackUsedThenable 里还藏着一个对前端工程师极有价值的保护:在 Shell(即上方没有任何 Suspense 边界的部分)同步渲染路径下,如果一个 thenable 从来没被缓存、导致 root 反复 ping-suspend 超过 100 次,React 会直接抛错阻止死循环。
// ReactFiberThenable.js
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
const root = getWorkInProgressRoot();
if (root !== null && root.shellSuspendCounter > 100) {
// This root has suspended repeatedly in the shell without making any
// progress (i.e. committing something). This is highly suggestive of
// an infinite ping loop, often caused by an accidental Async Client
// Component.
//
// During a transition, we can suspend the work loop until the promise
// to resolve, but this is a sync render, so that's not an option. We
// also can't show a fallback, because none was provided. So our last
// resort is to throw an error.
throw new Error(
'async/await is not yet supported in Client Components, only ' +
'Server Components. This error is often caused by accidentally ' +
"adding `'use client'` to a module that was originally written " +
'for the server.',
);
}
这个 shellSuspendCounter > 100 的判定对应了一个典型踩坑:开发者把一个 Server Component(里面用了顶层 await fetch(...))错误地标了 'use client',结果每次渲染都是一个新的、未缓存的 Promise——Promise 一 resolve 就触发重渲染、重渲染又制造新 Promise——ping 循环就此成立。如果没有这个红线,浏览器会静默挂起。React 19 用 shellSuspendCounter 数到 100 就果断抛错,并在错误信息里直接点名"可能是误加了 'use client'"——这是框架为用户预埋的诊断线索。
第 9 章会展开并发渲染与 use/Suspense 的协作:Transition 下 React 可以真的停下来等 Promise,而不是立刻切 fallback。这里的关键区别是 "sync render vs transition"——同步路径(shell 初始渲染)没有等待余地,才需要计数器保护;而第 8.2 节后面讲的 async Action 发生在 Transition 中,允许挂起直到 resolve。"是否允许等待"是同步/并发模式的核心分水岭。
8.1.7 trackUsedThenable 的幂等去重:组件必须是 idempotent 的
源码中 trackUsedThenable 开头还有一段看似不起眼、实则定义了一条契约的逻辑:
// ReactFiberThenable.js(省略 DEV 下的 uncached promise 警告)
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.
// ...(DEV 警告省略)
// Avoid an unhandled rejection errors for the Promises that we'll
// intentionally ignore.
thenable.then(noop, noop);
thenable = previous;
}
}
这段代码告诉我们一条 React 19 隐藏的心智模型:同一个位置的 use(promise) 在重复渲染时若拿到两个不同的 Promise 实例,React 会扔掉新的、保留旧的,理由是"组件是幂等的,两个 Promise 代表同一个值"。这解释了为什么官方文档在讲 use(Promise) 时反复强调"Promise 不能在组件内直接创建"——不是规范如此,而是 trackUsedThenable 的这个假设成立需要你把 Promise 放在组件外(或用缓存包裹)。
注意 thenable.then(noop, noop) 这一行不是多余的:React 扔掉的那个 Promise 如果是 rejected 且从未被 .then 挂过监听,Node.js 会报 unhandledRejection;React 这里显式挂空监听,让被丢弃的 Promise 即便出错也不会污染宿主环境。这是一种"礼貌丢弃"——只用一行 thenable.then(noop, noop) 保住宿主的清洁。
8.2 Actions:异步 Transition 的进化
React 19 引入了 Actions 概念——将异步函数传递给 startTransition,使其成为一个可追踪状态的"Action"。
8.2.1 Transition 的异步扩展
在 React 18 中,startTransition 只接受同步回调。React 19 扩展了它的能力:
// React 18:只能同步
startTransition(() => {
setSearchQuery(input); // 同步状态更新
});
// React 19:支持异步函数(Action)
startTransition(async () => {
const data = await submitForm(formData); // 异步操作
setResult(data); // 异步完成后更新状态
});
内核实现中,当 startTransition 检测到回调返回了 Promise 时,React 会追踪这个 Promise 的生命周期:
// ReactFiberHooks.js(v19.0.0,精简:省略 enableAsyncActions=false 的旧分支与 DEV 统计)
function startTransition<S>(
fiber: Fiber,
queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
pendingState: S,
finishedState: S,
callback: () => mixed,
options?: StartTransitionOptions
): void {
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority)
);
// v19 公共路径:ReactSharedInternals.T(React 18 叫 ReactCurrentBatchConfig.transition)
const prevTransition = ReactSharedInternals.T;
const currentTransition: BatchConfigTransition = {};
ReactSharedInternals.T = currentTransition;
// 先把 isPending 置 true——注意走的是乐观更新:它的 revertLane 与下面
// finishedState 更新的 lane 是同一条 transition lane(源码注释:避免两者行为意外分叉)
dispatchOptimisticSetState(fiber, false, queue, pendingState);
try {
const returnValue = callback();
// ReactSharedInternals.S:reconciler 在 ReactFiberTransition.js 注册的回调,
// 若返回值是 thenable,就在这里被 entangleAsyncAction 纠缠进 async action scope(§8.2.3)
const onStartTransitionFinish = ReactSharedInternals.S;
if (onStartTransitionFinish !== null) {
onStartTransitionFinish(currentTransition, returnValue);
}
if (
returnValue !== null &&
typeof returnValue === 'object' &&
typeof returnValue.then === 'function'
) {
// 🔑 这是一个 async Action:把 finishedState 包成一个 thenable 塞进 state 队列。
// 渲染时 useTransition 用 use 的算法解包它——scope 没结束前会挂起(§8.4.5)
const thenableForFinishedState = chainThenableValue(
returnValue as Thenable<mixed>,
finishedState
);
dispatchSetStateInternal(fiber, queue, thenableForFinishedState, requestUpdateLane(fiber));
} else {
// 同步 Action,直接标记完成
dispatchSetStateInternal(fiber, queue, finishedState, requestUpdateLane(fiber));
}
} catch (error) {
// Action 抛错:塞进一个 rejected thenable,让 useTransition 在 render 时把错误重新抛出
const rejectedThenable: RejectedThenable<S> = {
then() {},
status: 'rejected',
reason: error,
};
dispatchSetStateInternal(fiber, queue, rejectedThenable, requestUpdateLane(fiber));
} finally {
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}
8.2.2 Action 的状态追踪
Actions 的核心价值在于自动追踪异步操作的生命周期。useTransition 返回的 isPending 在 React 19 中被增强为能感知异步 Action 的状态:
function SubmitButton() {
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
// isPending 自动变为 true
await saveToServer(data);
// isPending 自动变回 false
});
};
return (
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? '提交中...' : '提交'}
</button>
);
}
stateDiagram-v2
[*] --> Idle: 初始状态
Idle --> Pending: startTransition(async fn)
Pending --> Idle: Promise resolve
Pending --> Rethrown: Promise reject
Rethrown --> [*]: 渲染时重新抛出,交给最近的 Error Boundary
state Pending {
[*] --> AsyncRunning
AsyncRunning --> WaitingResolve: await 异步操作
WaitingResolve --> AsyncRunning: 继续执行
}
图 8-2:Action 的状态生命周期
注意 reject 之后并不存在一个可以"重新提交"的错误态:v19 会把被拒绝的结果作为 thenable 交给 useTransition 的状态,渲染该组件时 useThenable 把错误重新抛出,由最近的 Error Boundary 接管(startTransition 里同步抛出的错误也被包成 status: 'rejected' 的 thenable 走同一条路,见 ReactFiberHooks.js 的 startTransition)。想在界面上展示错误并允许重试,要在 Action 内部自己 try/catch 并把错误写进状态,或改用 useActionState 返回错误信息。
8.2.3 entangled action scope:多个并发 Action 为什么被绑在一起
上面 8.2.1 节代码注释里提到的 entangleAsyncAction 不是虚构——它就是 React 19.0 正式版 packages/react-reconciler/src/ReactFiberAsyncAction.js 里的导出函数真名(由 ReactFiberTransition.js 注册到 ReactSharedInternals.S 的 onStartTransitionFinishForReconciler 调用)(2023 年的 canary 里这套逻辑曾叫 requestAsyncActionContext,19.0 发布前重构改名)。这个文件只有 161 行,但它解释了 React 19 Actions 最反直觉的一个行为:同时启动的多个 async Action 会被 React 绑在同一个"纠缠范围(entangled scope)"里,共用一条 TransitionLane。
文件开头的注释把设计动机说得很直白:
// If there are multiple, concurrent async actions, they are entangled. All
// transition updates that occur while the async action is still in progress
// are treated as part of the action.
//
// The ideal behavior would be to treat each async function as an independent
// action. However, without a mechanism like AsyncContext, we can't tell which
// action an update corresponds to. So instead, we entangle them all into one.
翻译成人话:React 做不到知道"这个 setState 属于哪个 async 函数",所以退而求其次——所有在 async scope 打开期间发生的 transition 更新都算在同一个 scope 里,一损俱损、一荣俱荣。 这是个由 JavaScript 语言本身缺陷(没有像 Node.js AsyncLocalStorage 那样的原生 AsyncContext)倒逼的设计。
scope 由四个模块级变量组成:
// The listeners to notify once the entangled scope completes.
let currentEntangledListeners: Array<() => mixed> | null = null;
// The number of pending async actions in the entangled scope.
let currentEntangledPendingCount: number = 0;
// The transition lane shared by all updates in the entangled scope.
let currentEntangledLane: Lane = NoLane;
// A thenable that resolves when the entangled scope completes. It does not
// resolve to a particular value because it's only used for suspending the UI
// until the async action scope has completed.
let currentEntangledActionThenable: Thenable<void> | null = null;
工作流程用文字一图流:
- 第一个 async Action 启动:
currentEntangledListeners === null,React 建一个新 scope,调用requestTransitionLane(transition)要一条专属 lane,currentEntangledPendingCount从 0 涨到 1。 - 第二个 async Action 在第一个还没结束时启动:
currentEntangledListeners !== null,直接复用已有 scope 和 lane,currentEntangledPendingCount涨到 2。 - 每个 Action 的 Promise resolve/reject:调用
pingEngtangledActionScope()(源码里就这么拼,Engtangled是个留到 19.0 的 typo——见packages/react-reconciler/src/ReactFiberAsyncAction.js:70与 L74 的函数定义),--currentEntangledPendingCount。 - 当 pending 计数归零时:把
currentEntangledActionThenable的 status 一次性 flip 成 fulfilled、重置currentEntangledListeners = null; currentEntangledLane = NoLane,然后遍历所有 listeners 通知它们。
最后一步是关键——currentEntangledActionThenable 头顶的注释写得很直白:
// A thenable that resolves when the entangled scope completes. It does not
// resolve to a particular value because it's only used for suspending the UI
// until the async action scope has completed.
也就是说,React 拿来挂起 UI 的不是每个 Action 自己的 Promise,而是整个 scope 共享的这个 thenable——即便 Action A 的 Promise 已经 resolve,只要同一个 scope 里的 Action B 还在 pending,计数不归零、scope thenable 就不 resolve。从用户视角看,就是"isPending 要等所有并发 Action 全部完成才变回 false"。
这个设计的工程代价和工程收益各一:
- 代价:用户写
startTransition(async () => { await A(); })和startTransition(async () => { await B(); })两个并行 Action,本来以为是独立的进度条,结果变成了"两个都完事 isPending 才为 false"——和直觉不符。 - 收益:UI 不会陷入"先看见 A 的新数据、再看见 B 的新数据的闪烁"。因为两个 Action 共享同一条 TransitionLane,它们的 commit 会被 React 合并到同一次渲染。这是一种"牺牲独立性换一致性"的选择。
8.2.4 chainThenableValue:注释里的"小作弊"
同一个文件里的 chainThenableValue(8.2.1 伪代码里用它把 Action 的 Promise 和 finishedState 串起来)有一段绝妙的注释:
// packages/react-reconciler/src/ReactFiberAsyncAction.js:107
export function chainThenableValue<T>(
thenable: Thenable<T>,
result: T,
): Thenable<T> {
// Equivalent to: Promise.resolve(thenable).then(() => result), except we can
// cheat a bit since we know that that this thenable is only ever consumed
// by React.
//
// We don't technically require promise support on the client yet, hence this
// extra code.
const listeners = [];
const thenableWithOverride: Thenable<T> = {
status: 'pending',
value: null,
reason: null,
then(resolve: T => mixed) {
listeners.push(resolve);
},
};
// ...thenable.then 里手动 flip status、遍历 listeners
return thenableWithOverride;
}
同样的手法也出现在 entangleAsyncAction 内联创建的 entangledThenable 上——then(resolve) 直接把 resolve 塞进 listeners 数组,status 字段由 React 自己手动 instrument。注释说的 "cheat a bit" 说明 React 心知肚明这不完全符合 Promises/A+ 的严谨语义,但敢这么写是因为这些 thenable 不会被用户代码拿到,只被 React 自己的 useThenable/trackUsedThenable 消费——在封闭系统里,规范可以被理解成"契约的上限"而非"契约的下限"。
reject 分支还有一处更狠的:源码注释直言 "This is a perf hack where we call the onFulfill ping function instead of onReject"——因为 React 给 then 的 onFulfill/onReject 传的是同一个 listener、且会直接从 .reason 字段读错误,所以 reject 时干脆复用 fulfill 的 ping。把责任推给消费者、省下生产者的重复逻辑,这是 React 源码中反复出现的性能工程风格。
这个小作弊和 reconciler 里多处"空对象 / 哨兵对象全局共享"是一个套路:当你完全掌控读写两端时,可以放弃中间契约的严谨性换 CPU。业务代码里少见这种技巧,因为两端通常不在同一个人手里;框架源码里常见,因为写的人和用的人是同一个。
8.3 useActionState:表单状态管理的内核
useActionState 是 React 19 为表单场景设计的专用 Hook,它将表单的提交动作与状态管理合二为一。
8.3.1 API 设计与使用模式
// useActionState 的类型签名(payload 是泛型 P,不写死 FormData)
// form action 场景下 P 通常是 FormData;手动 dispatch 时可传任意 P
function useActionState<State, P>(
action: (prevState: State, payload: P) => State | Promise<State>,
initialState: State,
permalink?: string
): [state: State, dispatch: (payload: P) => void, isPending: boolean];
// 实际使用示例
interface FormState {
message: string;
errors: Record<string, string>;
}
async function createTodo(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const title = formData.get('title') as string;
if (!title.trim()) {
return {
message: '',
errors: { title: '标题不能为空' },
};
}
try {
await saveTodoToServer({ title });
return { message: '创建成功!', errors: {} };
} catch (e) {
return { message: '保存失败', errors: { _form: String(e) } };
}
}
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, {
message: '',
errors: {},
});
return (
<form action={formAction}>
<input name="title" disabled={isPending} />
{state.errors.title && <span className="error">{state.errors.title}</span>}
<button type="submit" disabled={isPending}>
{isPending ? '创建中...' : '创建'}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
8.3.2 内核实现
高度简化说明:下面按 React 19.0.0
ReactFiberHooks.js的真实结构讲解。历史上若见「一个mountState+ 立刻action()+dispatchSetState」的伪实现,那是教学压缩,不是 v19 路径。
真实 mountActionState 连续挂三个 Hook:
- state hook——
actionStateReducer(恒等:新 state 直接替换)+dispatchSetState;dispatch 时就把 actionNode(一个 thenable)setState 进去,render 时用use的算法解包,action 完成前会挂起; - pending hook——
mountStateImpl(false),但 pending 的 setter 绑的是dispatchOptimisticSetState(乐观更新,类似 transition pending),不是普通dispatchSetState; - actionQueue hook——挂
ActionStateQueue(state/dispatch/action/ 环形pending),dispatch绑dispatchActionState;memoizedState存当前action引用,用来检测 action 函数是否变化,变了就在 passive effect 里更新actionQueue.action。
// packages/react-reconciler/src/ReactFiberHooks.js:2046(React 19.0.0,结构示意)
type ActionStateQueue<S, P> = {
state: Awaited<S>; // 最近一次 action 成功后的 state
dispatch: Dispatch<P>;
action: ((Awaited<S>, P) => S) | null; // null 表示曾出错,后续 action 不再跑
pending: ActionStateQueueNode<S, P> | null; // 环形串行队列
};
function mountActionState<S, P>(
action: (Awaited<S>, P) => S,
initialStateProp: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
// (省略:激活时从 root.formState 取 SSR 初始值的分支)
// ① state:标准 update queue + dispatchSetState
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = stateHook.baseState = initialStateProp;
const stateQueue = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: actionStateReducer,
lastRenderedState: initialStateProp,
};
stateHook.queue = stateQueue;
const setState = dispatchSetState.bind(null, currentlyRenderingFiber, stateQueue);
stateQueue.dispatch = setState;
// ② pending:乐观 set(dispatchOptimisticSetState)
const pendingStateHook = mountStateImpl(false);
const setPendingState = dispatchOptimisticSetState.bind(
null,
currentlyRenderingFiber,
false,
pendingStateHook.queue,
);
// ③ actionQueue:串行执行队列(与普通 state queue 不同)
const actionQueueHook = mountWorkInProgressHook();
const actionQueue: ActionStateQueue<S, P> = {
state: initialStateProp,
dispatch: null as any,
action,
pending: null,
};
actionQueueHook.queue = actionQueue;
const dispatch = dispatchActionState.bind(
null,
currentlyRenderingFiber,
actionQueue,
setPendingState,
setState,
);
actionQueue.dispatch = dispatch;
actionQueueHook.memoizedState = action;
return [initialStateProp, dispatch, false];
}
function actionStateReducer<S>(oldState: S, newState: S): S {
return newState;
}
用户调用 dispatch(payload) 时,不会立刻裸跑 action 再 dispatchSetState,而是入队 + 串行执行:
// packages/react-reconciler/src/ReactFiberHooks.js:2084
function dispatchActionState<S, P>(
fiber: Fiber,
actionQueue: ActionStateQueue<S, P>,
setPendingState: (boolean) => void,
setState: Dispatch<ActionStateQueueNode<S, P>>,
payload: P,
): void {
if (isRenderPhaseUpdate(fiber)) {
throw new Error('Cannot update form state while rendering.');
}
// 曾 onActionError → actionQueue.action === null,后续 dispatch 直接 return
const currentAction = actionQueue.action;
if (currentAction === null) return;
const actionNode = { /* payload, action, thenable 字段, isTransition, ... */ };
// 若在 transition 内:乐观 setPending(true),并把 actionNode thenable setState 出去
// v19:ReactSharedInternals.T(旧称 ReactCurrentBatchConfig.transition)
const prevTransition = ReactSharedInternals.T;
if (prevTransition !== null) {
setPendingState(true);
setState(actionNode);
} else {
actionNode.isTransition = false;
setState(actionNode);
}
// 环形队列:空则立刻 run,否则挂到队尾(串行)
const last = actionQueue.pending;
if (last === null) {
actionNode.next = actionQueue.pending = actionNode;
runActionStateAction(actionQueue, actionNode);
} else {
const first = last.next;
actionNode.next = first;
actionQueue.pending = last.next = actionNode;
}
}
function runActionStateAction<S, P>(actionQueue, node) {
// 用「dispatch 当时」快照的 node.action,不是最新 actionQueue.action
// transition 路径会临时设 ReactSharedInternals.T 再调 action
try {
const returnValue = node.action(actionQueue.state, node.payload);
handleActionReturnValue(actionQueue, node, returnValue);
// thenable → onActionSuccess 再 run 下一个;同步则直接 success
} catch (error) {
onActionError(actionQueue, node, error);
// 清空 pending 环、全部 reject、actionQueue.action = null
}
}
要点:
- 三 Hook 分工:state / pending(optimistic) / actionQueue,不是「一个 state + 手动 pending」。
- 环形串行
runActionStateAction:每个 action 收到的prevState是上一次成功后的actionQueue.state;队列保证顺序。 onActionError:失败后actionQueue.action = null,后续 dispatch 全部 no-op;不是简单isPending = false。- Transition 上下文:
ReactSharedInternals.T(勿再写ReactCurrentBatchConfig.transition当 v19 真名)。
graph TD
A["用户提交 / dispatch(payload)"] --> B["dispatchActionState 入队"]
B --> C{"actionQueue.action === null?"}
C -->|是 曾出错| Z["直接 return"]
C -->|否| D["挂到环形 pending"]
D --> E{"队列原本为空?"}
E -->|是| F["runActionStateAction"]
E -->|否| G["等前一个 onActionSuccess 再 run"]
F --> H{"返回 Promise?"}
H -->|是| I["then → onActionSuccess / onActionError"]
H -->|否| J["onActionSuccess"]
I --> K["更新 actionQueue.state,通知 actionNode 的 listeners"]
J --> K
K --> L["弹出节点,串行跑下一个"]
I -->|reject| M["onActionError:清空队列 action=null"]
style F fill:#FFD93D
style M fill:#FF6B6B
style K fill:#6BCB77
图 8-3:useActionState 的执行流程(v19 串行队列)
8.4 useFormStatus:跨组件的表单状态共享
useFormStatus 解决了一个常见的痛点:表单内的子组件如何知道表单当前是否在提交中。
8.4.1 使用模式
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
// 自动读取最近的 <form> 祖先的提交状态
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
function MyForm() {
const [state, formAction] = useActionState(handleSubmit, initialState);
return (
<form action={formAction}>
<input name="email" type="email" />
{/* SubmitButton 自动感知 form 的提交状态 */}
<SubmitButton />
</form>
);
}
8.4.2 实现原理:HostContext 与 Fiber 树
useFormStatus 的实现依赖 React DOM 的 Host Context 机制。当 <form> 元素处于提交状态时,React 会在对应的 Fiber 子树中传播一个"form status" context:
// react-dom 内部(教学示意——真名与真实默认值见 §8.4.3)
type FormStatusState = {
pending: boolean;
data: FormData | null;
method: string;
action: string | ((formData: FormData) => void | Promise<void>) | null;
};
// 全局 form status context
const FormContext: ReactContext<FormStatusState> = createContext({
pending: false,
data: null,
method: null,
action: null,
});
function useFormStatus(): FormStatusState {
const context = useContext(FormContext);
return context;
}
当表单通过 Action 提交时,React DOM 会更新 FormContext 的值:
function handleFormAction(
formFiber: Fiber,
action: (formData: FormData) => void | Promise<void>,
formData: FormData
) {
// 更新 FormContext,标记 pending
const formStatus: FormStatusState = {
pending: true,
data: formData,
method: 'POST',
action: action,
};
// 通过 Context Provider 传播到子树
updateFormStatus(formFiber, formStatus);
// 执行 action
startTransition(async () => {
try {
await action(formData);
} finally {
// 重置 form status
updateFormStatus(formFiber, {
pending: false,
data: null,
method: null,
action: null,
});
}
});
}
这种设计的精妙之处在于:子组件不需要接收任何 props 就能知道表单的状态。这是 Context 模式在框架内部的一个典型应用——将"最近的 form 祖先"作为隐式的 Provider。
8.4.3 源码核对:真名是 HostTransitionContext,不是 FormContext
上面为了快速讲清楚故意使用了"FormContext"这个称呼。打开 React 19 的真实源码,这个 context 的真名是 HostTransitionContext——它定义在 DOM host config(packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js),经 ReactFiberConfig 再被 reconciler 侧的 ReactFiberHostContext.js 导入使用;栈游标(hostTransitionProviderCursor)仍在 HostContext 模块里维护,但 context 对象本身不在那里创建:
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:3577(React 19.0.0)
export const NotPendingTransition: TransitionStatus = NotPending;
export const HostTransitionContext: ReactContext<TransitionStatus> = {
$$typeof: REACT_CONTEXT_TYPE,
Provider: (null: any), // 注意这里是 null
Consumer: (null: any), // 用户永远不应该手动使用
_currentValue: NotPendingTransition, // 默认就是 NotPending,不是 null
_currentValue2: NotPendingTransition,
_threadCount: 0,
};
几个值得细看的设计决策:
1、它是个特殊的 Context,没有公开 Provider/Consumer。 普通 Context 通过 createContext 初始化时会填入 Provider 和 Consumer 函数组件,用户可以手写 <MyContext.Provider>。而 HostTransitionContext 的 Provider/Consumer 都是 null——这个 context 不允许业务代码写成 JSX,它只被 React 内部的 fiber 写入。这是一种"内部协议借用了 Context 基础设施但不开放 API"的设计。
2、"forms cannot be nested" 的假设打开了优化空间。 HTML 规范禁止 <form> 嵌套,所以整棵 fiber 树里最多只有一层活跃的 host transition。ReactFiberHostContext.js 维护注释明确说明:技术上不一定需要栈,也可以用单个模块变量表示当前状态。注释没有交代为何仍用栈游标(StackCursor),从代码看,它与同文件里其他 HostContext 游标走同一套 push/pop,省得做特判。这是一种"不必要的一般性换来的结构对称"。
3、useFormStatus 不是直接 useContext(HostTransitionContext)。 真实的 useFormStatus 在 packages/react-dom-bindings/src/shared/ReactDOMFormActions.js,只做了一次转发;React 19.0.0 的 feature gate 只剩 enableAsyncActions(enableFormActions 已退出这条路径):
export function useFormStatus(): FormStatus {
if (!enableAsyncActions) {
throw new Error('Not implemented.');
} else {
const dispatcher = resolveDispatcher();
return dispatcher.useHostTransitionStatus();
}
}
dispatcher.useHostTransitionStatus() 的真实实现在 ReactFiberHooks.js:
function useHostTransitionStatus(): TransitionStatus {
if (!enableAsyncActions) {
throw new Error('Not implemented.');
}
// 默认值已是 NotPendingTransition,无需再做 null 兜底
return readContext(HostTransitionContext);
}
NotPending / NotPendingTransition 在 ReactDOMFormActions.js 里是一个全局共享的 frozen 对象:
// Since the "not pending" value is always the same, we can reuse the
// same object across all transitions.
const sharedNotPendingObject = {
pending: false,
data: null,
method: null, // 注意:不是 'GET'
action: null,
};
export const NotPending: FormStatus = __DEV__
? Object.freeze(sharedNotPendingObject)
: sharedNotPendingObject;
这里有两个容易被忽略的小工艺:
- method 默认值是
null而不是'GET'。 很多二手资料都把默认值写成 GET,那是把 HTML<form method>属性的默认值当成了 React 的运行时默认值。React 的语义是"没有正在进行的提交"——用 null 比用 GET 诚实,避免下游代码误以为"method: 'GET' 代表正在发 GET 请求"。 - 全局共享一个 not-pending 对象。 每次组件读取
useFormStatus,当没有进行中的提交时拿到的是同一个对象引用——NotPending本身没有对外导出,但前后两次读到的 status 可以直接用===/Object.is判定"没变"(比如作为useMemo依赖),走引用等价而不是结构等价。对高频调用的 Hook,这是能省掉 GC 压力的实打实优化。
8.4.4 startHostTransition:给 form 元素"追认"两个 Hook
<form action={...}> 提交时,React 要让这个 DOM 元素本身有"pending / not pending"的状态(这样 useFormStatus 才能读到)。问题来了——DOM 元素对应的是 HostComponent fiber,它本来是无状态的(没有 Hook 链表)。React 19 怎么解决这个看似矛盾的需求?
React 19.0.0 把升级逻辑抽到 ensureFormComponentIsStateful:startHostTransition 先调用它拿到 transition state hook,再 startTransition(...)。升级时一次创建两个 hook——transition 状态 + form-reset 状态:
// packages/react-reconciler/src/ReactFiberHooks.js:3201(React 19.0.0,简化)
function ensureFormComponentIsStateful(formFiber: Fiber) {
const existingStateHook = formFiber.memoizedState;
if (existingStateHook !== null) {
// 已经升级过,直接复用
return existingStateHook;
}
// Upgrade this host component fiber to be stateful. We're going to pretend
// it was stateful all along so we can reuse most of the implementation
// for function components and useTransition.
const stateHook: Hook = {
memoizedState: NoPendingHostTransition,
baseState: NoPendingHostTransition,
baseQueue: null,
queue: newQueue, // lastRenderedState = NoPendingHostTransition
next: null,
};
// 第二个 hook:跟踪 form 是否需要 reset
// state 是空对象;要触发 reset 就换成新对象,渲染时 diff 到变化后打 FormReset flag
const initialResetState = {};
const resetStateHook: Hook = {
memoizedState: initialResetState,
baseState: initialResetState,
baseQueue: null,
queue: newResetStateQueue,
next: null,
};
stateHook.next = resetStateHook;
// 双写 current / alternate,假装 fiber 一直 stateful
formFiber.memoizedState = stateHook;
const alternate = formFiber.alternate;
if (alternate !== null) {
alternate.memoizedState = stateHook;
}
return stateHook;
}
这段代码做的事有一个极生动的描述——"追认":
- 用户写
<form action={submitHandler}>,React 一开始把它当成普通 HostComponent fiber 挂载,没有 Hook 链表。 - 用户第一次提交表单,
startHostTransition→ensureFormComponentIsStateful。 - 若
formFiber.memoizedState === null,现场构造 transition state + form-reset 两个 Hook,串成链表,塞进formFiber.memoizedState与alternate.memoizedState。 - 注释 "pretend it was stateful all along":假装这个 fiber 一直都是 stateful 的——典型的"以后可以改历史"。
注意两个技术细节:
把 Hook 同时装进 current 和 alternate。普通 function component 的 Hook 只挂在 WIP fiber 上,commit 后才同步到 current。这里跳过这个流程,直接双写——因为这是一个"从外部插入"的 Hook(不是由 render 函数调用产生),必须立刻让两棵树都能看到,否则下一次渲染读取 hook 链表时会不一致。
"intentionally not create a bound dispatch method"。两个 hook 的 queue 都故意不绑 dispatch:
// We're going to cheat and intentionally not create a bound dispatch
// method, because we can call it directly in startTransition.
dispatch: (null: any),
正常 mountState 会 dispatch = dispatchSetState.bind(null, fiber, queue),这里不绑是因为 startHostTransition / requestFormReset 会手动调 startTransition 或 dispatchSetStateInternal,没人需要拿着 dispatch 去触发。省一次 bind 调用,也省一个 Function 对象的分配——源码注释给出的理由只是"反正会在 startTransition 里直接调用",谈不上性能关键,更多是不做无用功。
requestFormReset 本身也是 react-dom 导出的公开 API:requestFormReset(form) 接收一个 <form> DOM 元素,在当前 Transition / Action 里排一次表单重置,随这次更新一起提交。它应当在 Action 或 startTransition 内调用,否则开发环境会报 "requestFormReset was called outside a transition or action"(packages/react-reconciler/src/ReactFiberHooks.js:3269)。
8.4.5 isPending 是 boolean | Thenable:useTransition 为什么能 Suspend
前面讲 useTransition 返回的 [isPending, start],这个 isPending 按直觉应该是 boolean——要么在 pending,要么不在。但打开 updateTransition 和 rerenderTransition,你会发现真实类型签名是:
// packages/react-reconciler/src/ReactFiberHooks.js:3320
function updateTransition(): [
boolean,
(callback: () => void, options?: StartTransitionOptions) => void,
] {
const [booleanOrThenable] = updateState(false); // ← 注意 "booleanOrThenable"
const hook = updateWorkInProgressHook();
const start = hook.memoizedState;
const isPending =
typeof booleanOrThenable === 'boolean'
? booleanOrThenable
: // This will suspend until the async action scope has finished.
useThenable(booleanOrThenable);
return [isPending, start];
}
updateState(false) 读出来的值叫 booleanOrThenable——也就是说 useTransition 的 pending state 里既可能存布尔值,也可能存一个 Thenable。当它是 Thenable 时,React 用 useThenable(booleanOrThenable) 把它同步化——这行代码上方那句注释写得极其重要:
// This will suspend until the async action scope has finished.
翻译:当一个组件读取自己的 useTransition 并且当前处于 async action scope 中时,这次读取会触发 Suspense。这颠覆了直觉上"isPending 只是个 boolean flag"的心智模型。
为什么要这么设计?把 8.2.3 节讲的 entangled scope 和这里的 useThenable 连起来看就明白了:
- 多个 async Action 被绑在同一个 entangled scope。
entangleAsyncAction维护的 scope thenable(currentEntangledActionThenable)直到所有 Action 完成才 resolve。updateTransition里的updateState处理到属于该 scope lane 的更新时,updateReducerImpl会直接throw这个 scope thenable(peekEntangledActionThenable())。- 读出的值若是 Thenable(
chainThenableValue包出来的那个),updateTransition再useThenable它——也就是挂起这次渲染,直到 scope 内所有 Action 完成。
这条链的总效果是:当你在一个按钮里 startTransition(async () => {...}),本该把 isPending 改回 false 的那次 transition 渲染会被挂起。但它挂起在 transition 车道上:按钮本就显示在当前画面里,属于 shell(getShellBoundary() === null),throwException 于是调用 renderDidSuspendDelayIfPossible(),这次渲染不提交,也不会把已显示的内容换成 fallback。用户看到的是 SyncLane 乐观更新早已提交的 isPending: true 画面,一直保持到 Action 全部完成、这次 transition 渲染最终提交(第 9 章 §9.3 有展开)。
这个机制解释了 React 团队反复强调的"Actions 与 Suspense 是一套"——不是营销口号,是实现层面的同构。第 9 章会展开 Suspense 是 React 把"等待"统一成控制流的机制;Actions 的"等待"通过 useThenable(以及 updateReducerImpl 里直接抛出的 scope thenable)接入了同一条控制流——两者共用同一套"抛出、交给 work loop 处理"的挂起基础设施。
8.5 useOptimistic:乐观更新的内核
乐观更新(Optimistic Update)是一种常见的 UI 模式:在服务端确认之前,先假设操作会成功并立即更新 UI。React 19 通过 useOptimistic 将这个模式标准化。
8.5.1 使用模式
interface Message {
id: string;
text: string;
sending?: boolean;
}
function ChatThread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
// 更新函数:将乐观值合并到当前状态
(currentMessages: Message[], newMessage: string) => [
...currentMessages,
{
id: 'temp-' + Date.now(),
text: newMessage,
sending: true, // 标记为发送中
},
]
);
async function sendMessage(formData: FormData) {
const text = formData.get('message') as string;
// 立即在 UI 中显示消息(乐观)
addOptimisticMessage(text);
// 实际发送到服务端
await submitMessageToServer(text);
// 当 messages prop 更新时,乐观状态自动被真实状态替换
}
return (
<div>
{optimisticMessages.map((msg) => (
<div key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
{msg.text}
{msg.sending && <span> (发送中...)</span>}
</div>
))}
<form action={sendMessage}>
<input name="message" />
<button type="submit">发送</button>
</form>
</div>
);
}
8.5.2 内核实现
useOptimistic 的实现基于一个精巧的"双层状态"模型:
function mountOptimistic<State, Action>(
passthrough: State,
reducer: ((state: State, action: Action) => State) | null
): [State, (action: Action) => void] {
const hook = mountWorkInProgressHook();
hook.memoizedState = passthrough;
hook.baseState = passthrough;
const queue: UpdateQueue<State> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: null,
lastRenderedState: null,
};
hook.queue = queue;
const dispatch = dispatchOptimisticSetState.bind(
null,
currentlyRenderingFiber,
true, // throwIfDuringRender:render 期间调用 dispatch 时直接抛错(见 §8.5.3 签名)
queue
);
queue.dispatch = dispatch;
return [passthrough, dispatch];
}
function updateOptimistic<State, Action>(
passthrough: State,
reducer: ((state: State, action: Action) => State) | null
): [State, (action: Action) => void] {
const hook = updateWorkInProgressHook();
// 🔑 关键逻辑:如果没有待处理的乐观更新,直接使用 passthrough
// 这实现了"真实状态自动替换乐观状态"的效果
return updateOptimisticImpl(hook, currentHook, passthrough, reducer);
}
核心在于 baseState 每次 render 都被 passthrough 覆盖,而乐观 update 只是带着 revertLane 留在队列里等待"过期"——真实的 updateOptimisticImpl 只有几行,剩下的全部交给 useState/useReducer 共用的队列处理函数:
// ReactFiberHooks.js(v19.0.0)
function updateOptimisticImpl<S, A>(
hook: Hook,
current: Hook | null,
passthrough: S,
reducer: ?(S, A) => S
): [S, (A) => void] {
// 乐观更新永远 rebase 在最新传入的 passthrough 之上:
// 每次 render 都把 baseState 重置为 passthrough;没有 pending 更新时它就被原样返回
hook.baseState = passthrough;
// 没传 reducer 就用 useState 的 basicStateReducer(此时 action 本身就是新值)
const resolvedReducer: (S, A) => S =
typeof reducer === 'function' ? reducer : basicStateReducer;
// 乐观更新的"过期"判断(revertLane)就在 updateReducerImpl 里,见 §8.5.4
return updateReducerImpl(hook, currentHook!, resolvedReducer);
}
sequenceDiagram
participant U as UI
participant O as useOptimistic
participant S as Server
Note over U,S: 初始状态: messages = [{id:1, text:"Hello"}]
U->>O: addOptimisticMessage("World")
O->>U: 立即渲染 [{id:1, text:"Hello"}, {id:temp, text:"World", sending:true}]
U->>S: submitMessageToServer("World")
Note over S: 服务端处理...
S-->>U: 返回成功,新 messages prop 到达
Note over O: passthrough 更新为<br/>[{id:1, text:"Hello"}, {id:2, text:"World"}]
O->>U: 渲染真实状态(乐观状态被替换)
图 8-4:useOptimistic 的乐观更新与状态替换流程
8.5.3 乐观更新与 Transition 的关系
乐观更新在 Transition 的上下文中有特殊行为。当一个 Action(异步 Transition)正在进行时,乐观更新会保持"活跃",直到 Transition 完成。一旦 Transition 结束(无论成功还是失败),所有与该 Transition 相关的乐观更新都会被清除,回退到真实状态:
// ReactFiberHooks.js(v19.0.0,精简)
function dispatchOptimisticSetState<S, A>(
fiber: Fiber,
throwIfDuringRender: boolean,
queue: UpdateQueue<S, A>,
action: A
): void {
const transition = requestCurrentTransition();
// DEV:不在 transition / action 里、也没有进行中的 async action 时警告
// "An optimistic state update occurred outside a transition or action. ..."
const update: Update<S, A> = {
lane: SyncLane, // 乐观更新同步提交,立即生效
revertLane: requestTransitionLane(transition), // 记录关联的 Transition lane:它完成时撤销
action,
hasEagerState: false,
eagerState: null,
next: null as any,
};
if (isRenderPhaseUpdate(fiber)) {
// useOptimistic 的 setter 在 render 期调用直接抛错;startTransition 复用本函数时只警告
if (throwIfDuringRender) {
throw new Error('Cannot update optimistic state while rendering.');
}
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane);
// 乐观更新总是同步的,不需要 entangleTransitionUpdate
}
}
markUpdateInDevTools(fiber, SyncLane, action);
}
revertLane 字段是乐观更新的精髓——它记录了"当哪个 Transition 完成时,这个乐观更新应该被撤销"。
8.5.4 updateReducerImpl 中的 revertLane 鉴别:乐观与普通更新的共同队列
前面讲过乐观更新使用 SyncLane 立即提交、用 revertLane 记录"等哪个 Transition 收尾再撤回"。真正把这两条性质组合起来的,是 updateReducerImpl——也就是 useReducer/useState/useOptimistic 共用的队列处理函数。它在 ReactFiberHooks.js 的 updateReducerImpl 里对"这是普通更新还是乐观更新"做分支处理:
// This update does have sufficient priority.
// Check if this is an optimistic update.
const revertLane = update.revertLane;
if (!enableAsyncActions || revertLane === NoLane) {
// This is not an optimistic update, and we're going to apply it now.
// But, if there were earlier updates that were skipped, we need to
// leave this update in the queue so it can be rebased later.
if (newBaseQueueLast !== null) {
const clone: Update<S, A> = {
// This update is going to be committed so we never want uncommit
// it. Using NoLane works because 0 is a subset of all bitmasks, so
// this will never be skipped by the check above.
lane: NoLane,
revertLane: NoLane,
...
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// ...(省略:若 updateLane 属于进行中的 async action,
// 记 didReadFromEntangledAsyncAction = true,见 §8.4.5)
} else {
// This is an optimistic update. If the "revert" priority is
// sufficient, don't apply the update. Otherwise, apply the update,
// but leave it in the queue so it can be either reverted or
// rebased in a subsequent render.
if (isSubsetOfLanes(renderLanes, revertLane)) {
// The transition that this optimistic update is associated with
// has finished. Pretend the update doesn't exist by skipping
// over it.
update = update.next;
// ...(省略:同样的 didReadFromEntangledAsyncAction 标记)
continue;
} else {
const clone: Update<S, A> = {
// Once we commit an optimistic update, we shouldn't uncommit it
// until the transition it is associated with has finished
// (represented by revertLane). Using NoLane here works because 0
// is a subset of all bitmasks, so this will never be skipped by
// the check above.
lane: NoLane,
// Reuse the same revertLane so we know when the transition
// has finished.
revertLane: update.revertLane,
...
};
...
}
}
这段代码的核心逻辑可以翻译成一句话:revertLane === NoLane 的是普通更新(commit 后即抛弃),否则是乐观更新(commit 后保留在队列,直到 renderLanes 的优先级能覆盖 revertLane——说明对应 Transition 已经完成)。
三个被放在源码注释里的微观决策特别值得点出:
lane: NoLane的 clone 永远不会被跳过。 注释写了 "0 is a subset of all bitmasks"——bitmask 的isSubsetOfLanes(renderLanes, NoLane)恒为 true。乐观更新一旦被保留进 baseQueue,后续重渲染不会再被优先级过滤掉,保证"UI 上已经看到的乐观值不会莫名消失"。continue而不是break。 当检测到一个乐观更新对应的 Transition 已完成(isSubsetOfLanes(renderLanes, revertLane)为真),代码只跳过这一条、继续处理后面的更新,而不是整个退出循环。被跳过的乐观更新不会进入newBaseQueue:这次渲染一旦提交,它就随之从队列里消失;若这次渲染被丢弃,current 上的队列没被改过,下次渲染仍会重新处理它。这是 React 并发模型下"render 不修改已提交的历史"原则的典型体现。reuse the same revertLane。 一个乐观更新可能要跨多次渲染才等到它的 Transition 完成(比如 Transition 被别的更新打断了);每次保留到 baseQueue 时都必须带走原始的revertLane,这样 React 才能知道"这个乐观值什么时候该被抹掉"——revertLane 是乐观更新的"自杀时间戳"。
8.5.5 dispatchOptimisticSetState 的假设:乐观更新必须同步提交
dispatchOptimisticSetState 在源码里还有一段平时看不见、但定义了边界条件的注释:
// packages/react-reconciler/src/ReactFiberHooks.js:3678
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
if (root !== null) {
// NOTE: The optimistic update implementation assumes that the transition
// will never be attempted before the optimistic update. This currently
// holds because the optimistic update is always synchronous. If we ever
// change that, we'll need to account for this.
startUpdateTimerByLane(SyncLane);
scheduleUpdateOnFiber(root, fiber, SyncLane);
// Optimistic updates are always synchronous, so we don't need to call
// entangleTransitionUpdate here.
}
这段注释记录了一个暂时性假设——"乐观更新永远先于 Transition 被尝试"。为什么能成立?因为:
- 用户代码里
addOptimisticMessage(...)总是写在await submitToServer(...)之前(不这么写就失去乐观更新的意义); - React 给乐观更新打
SyncLane,给 Transition 里的异步更新打 TransitionLane; SyncLane在 lane 优先级里高于 TransitionLane;- 所以浏览器一次事件循环内,乐观更新的 commit 一定先于 Transition 里
await之后的 commit。
这是一个由 lane 优先级+用户代码模式共同支撑的稳定状态。注释里说 "If we ever change that, we'll need to account for this."——留给未来版本的钩子。作为读者,这段注释的价值不是"要背下来",而是理解一条心智准则:当框架注释明确告诉你"这里依赖 X,如果 X 变了要处理",这就是一个设计契约,不是偶然实现。你在业务代码里复现这种模式(比如"乐观 UI 先于异步提交")时,不该推翻这个顺序。
8.6 ref 作为 prop:告别 forwardRef
React 19 中最令人愉快的改变之一是:函数组件可以直接接收 ref 作为 prop,不再需要 forwardRef 包裹。
8.6.1 旧世界 vs 新世界
// React 18:需要 forwardRef
const Input = forwardRef<HTMLInputElement, InputProps>(
function Input(props, ref) {
return <input ref={ref} {...props} />;
}
);
// React 19:ref 就是一个普通的 prop
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
8.6.2 内核变更
这个变化的实现涉及 Fiber 创建和 props 处理的核心逻辑:
// React 18(历史):JSX runtime 在创建 element 时就把 ref 从 props 里抽走,
// 单独挂在 element.ref 上;ReactChildFiber.js 的 coerceRef 再从 element.ref 读到 fiber.ref
//(string ref 也是在这里被包成函数的)
function coerceRef(returnFiber, current, element) {
const mixedRef = element.ref; // 简化;ref 与 props 从源头就是分离的
// ...(string ref 转函数等处理省略)
return mixedRef; // 调用处:created.ref = coerceRef(returnFiber, current, element)
}
// React 19(ReactChildFiber.js,v19.0.0 真实代码):方向反过来——
// JSX runtime(ReactJSXElement.js)不再从 props 里抽取 ref,
// ref 从 element 创建那一刻起就留在 props 里;
// element.ref 访问器被废弃(DEV 下读它会警告),fiber.ref 反而从 props 里读
function coerceRef(workInProgress: Fiber, element: ReactElement): void {
const refProp = element.props.ref;
workInProgress.ref = refProp !== undefined ? refProp : null;
}
注意方向:不存在"先取出 ref 再合并回 props"的动作——React 19 里 ref 根本没离开过 props。函数组件在 render 时能直接读到 props.ref;而 host 组件和 class 组件仍需要 fiber.ref,所以 Fiber 层反过来从 element.props.ref 读一份挂到 fiber 上。这个改变消除了大量的 boilerplate 代码,并且让组件的类型签名更加自然。React 19 官方对 forwardRef 的措辞是"未来版本将废弃并移除"——19 中它仍完全可用,也尚无运行时 deprecation 警告。
8.7 其他 API 变更
顺带一提,useDeferredValue 在 19 里多了可选的第二个参数:useDeferredValue(value, initialValue)(packages/react/src/ReactHooks.js:181)。传了 initialValue 时,首次挂载先用它渲染,再安排一次 DeferredLane 上的延迟渲染切换到 value(packages/react-reconciler/src/ReactFiberHooks.js 的 mountDeferredValueImpl)。
8.7.1 ref 回调的清理函数
React 19 允许 ref 回调返回一个清理函数,类似 useEffect:
function MeasuredComponent() {
return (
<div
ref={(node) => {
if (node) {
// 挂载时执行
const observer = new ResizeObserver(handleResize);
observer.observe(node);
// 🔑 返回清理函数——React 19 新特性
return () => {
observer.disconnect();
};
}
}}
>
Content
</div>
);
}
内核实现:
// ReactFiberCommitEffects.js(v19.0.0,精简:省略 Profiler 计时与 DEV 包装)
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
if (typeof ref === 'function') {
// React 19:回调 ref 的返回值直接存进 refCleanup(不是函数就存 undefined)
finishedWork.refCleanup = ref(instanceToUse);
} else {
ref.current = instanceToUse;
}
}
}
export function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
const ref = current.ref;
const refCleanup = current.refCleanup;
if (ref !== null) {
if (typeof refCleanup === 'function') {
try {
refCleanup(); // 有清理函数:调它,不再调 ref(null)
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
} finally {
current.refCleanup = null; // 双写 null 防重入(见 §8.10.1)
if (current.alternate != null) current.alternate.refCleanup = null;
}
} else if (typeof ref === 'function') {
try {
ref(null);
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
} else {
ref.current = null;
}
}
}
8.7.2 Context 的简化使用
React 19 允许直接使用 <Context> 作为 Provider,不再需要 <Context.Provider>:
// React 18
const ThemeContext = createContext('light');
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
// React 19
<ThemeContext value="dark">
<App />
</ThemeContext>
8.7.3 文档元数据原生支持
React 19 允许在组件中直接渲染 <title>、<meta>、<link> 等元素,React 会自动将它们提升到 <head> 中:
function BlogPost({ post }: { post: Post }) {
return (
<article>
{}
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<link rel="canonical" href={`https://blog.example.com/${post.slug}`} />
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
8.7.4 HostTransitionContext 的赋值时机:beginWork 里的一段重要注释
HostTransitionContext 的值什么时候被写?为什么不在 pushHostContext 里统一处理?答案在 packages/react-reconciler/src/ReactFiberBeginWork.js(L1632 起)处理 transition-aware host component 的分支,注释写得克制而精确:
const newState = renderTransitionAwareHostComponentWithHooks(
current,
workInProgress,
renderLanes,
);
// If the transition state changed, propagate the change to all the
// descendents. We use Context as an implementation detail for this.
//
// This is intentionally set here instead of pushHostContext because
// pushHostContext gets called before we process the state hook, to avoid
// a state mismatch in the event that something suspends.
//
// NOTE: This assumes that there cannot be nested transition providers,
// because the only renderer that implements this feature is React DOM,
// and forms cannot be nested. If we did support nested providers, then
// we would need to push a context value even for host fibers that
// haven't been upgraded yet.
if (isPrimaryRenderer) {
HostTransitionContext._currentValue = newState;
} else {
HostTransitionContext._currentValue2 = newState;
}
把三条信息逐一拆开:
1、"We use Context as an implementation detail for this." React 团队在代码里对这句话特意用了 "implementation detail" 这种措辞——暗示这是框架内部复用 Context 基础设施,而不是鼓励用户把"form 的 pending"也当成可订阅 Context。你不应该在业务代码里 useContext(HostTransitionContext)(也拿不到它的引用,因为没导出),这点在 §8.4.3 我们看过——Provider/Consumer 都是 null。
2、"intentionally set here instead of pushHostContext"——避免 suspense 时的状态错位。pushHostContext 是 React 每次走到 HostComponent 时都会调的例行公事,会把一些 host-level context 压栈。按理说把 HostTransitionContext._currentValue = newState 放进 pushHostContext 是最自然的——每次碰到 form 就更新。但源码没这么做,理由是:pushHostContext 在 state hook 处理之前被调用,如果 state hook 处理过程中发生 suspense,context 已经被写了但 state 还没提交,下次恢复时就会出现"context 显示 pending,state 却是 not pending"的不一致。 把赋值挪到 renderTransitionAwareHostComponentWithHooks 之后,就能保证要么两者都改,要么两者都不改——这是一个 transactional 的工程模式,和数据库事务的"atomicity"是一个思路。
3、isPrimaryRenderer 双写 _currentValue / _currentValue2。Context 内部有两个值槽(见 ReactFiberNewContext.js / Context 对象本身),用来支撑 React 同时跑两个 renderer(比如 React DOM 与作为副 renderer 的 react-art)的场景。当你是主 renderer 时写 _currentValue,否则写 _currentValue2——避免两个 renderer 互相覆盖同一槽位。
4、"forms cannot be nested" 的假设被这里再利用了一次。注释里明确写了"this assumes that there cannot be nested transition providers"。之所以只用全局 context 的一个值(而不是维护一个栈),因为 HTML <form> 不能嵌套。这条假设在 §8.4.3 节是"不需要用栈"的论据,在这里又变成"不需要为未 upgrade 的 host fiber 额外压值"的论据——同一条领域约束被源码多次复用,这正是好设计的标志:一条真实存在的约束能省掉多处代码。
8.7.5 什么时候通知子树:lazy propagation 下的 HostTransitionContext
前面讲了"写入 context"。但写完 context 不代表子树会重新渲染——还得有人去找到订阅者并给它们标 lane。紧接着上面那段赋值,updateHostComponent 里有这样一个分支:
// packages/react-reconciler/src/ReactFiberBeginWork.js:1655
if (enableLazyContextPropagation) {
// In the lazy propagation implementation, we don't scan for matching
// consumers until something bails out.
} else {
if (didReceiveUpdate) {
if (current !== null) {
const oldStateHook: Hook = current.memoizedState;
const oldState: TransitionStatus = oldStateHook.memoizedState;
// This uses regular equality instead of Object.is because we assume
// that host transition state doesn't include NaN as a valid type.
if (oldState !== newState) {
propagateContextChange(
workInProgress,
HostTransitionContext,
renderLanes,
);
}
}
}
}
要注意 v19.0.0 的 enableLazyContextPropagation = true(packages/shared/ReactFeatureFlags.js),所以 else 里那段"didReceiveUpdate 双门 + 立刻 propagateContextChange"是被 flag 关掉的老路径(React 18 的行为),在 19.0.0 里不会执行。真正生效的是第 7 章 §7.8 讲过的 lazy 机制:Provider 处什么都不做,等某个子孙 Fiber 打算 bailout 时,propagateParentContextChanges(ReactFiberNewContext.js)沿 return 链向上找"值变了的 Provider"——而 <form> 这个被升级过的 host fiber 就是在那里被当成 Provider 识别的:
// packages/react-reconciler/src/ReactFiberNewContext.js:601(propagateParentContextChanges 里的分支,v19.0.0)
} else if (enableAsyncActions && parent === getHostTransitionProvider()) {
// During a host transition, a host component can act like a context
// provider. E.g. in React DOM, this would be a <form />.
const currentParent = parent.alternate;
// ...(省略 currentParent === null 时抛内部错误的检查)
const oldStateHook: Hook = currentParent.memoizedState;
const oldState: TransitionStatus = oldStateHook.memoizedState;
const newStateHook: Hook = parent.memoizedState;
const newState: TransitionStatus = newStateHook.memoizedState;
// This uses regular equality instead of Object.is because we assume that
// host transition state doesn't include NaN as a valid type.
if (oldState !== newState) {
if (contexts !== null) {
contexts.push(HostTransitionContext);
} else {
contexts = [HostTransitionContext];
}
}
}
两个细节:
1、getHostTransitionProvider() 让 <form> 混进 Provider 的行列。普通 Provider 靠 parent.tag === ContextProvider 识别;升级过的 form fiber 没有这个 tag,只能靠 ReactFiberHostContext.js 的栈游标 hostTransitionProviderCursor 报出"当前正在渲染的 host transition provider 是谁"。§8.4.3 讲过的"forms cannot be nested"假设在这里第三次派上用场:栈里最多一层,所以一次比较就够。
2、"We use regular equality instead of Object.is":和 useState 用 Object.is 比较不同,这里用 !==——两条路径(被关掉的 eager 版和生效的 lazy 版)里这句注释一字不差地出现了两次。理由是:"we assume that host transition state doesn't include NaN as a valid type"——NaN !== NaN 为 true,但 host transition state 里永远不会出现 NaN(它的字段是 boolean/FormData/string/函数),所以用更快的 !== 是安全的。针对具体领域类型选更便宜的比较运算符,又一处肉眼可见的微优化。
这种程度的刻意是有代价的——维护者要记住"哪些字段可以走 !==、哪些必须走 Object.is",不能一刀切;也要记住一份代码里同时存在"flag 开"和"flag 关"两条路径,读源码时先查 flag 的值再决定看哪条。读到这一层,你也会明白为什么 React 的 PR review 以"逐行挑刺"著称——每一行背后都有一个成立的论据,review 者要检查这个论据是不是被未来改动悄悄破坏了。
8.8 本章与已写章节的呼应
React 19 的新 Hook 体系不是凭空长出来的。把它们的源码机制铺开,会发现几乎每一个"新"设计都和我们在前面章节建立起来的基础对应:
- 第 3 章(Fiber 架构与双缓冲) 讲过
work-in-progress与current的镜像关系。useOptimistic的baseState每次渲染都被重置为passthrough、然后按需重放 pending 队列,这条"基线跟随真实数据、增量单独管理"的模式,正是 WIP/current 在 Hook 状态层面的延续——不过这次的"真实态"不是 Fiber,而是 props。 - 第 3/4 章(Lane 模型与调度) 讲过 React 的优先级分层和 bitmask 算术。本章 8.5.4 节
updateReducerImpl用isSubsetOfLanes(renderLanes, revertLane)判断"Transition 是否完成",用lane: NoLane确保乐观更新副本永不被优先级过滤,就是 Lane bitmask 语义在 Hook 生命周期里的应用。理解了第 3 章的"lane 是 bitset 位",这里的 "0 is a subset of all bitmasks" 才会秒懂。 - 第 9 章(并发模式与 Suspense) 会完整展开 Suspense 边界和 throw-to-boundary 机制,本章 8.1.5 节的
SuspenseException常量正是那套机制在 React 19 的入口——React 用异常栈展开做控制流,而且连异常对象本身都换成了"可识别的 sentinel Error",表达式从模式变成了 API。 - 第 7 章(Hooks 链表与调度) 详细推导了为什么传统 Hook 必须在顶层同顺序调用。本章 8.1.3 节"use 为什么能在 if 里面"的答案——
use(Context)直接读_currentValue、use(Promise)走独立thenableState数组——正是第 7 章 "Hook 靠调用顺序索引、所以不能条件调用" 这条推导的反证:只要有另一套足够独立的索引机制,就不需要依赖调用顺序。
这种前后呼应不是偶然:React 19 的 API 表面上是新的,内核上却严格遵守了 16.8 以来积累下来的不变量。如果有一天你在阅读 React 未来版本的源码时遇到"这个新 Hook 到底凭什么能这么写"的疑问,回到这些基础不变量(双缓冲、Lane bitmask、throw-to-boundary、索引机制)几乎一定能找到答案。这是一个被持续演进了十余年的大型前端框架最可贵的特质——新特性往往只是旧机制的重新组合。
8.9 本章小结
React 19 的新 Hooks 和 API 标志着 React 从"渲染引擎"向"全栈应用框架基础设施"的演进。这些 API 不是孤立的功能点,而是一个有机的整体:
关键要点:
use打破了 Hook 规则:通过独立于 Hook 链表的追踪机制,use可以在条件语句中调用,为 Promise 和 Context 的消费提供了统一的 API- Actions 将异步操作升级为一等公民:
startTransition对异步函数的支持,使得 pending/error 状态的追踪变得自动化 useActionState统一了表单的状态管理:将 action 函数、状态、pending 状态三位一体useFormStatus实现了跨组件的表单状态共享:基于 Context 的隐式传播,避免了 props drillinguseOptimistic标准化了乐观更新模式:双层状态模型确保了乐观值在 Transition 完成后自动回退- ref 作为 prop 简化了组件设计:消除了
forwardRef的 boilerplate - 这些 API 共同指向一个方向:让 React 能够更好地处理数据变更场景,而不仅仅是数据展示
在下一章中,我们将深入 React 的并发模式——这是支撑 use、Actions、Suspense 等功能的底层引擎。理解并发模式的调度策略和优先级机制,是掌握 React 19 所有高级特性的基础。
8.10 源码核对
本章正文为了讲清主干做过简化。这一节把正文里每一处简化,逐条对回真实源码—— 路径、行号、以及正文没有展开的边界条件。想核对的读者可以照着逐条验证。
8.10.1 refCleanup 的双写与防重入
上面 §8.7.1 给出的 commitAttachRef/safelyDetachRef 是去掉了 Profiler 计时与 DEV 包装的精简版。真实源码中它们实现在 ReactFiberCommitEffects.js(ReactFiberCommitWork.js 只负责调用),并隐藏了几条被源码注释明确标注的"小心踩":
1、ref slot 是 fiber 的一等字段,双缓冲也要双写。 ReactFiber.js 在 createFiber 构造函数里就初始化了:
this.refCleanup = null;
意味着每个 Fiber(不只是用了 ref 的那些)都带一个 refCleanup 槽位。Fiber 克隆(createWorkInProgress)时把它从 current 拷到 WIP:
workInProgress.refCleanup = current.refCleanup;
把清理函数挂在 Fiber 上而不是 Hook 链表上,是因为 ref 并不仅限于 Function Component——Class Component 的 ref、HostComponent 的 ref 也都走同一套。让每个 Fiber 都有这个槽位,省去"根据组件类型决定存在哪"的分支判断。
2、真实的 attach 会区分 HostComponent 与其它。commitAttachRef 读完 ref 之后先做 instanceToUse 的解析:
// packages/react-reconciler/src/ReactFiberCommitEffects.js:747
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
getPublicInstance 是宿主配置提供的接口——它允许宿主把"内部实例"和"暴露给用户的 ref 值"分开。不过在 React DOM 里它就是恒等函数(packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:322,return instance):无论普通 HostComponent,还是 HostHoistable(React 19 用来提升 <title>/<meta> 的机制,见 §8.7.3)和 HostSingleton(用来处理 <html>/<body>/<head> 的单例),ref 拿到的都是 fiber 的 stateNode 本身。这层间接是留给其他渲染器区分内部实例与公开实例用的。
3、清理函数执行后立刻双写 null,防止重入。safelyDetachRef 里有一段被注释明确标出的"防御性写":
} finally {
// `refCleanup` has been called. Nullify all references to it to prevent double invocation.
current.refCleanup = null;
const finishedWork = current.alternate;
if (finishedWork != null) {
finishedWork.refCleanup = null;
}
}
为什么 current 和 alternate 都要清?注释写的是"prevent double invocation"。双缓冲 Fiber 机制下,current 和 WIP 指向同一份状态是常态,如果只清一侧,下次切换双缓冲(current ↔ alternate)时另一侧还持有旧 cleanup 引用,React 可能会二次调用同一个清理函数。用户写的 cleanup 未必是幂等的(比如递减引用计数、归还只该归还一次的资源),二次调用就可能出错;React 通过双写 null 把这种风险在框架层面消除——业务侧不需要自己判断"是不是已经清理过了"。
这条"双缓冲双写"的原则和第 3 章双缓冲(约 3.4 节)讲的 current/alternate 不变量是同一条规则的不同实例——React 并发内核里每次 commit 后的状态传播都遵守这个规则,不只是 ref。
8.10.2 哪些元素会被提升、哪些不会——isHostHoistableType 的分类规则
§8.7.3 举了 <title>/<meta>/<link> 被自动提升的例子,但并未说明"提升到底发生在哪里、依据什么规则"。真实的判断逻辑在 packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js 的 isHostHoistableType——这是一个由宿主(React DOM)提供给 reconciler 的接口,决定一个 JSX 元素是不是应该走"Hoistable"fiber 类型(即被 HostHoistable 接管、最终渲染到 <head>)。
简化后的规则可以归纳如下:
// Global opt out of hoisting for anything in SVG Namespace
// or anything with an itemProp inside an itemScope
if (hostContextProd === HostContextNamespaceSvg || props.itemProp != null) {
return false;
}
switch (type) {
case 'meta':
case 'title': {
return true; // 无条件提升
}
case 'style': {
// 需要 precedence 和 href,否则不提升
if (typeof props.precedence !== 'string' ||
typeof props.href !== 'string' || props.href === '') {
return false;
}
return true;
}
case 'link': {
// rel + href 必须是字符串、没有 onLoad/onError
if (typeof props.rel !== 'string' ||
typeof props.href !== 'string' ||
props.href === '' ||
props.onLoad ||
props.onError) {
return false; // 带 onLoad 的 link 要留在组件内(因为需要生命周期)
}
// ...
return true;
}
}
这份规则揭示了几条不写在官方文档里的精确行为:
1、SVG 里的 <title> 不会被提升。 源码在 isHostHoistableType 里:if (hostContextProd === HostContextNamespaceSvg || ...) return false;。HTML 的 <title> 放在 <head> 里表示页面标题,但 SVG 的 <title> 是一个子元素(给屏幕阅读器用)。React DOM 通过 host context 判断当前处于哪个 namespace,避免"把 SVG title 错误地提到文档头部"。这是 DOM 领域知识被固化到框架代码里的一个典型例子。
2、带 itemProp 的元素一律不提升。 itemProp 是 HTML Microdata 的属性,一个带 itemProp 的 <meta> 或 <link> 本质上是"依附于当前 itemScope 的结构化数据",位置本身就是语义——把它提到 head 会破坏 itemScope 的父子关系。React 选择不提升这种元素,用 DEV 下的警告提醒开发者"如果你想让它提升,请移除 itemProp"。
3、带 onLoad/onError 的 <link> 也不提升。 提升到 head 的 link/style 被 React 去重共享(多个组件声明同一个 href 只加载一次),这种情况下"哪个组件收到 onLoad 回调"就语义不明。React 的选择是:一旦你写了 onLoad/onError,就说明你想把这个 link 当成组件内部的可控资源——那就别提升,老老实实放在原位。通过检测 prop 来决定是否启用某种优化,这是 React 19 大量使用的一种 opt-in 技术。
4、<style> 和 <link rel="stylesheet"> 必须带 precedence 才被提升并去重。 precedence 定义"谁排在前面、谁 override 谁"——React 用它替代 CSS-in-JS 工具(如 styled-components)原本需要运行时维护的样式加载顺序。没有 precedence 就是"React 无法决定你的样式如何合并",干脆不接管。
这些规则单独看每条都不复杂,合起来就构成了一个"提升 = 宿主能判断去重/顺序语义"的统一原则。理解这个原则比背规则列表更有用——将来遇到"我写的 <link> 为什么没被提升"的疑问时,可以直接追到 isHostHoistableType 一行行比对。
思考题
-
use(Promise)在并发渲染中的行为是什么? 如果一个组件在 Transition 中渲染,并且use抛出了一个 pending 的 Promise,React 是保留旧 UI 还是显示 Suspense fallback?为什么? -
useOptimistic的乐观状态为什么选择在 Transition 完成时自动清除,而不是在 passthrough 变化时清除? 构造一个场景,说明这两种策略在竞态条件下的行为差异。 -
useActionState的permalink参数有什么作用? 它与 React Server Components 和渐进增强(Progressive Enhancement)有什么关系?在没有 JavaScript 的环境下,表单提交如何工作? -
React 19 将 ref 作为 prop 传递给函数组件,但对于 Class 组件,ref 仍然由 React 内部处理。 从 Fiber 的创建流程分析,React 是如何区分这两种情况的?如果将一个使用了 ref prop 的函数组件用
React.memo包裹,ref 的传递是否会受到影响?