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()');
}
useThenable 是核心实现,它实现了”同步化异步”的魔法:
function useThenable<T>(thenable: Thenable<T>): T {
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
const result = trackUsedThenable(thenableState, thenable, index);
// 如果 result 是 SUSPENDED_THENABLE,说明 Promise 还没 resolve
// React 会 throw 这个 thenable,触发 Suspense 边界
if (
currentlyRenderingFiber.alternate === null &&
(workInProgressHook === null
? currentlyRenderingFiber.memoizedState === null
: workInProgressHook.next === null)
) {
// 初次渲染时 Promise 未完成:这是合法的 Suspense 场景
}
return result;
}
function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number
): T {
const trackedThenables = thenableState;
const previous = trackedThenables[index];
if (previous === undefined) {
// 第一次遇到这个 thenable
trackedThenables[index] = thenable;
switch (thenable.status) {
case 'fulfilled':
return thenable.value;
case 'rejected':
throw thenable.reason;
default:
// pending 状态:附加 then 回调
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;
}
}
);
// 抛出 thenable,触发 Suspense
throw thenable;
}
} else {
// 之前已经追踪过
switch (previous.status) {
case 'fulfilled':
return (previous as FulfilledThenable<T>).value;
case 'rejected':
throw (previous as RejectedThenable<T>).reason;
default:
// 仍在 pending,继续挂起
throw previous;
}
}
}
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 promise(挂起)
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.3-bis readContext 与 lastFullyObservedContext:use(Context) 为什么不用链表
上面讲”use(Context) 不依赖 Hook 链表顺序”时跳过了一个问题:React 到底怎么追踪”这个 fiber 订阅了哪些 context”的? 答案在 ReactFiberNewContext.js 第 740 行的 readContextForConsumer,值得展开。
这个函数的核心结构是两步:第一步读值——直接访问 context._currentValue(或 _currentValue2 作为副 renderer 备份);第二步登记订阅——把 context 作为一个 contextItem 追加到当前 fiber 的 dependencies 链表上:
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2;
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context,
memoizedValue: value,
next: null,
};
// ...追加到 consumer.dependencies.firstContext
}
注意 lastFullyObservedContext === context 这个短路——React 在同一次渲染里如果已经完整订阅了某个 context(比如前面调了一次 useContext(Ctx)),就不会重复登记;这保证了”一个组件 N 次 use(Ctx) 只产生 1 条订阅”。订阅条目存在 fiber.dependencies 链表上,而不是 hook 链表上——这是 use(Context) 能在 if 分支里调用的根本原因:分支里调用只是决定”这一帧要不要读 Ctx”,登记动作是幂等的,不依赖调用序号。
这也解释了 §8.1.3 提到的”即使 use 在不同分支调用次数不同也不影响”为什么成立——订阅机制是按 context 对象的引用去重的,不是按 index 去重的。Hook 链表按序号寻址(易受条件影响),context dependencies 按对象去重(天然支持条件)——两种索引策略的差别决定了能不能在 if 里调用。
8.1.4 use 抛出的不是 Promise 本身——SuspenseException 的真实机制
前面 8.1.2 节的伪代码中写了 “throw thenable” 来触发 Suspense。这是为了便于理解的简化表达,真实 React 19 的实现要精细一级。打开 packages/react-reconciler/src/ReactFiberThenable.js 第 26 行,能看到一个模块级常量:
// 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 第 169-181 行: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”——这是一种工程温度。
这个设计与第 5 章 5.6 节讲过的 Fiber work loop 的 throwException 路径是同一脉络:React 把所有”用异常栈展开到边界”的地方都做成可识别的常量(SuspenseException、SuspenseyCommitException、还有 noopSuspenseyCommitThenable),commit 阶段根据这些常量决定是显示 fallback 还是触发 suspendCommit。如果抛的是业务层自己的 Error,走 Error Boundary 路径;抛的是 SuspenseException,走 Suspense 路径——识别位不是靠 try/catch,而是靠引用相等。
8.1.5 shellSuspendCounter > 100:async Client Component 的红线
trackUsedThenable 里还藏着一个对前端工程师极有价值的保护:在 Shell(根 Suspense 边界内部)同步渲染路径下,如果一个 thenable 从来没被缓存、导致 root 反复 ping-suspend 超过 100 次,React 会直接抛错阻止死循环。
// ReactFiberThenable.js 第 111-133 行
// 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'”——这是框架为用户预埋的诊断线索。
第 4 章 4.3 节讨论并发渲染的”工作循环可中断”时说过:Transition 下 React 可以真的停下来等 Promise。这里的关键区别是 “sync render vs transition”——同步路径(shell 初始渲染)没有等待余地,才需要数器保护;而第 8.2 节后面讲的 async Action 发生在 Transition 中,允许挂起直到 resolve。“是否允许等待”是同步/并发模式的核心分水岭。
8.1.6 trackUsedThenable 的幂等去重:组件必须是 idempotent 的
源码中 trackUsedThenable 开头还有一段看似不起眼、实则定义了一条契约的逻辑:
// ReactFiberThenable.js 第 69-82 行
const previous = thenableState[index];
if (previous === undefined) {
thenableState.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.
// 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 即便出错也不会污染宿主环境。这是一种”礼貌丢弃”——用 4 个字符(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 的生命周期:
function startTransition(
fiber: Fiber,
queue: UpdateQueue<boolean>,
pendingState: boolean,
finishedState: boolean,
callback: () => void | Promise<void>
) {
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority)
);
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
// 先标记 isPending = true
dispatchSetState(fiber, queue, pendingState);
try {
const returnValue = callback();
if (
returnValue !== null &&
typeof returnValue === 'object' &&
typeof returnValue.then === 'function'
) {
// 🔑 这是一个 async Action
const thenable = (returnValue as Thenable<void>);
// 创建一个 "listener",在 Promise resolve 时标记 isPending = false
const thenableForFinishedState = chainThenableValue(
thenable,
finishedState
);
// 通知 Transition 追踪系统
entangleAsyncAction(fiber, thenableForFinishedState);
} else {
// 同步 Action,直接标记完成
dispatchSetState(fiber, queue, finishedState);
}
} catch (error) {
// Action 出错,也标记完成(isPending = false)
dispatchSetState(fiber, queue, finishedState);
throw error;
} finally {
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig.transition = 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 --> Error: Promise reject
Error --> Idle: 重新提交
state Pending {
[*] --> AsyncRunning
AsyncRunning --> WaitingResolve: await 异步操作
WaitingResolve --> AsyncRunning: 继续执行
}
图 8-2:Action 的状态生命周期
8.2.3 entangled action scope:多个并发 Action 为什么被绑在一起
上面 8.2.1 节的伪代码里出现了一个 entangleAsyncAction——看着像函数名,真到源码里这套逻辑的真名是 requestAsyncActionContext,定义在 packages/react-reconciler/src/ReactFiberAsyncAction.js。这个文件只有 176 行,但它解释了 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;
工作流程用文字一图流:
- 第一个 async Action 启动:
currentEntangledListeners === null,React 建一个新 scope,调用requestTransitionLane()要一条专属 lane,currentEntangledPendingCount从 0 涨到 1。 - 第二个 async Action 在第一个还没结束时启动:
currentEntangledListeners !== null,直接复用已有 scope 和 lane,currentEntangledPendingCount涨到 2。 - 每个 Action 的 Promise resolve/reject:调用
pingEngtangledActionScope(),--currentEntangledPendingCount。 - 当 pending 计数归零时:
currentEntangledListeners = null; currentEntangledLane = NoLane,然后遍历所有 listeners,一次性把所有 Action 的结果 thenable 都 flip 成 fulfilled/rejected。
最后一步是关键——注释写得像教科书:
// Create a thenable that represents the result of this action, but doesn't
// resolve until the entire entangled scope has finished.
//
// Expressed using promises:
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
// return thisResult;
也就是说,即便 Action A 的 Promise 已经 resolve,只要同一个 scope 里的 Action B 还在 pending,A 返回给 React 的 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 createResultThenable:注释里的”小作弊”
requestAsyncActionContext 返回的 resultThenable 对象实现里有一段绝妙的注释:
function createResultThenable<S>(
entangledListeners: Array<() => mixed>,
): Thenable<S> {
const resultThenable: PendingThenable<S> = {
status: 'pending',
value: null,
reason: null,
then(resolve: S => mixed) {
// This is a bit of a cheat. `resolve` expects a value of type `S` to be
// passed, but because we're instrumenting the `status` field ourselves,
// and we know this thenable will only be used by React, we also know
// the value isn't actually needed. So we add the resolve function
// directly to the entangled listeners.
//
// This is also why we don't need to check if the thenable is still
// pending; the Suspense implementation already performs that check.
const ping: () => mixed = (resolve: any);
entangledListeners.push(ping);
},
};
return resultThenable;
}
then 的第一个参数按 Promises/A+ 规范应该是 (value: S) => mixed——即 resolve 会收到一个 S 类型的值。React 这里直接把它当成 () => mixed 塞进 listeners 数组。注释说的 “This is a bit of a cheat” 说明 React 心知肚明这不符合规范,但敢这么写是因为这个 thenable 不会被用户代码拿到,只被 React 自己的 useThenable/trackUsedThenable 消费——在封闭系统里,规范可以被理解成”契约的上限”而非”契约的下限”。
这里还藏着一个”为什么不做状态检查”的解释:Suspense implementation already performs that check——trackUsedThenable 已经根据 status 字段区分 pending/fulfilled/rejected,所以 createResultThenable 的 then 不用重复检查。把责任推给消费者、省下生产者的重复逻辑,这是 React 源码中反复出现的性能工程风格。
这个小作弊和第 5 章 5.4 节讲过的”Fiber.pendingProps 和 memoizedProps 同为空对象时的引用复用”是一个套路:当你完全掌控读写两端时,可以放弃中间契约的严谨性换 CPU。业务代码里少见这种技巧,因为两端通常不在同一个人手里;框架源码里常见,因为写的人和用的人是同一个。
8.3 useActionState:表单状态管理的内核
useActionState 是 React 19 为表单场景设计的专用 Hook,它将表单的提交动作与状态管理合二为一。
8.3.1 API 设计与使用模式
// useActionState 的类型签名
function useActionState<State>(
action: (prevState: State, formData: FormData) => State | Promise<State>,
initialState: State,
permalink?: string
): [state: State, dispatch: (payload: FormData) => 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 内核实现
useActionState 的实现巧妙地组合了 useReducer 的状态管理能力和 Action 的异步追踪能力:
function mountActionState<State>(
action: (state: State, payload: FormData) => State | Promise<State>,
initialState: State,
permalink?: string
): [State, (payload: FormData) => void, boolean] {
// 状态存储:使用 reducer 模式
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = initialState;
stateHook.baseState = initialState;
const stateQueue: UpdateQueue<State> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: actionStateReducer,
lastRenderedState: initialState,
};
stateHook.queue = stateQueue;
// isPending 状态追踪
const pendingStateHook = mountWorkInProgressHook();
pendingStateHook.memoizedState = false; // 初始不在 pending
// 创建 dispatch 函数
const dispatch = dispatchActionState.bind(
null,
currentlyRenderingFiber,
stateQueue,
action,
pendingStateHook.queue
);
stateQueue.dispatch = dispatch;
// Action 引用存储(用于热更新)
const actionHook = mountWorkInProgressHook();
actionHook.memoizedState = action;
return [initialState, dispatch, false];
}
// Action 的 reducer
function actionStateReducer<State>(state: State, action: State): State {
return action; // 直接用新值替换旧值
}
当用户调用 dispatch 时,实际执行流程如下:
function dispatchActionState<State>(
fiber: Fiber,
actionQueue: UpdateQueue<State>,
action: (state: State, payload: FormData) => State | Promise<State>,
pendingQueue: UpdateQueue<boolean>,
payload: FormData
) {
// 1. 标记 isPending = true
dispatchSetState(fiber, pendingQueue, true);
// 2. 在 Transition 中执行 action
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
const prevState = actionQueue.lastRenderedState;
const returnValue = action(prevState, payload);
if (returnValue instanceof Promise) {
// 异步 Action
returnValue.then(
(nextState) => {
dispatchSetState(fiber, actionQueue, nextState);
dispatchSetState(fiber, pendingQueue, false);
},
(error) => {
dispatchSetState(fiber, pendingQueue, false);
// 错误处理...
}
);
} else {
// 同步 Action
dispatchSetState(fiber, actionQueue, returnValue);
dispatchSetState(fiber, pendingQueue, false);
}
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}
graph TD
A["用户提交表单"] --> B["dispatch(formData)"]
B --> C["isPending = true"]
C --> D["执行 action(prevState, formData)"]
D --> E{返回 Promise?}
E -->|是| F["等待 Promise resolve"]
F --> G["state = resolvedValue"]
G --> H["isPending = false"]
E -->|否| I["state = returnValue"]
I --> H
H --> J["React 重渲染"]
style C fill:#FFD93D
style H fill:#6BCB77
图 8-3:useActionState 的执行流程
8.4 useFormStatus:跨组件的表单状态共享
useFormStatus 解决了一个常见的痛点:表单内的子组件如何知道表单当前是否在提交中。
8.4.1 使用模式
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 内部
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: 'GET',
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: 'GET',
action: null,
});
}
});
}
这种设计的精妙之处在于:子组件不需要接收任何 props 就能知道表单的状态。这是 Context 模式在框架内部的一个典型应用——将”最近的 form 祖先”作为隐式的 Provider。
8.4.3 源码核对:真名是 HostTransitionContext,不是 FormContext
上面为了快速讲清楚故意使用了”FormContext”这个称呼。打开 React 19 的真实源码,这个 context 的真名是 HostTransitionContext,定义在 packages/react-reconciler/src/ReactFiberHostContext.js 第 34-56 行:
// Represents the nearest host transition provider (in React DOM, a <form />)
// NOTE: Since forms cannot be nested, and this feature is only implemented by
// React DOM, we don't technically need this to be a stack. It could be a single
// module variable instead.
const hostTransitionProviderCursor: StackCursor<Fiber | null> =
createCursor(null);
export const HostTransitionContext: ReactContext<TransitionStatus | null> = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: null,
_currentValue2: null,
_threadCount: 0,
Provider: (null: any), // 注意这里是 null
Consumer: (null: any), // 用户永远不应该手动使用
_defaultValue: (null: any),
_globalName: (null: any),
};
几个值得细看的设计决策:
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。源码维护注释明确说明:这里技术上不一定需要栈,也可以用单个模块变量表示当前状态。之所以还是用了栈游标(StackCursor),纯粹是为了和 React 的 HostContext 栈机制保持结构一致,省得做特判。这是一种”不必要的一般性换来的结构对称”。
3、useFormStatus 不是直接 useContext(HostTransitionContext)。 真实的 useFormStatus 在 packages/react-dom-bindings/src/shared/ReactDOMFormActions.js 第 68 行,只做了一次转发:
export function useFormStatus(): FormStatus {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
const dispatcher = resolveDispatcher();
return dispatcher.useHostTransitionStatus();
}
}
dispatcher.useHostTransitionStatus() 的真实实现在 ReactFiberHooks.js 第 2593 行:
function useHostTransitionStatus(): TransitionStatus {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
}
const status: TransitionStatus | null = readContext(HostTransitionContext);
return status !== null ? status : NoPendingHostTransition;
}
NoPendingHostTransition 在 packages/react-dom-bindings/src/shared/ReactDOMFormActions.js 第 35-44 行是一个全局共享的 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,当没有进行中的提交时拿到的是同一个对象引用——这让用户写的const status = useFormStatus(); if (status === NotPending) ...能走引用等价而不是结构等价。对高频调用的 Hook,这是能省掉 GC 压力的实打实优化。
8.4.4 startHostTransition:给 form 元素”追认”一个 Hook
<form action={...}> 提交时,React 要让这个 DOM 元素本身有”pending / not pending”的状态(这样 useFormStatus 才能读到)。问题来了——DOM 元素对应的是 HostComponent fiber,它本来是无状态的(没有 Hook 链表)。React 19 怎么解决这个看似矛盾的需求?
打开 ReactFiberHooks.js 第 2463 行 startHostTransition,答案写得极其直白:
if (formFiber.memoizedState === null) {
// 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.
//
// Create the state hook used by TransitionAwareHostComponent. This is
// essentially an inlined version of mountState.
...
const stateHook: Hook = {
memoizedState: NoPendingHostTransition,
baseState: NoPendingHostTransition,
baseQueue: null,
queue: newQueue,
next: null,
};
// Add the state hook to both fiber alternates. The idea is that the fiber
// had this hook all along.
formFiber.memoizedState = stateHook;
const alternate = formFiber.alternate;
if (alternate !== null) {
alternate.memoizedState = stateHook;
}
}
这段代码做的事有一个极生动的描述——“追认”:
- 用户写
<form action={submitHandler}>,React 一开始把它当成普通 HostComponent fiber 挂载,没有 Hook 链表。 - 用户第一次提交表单,
startHostTransition被触发。 - React 检查
formFiber.memoizedState === null——还没有 Hook。 - 现场构造一个 Hook 对象,塞进
formFiber.memoizedState,同时塞进formFiber.alternate.memoizedState(让 WIP 和 current 两棵树都看起来”一直有这个 Hook”)。 - 注释的 “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”。上面代码里:
// 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 会手动调 startTransition(formFiber, queue, pendingState, ...),没人需要拿着 dispatch 去触发。省一次 bind 调用,省一个 Function 对象的分配——对”每次表单提交都走一次”的热路径来说,这些微优化累加起来能让 React 比对手快一截。
8.4.5 isPending 是 boolean | Thenable:useTransition 为什么能 Suspend
前面讲 useTransition 返回的 [isPending, start],这个 isPending 按直觉应该是 boolean——要么在 pending,要么不在。但打开 updateTransition(第 2563 行)和 rerenderTransition(第 2578 行),你会发现真实类型签名是:
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。
requestAsyncActionContext返回的resultThenable直到所有 Action 完成才 resolve。useTransition的isPending持有的就是这个resultThenable。updateTransition碰到 Thenable 就useThenable它——也就是挂起组件,直到 scope 内所有 Action 完成。
这条链的总效果是:当你在一个按钮里 startTransition(async () => {...}),这个按钮读 isPending 时的组件本身也会被 Suspense 边界挂起。如果按钮上层包了 <Suspense fallback={<Spinner/>}>,用户会先看到 Spinner;否则渲染会卡住直到 Action 完成。
这个机制解释了 React 团队反复强调的”Actions 与 Suspense 是一套”——不是营销口号,是实现层面的同构。第 6 章讲过 Suspense 是 React 把”等待”统一成控制流的机制;Actions 的”等待”通过 useThenable 接入了同一条控制流——两者共用同一套抛出 SuspenseException 并交给 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, // isOptimistic 标记
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, passthrough, reducer);
}
核心在于乐观更新的”过期”机制:
function updateOptimisticImpl<State, Action>(
hook: Hook,
passthrough: State,
reducer: ((state: State, action: Action) => State) | null
): [State, (action: Action) => void] {
// 基准状态始终跟随 passthrough(真实数据)
hook.baseState = passthrough;
const queue = hook.queue;
const pending = queue.pending;
if (pending !== null) {
// 有乐观更新待处理
// 以 passthrough 为基础,应用所有乐观更新
let newState = passthrough;
let update = pending.next;
do {
const action = update.action;
newState = reducer !== null ? reducer(newState, action) : action;
update = update.next;
} while (update !== pending.next);
hook.memoizedState = newState;
} else {
// 没有乐观更新,直接使用 passthrough
hook.memoizedState = passthrough;
}
return [hook.memoizedState, queue.dispatch];
}
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 相关的乐观更新都会被清除,回退到真实状态:
function dispatchOptimisticSetState<State>(
fiber: Fiber,
throwIfDuringRender: boolean,
queue: UpdateQueue<State>,
action: State
) {
const update: Update<State> = {
lane: SyncLane, // 乐观更新使用 SyncLane,确保立即生效
revertLane: requestTransitionLane(), // 记录关联的 Transition lane
action,
hasEagerState: false,
eagerState: null,
next: null,
};
// 将更新入队
const pending = queue.pending;
if (pending === null) {
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
// 触发重渲染
scheduleUpdateOnFiber(fiber, SyncLane);
}
revertLane 字段是乐观更新的精髓——它记录了”当哪个 Transition 完成时,这个乐观更新应该被撤销”。
8.5.4 updateReducerImpl 中的 revertLane 鉴别:乐观与普通更新的共同队列
前面讲过乐观更新使用 SyncLane 立即提交、用 revertLane 记录”等哪个 Transition 收尾再撤回”。真正把这两条性质组合起来的,是 updateReducerImpl——也就是 useReducer/useState/useOptimistic 共用的队列处理函数。它在 ReactFiberHooks.js 第 1300-1360 行对”这是普通更新还是乐观更新”做分支处理:
// 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;
}
} 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;
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)为真),代码不是”从队列中删除”而是”当这次渲染里假装它不存在”——原始队列没被修改,如果下一次渲染优先级变了,这个 update 依然会被重新考虑。这是 React 并发模型下的”不修改历史、只在当前帧重放”原则的典型体现。reuse the same revertLane。 一个乐观更新可能要跨多次渲染才等到它的 Transition 完成(比如 Transition 被别的更新打断了);每次保留到 baseQueue 时都必须带走原始的revertLane,这样 React 才能知道”这个乐观值什么时候该被抹掉”——revertLane 是乐观更新的”自杀时间戳”。
8.5.5 dispatchOptimisticSetState 的假设:乐观更新必须同步提交
dispatchOptimisticSetState 在源码第 2868-2877 行还有一段平时看不见、但定义了边界条件的注释:
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.
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 19 之前:ref 被从 props 中提取出来,单独存储在 Fiber 上
function createFiberFromElement(element: ReactElement): Fiber {
const { type, key, ref, props } = element;
const fiber = createFiber(type, props, key);
fiber.ref = ref; // ref 与 props 分离
return fiber;
}
// React 19:对于函数组件,ref 被保留在 props 中
function createFiberFromElement(element: ReactElement): Fiber {
const { type, key, ref, props } = element;
const fiber = createFiber(type, props, key);
if (typeof type === 'function') {
// 函数组件:将 ref 合并回 props
if (ref !== null) {
fiber.pendingProps = { ...props, ref };
}
// fiber.ref 仍然设置,用于 React 内部的 ref 处理
fiber.ref = ref;
} else {
fiber.ref = ref;
}
return fiber;
}
这个改变看似简单,但它消除了大量的 boilerplate 代码,并且让组件的类型签名更加自然。forwardRef 在 React 19 中被标记为 deprecated,虽然仍然可以使用,但会在未来版本中移除。
8.7 其他 API 变更
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>
);
}
内核实现:
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
const instance = finishedWork.stateNode;
if (typeof ref === 'function') {
// React 19:捕获返回值作为清理函数
const cleanup = ref(instance);
if (typeof cleanup === 'function') {
// 存储清理函数,在 ref 变化或卸载时调用
finishedWork.refCleanup = cleanup;
}
} else if (ref !== null) {
ref.current = instance;
}
}
function commitDetachRef(current: Fiber) {
const ref = current.ref;
if (current.refCleanup !== null) {
// 调用清理函数
current.refCleanup();
current.refCleanup = null;
} else if (typeof ref === 'function') {
ref(null);
} else if (ref !== null) {
ref.current = null;
}
}
8.7.1-bis 源码核对:refCleanup 的双写与防重入
上面 §8.7.1 给出的 commitAttachRef/commitDetachRef 是方便理解的教学版。真实源码中,清理函数机制的实现分散在 ReactFiberCommitWork.js 的三处地方,并隐藏了几条被源码注释明确标注的”小心踩”:
1、ref slot 是 fiber 的一等字段,双缓冲也要双写。 ReactFiber.js 第 157 行在 createFiber 构造函数里就初始化了:
this.refCleanup = null;
意味着每个 Fiber(不只是用了 ref 的那些)都带一个 refCleanup 槽位。Fiber 克隆(createWorkInProgress)时,第 346 行把它从 current 拷到 WIP:
workInProgress.refCleanup = current.refCleanup;
把清理函数挂在 Fiber 上而不是 Hook 链表上,是因为 ref 并不仅限于 Function Component——Class Component 的 ref、HostComponent 的 ref 也都走同一套。让每个 Fiber 都有这个槽位,省去”根据组件类型决定存在哪”的分支判断。
2、真实的 attach 会区分 HostComponent 与其它。commitAttachRef 在第 1600 行,读完 ref 之后先做 instanceToUse 的解析:
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
getPublicInstance 是 React DOM 的一个薄包装——它允许宿主把”内部 DOM 节点”和”暴露给用户的 ref 值”分开。对于 HostComponent(即普通 DOM 元素),instance 是 HTMLElement,getPublicInstance 直接返回它;但对于 HostHoistable(React 19 用来提升 <title>/<meta> 的机制,见 §8.7.3)和 HostSingleton(用来处理 <html>/<body>/<head> 的单例),这层间接让宿主可以返回”一个虚拟 handle”而不是真 DOM——这样 ref 就能跟踪”被提升到 head 里的那个元素”而非”JSX 里写的那个位置”。这是 React 19 元数据机制能工作的隐藏接口之一。
3、清理函数执行后立刻双写 null,防止重入。safelyDetachRef 在第 299 行,里面有一段被注释明确标出的”防御性写”:
} 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 指向同一个 Hook 状态是常态,如果只清一侧,下次切换双缓冲(current ↔ alternate)时另一侧还持有旧 cleanup 引用,React 可能会二次调用同一个清理函数。用户写的 cleanup 如果调用了 observer.disconnect(),二次调用大概率出错;React 通过双写 null 把这种风险在框架层面消除——业务侧完全不需要判断”是不是已经 disconnect 过了”。
这条”双缓冲双写”的原则和第 3 章 3.7 节讲的”fiber 更新必须双写 current/alternate”是同一条不变量的不同实例——React 并发内核里每次 commit 后的状态传播都遵守这个规则,不只是 ref。
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>
{/* 这些会被自动提升到 <head> */}
<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.4.6 HostTransitionContext 的赋值时机:beginWork 里的一段重要注释
HostTransitionContext 的值什么时候被写?为什么不在 pushHostContext 里统一处理?这条问题的答案在 ReactFiberBeginWork.js 第 1620-1664 行,注释写得可能是全 React 仓库里最克制又最精确的一段:
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 内部有两个值槽——这个机制在第 7 章 Context 实现里讲过,用来支撑 React 同时跑两个 renderer(比如 React DOM + React Native)的场景。当你是主 renderer 时写 _currentValue,否则写 _currentValue2。
4、“forms cannot be nested” 的假设被这里再利用了一次。注释里明确写了”this assumes that there cannot be nested transition providers”。之所以只用全局 context 的一个值(而不是维护一个栈),因为 HTML <form> 不能嵌套。这条假设在 §8.4.3 节是”不需要用栈”的论据,在这里又变成”不需要为未 upgrade 的 host fiber 额外压值”的论据——同一条领域约束被源码多次复用,这正是好设计的标志:一条真实存在的约束能省掉多处代码。
8.4.7 propagateContextChange 与 didReceiveUpdate:什么时候通知子树
前面讲了”写入 context”。但写完 context 不代表子树会重新渲染——context 变化还必须有一个显式的 propagateContextChange 调用来通知订阅者:
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,
);
}
}
}
两个细节:
1、didReceiveUpdate 双门:先判断整个 fiber 是不是”有新任务”,再判断 state hook 的新旧值不同——两个条件都满足才 propagate。这防止了”没有任何变化”的情况下也广播给所有订阅者(那会导致子树无意义重渲染)。React 在这里表现出的工程克制很典型:先快速剪枝,再做昂贵的遍历。
2、“We use regular equality instead of Object.is”:和 useState 用 Object.is 比较不同,这里用 !==。注释给出理由:“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”,不能一刀切。但对一个每秒被调用千次的函数,这种选择累加起来的收益是明显的。读到这一层,你也会明白为什么 React 的 PR review 以”逐行挑刺”著称——每一行背后都有一个成立的论据,review 者要检查这个论据是不是被未来改动悄悄破坏了。
8.7.3-bis 源码核对:哪些元素会被提升、哪些不会——isHostHoistableType 的分类规则
§8.7.3 举了 <title>/<meta>/<link> 被自动提升的例子,但并未说明”提升到底发生在哪里、依据什么规则”。真实的判断逻辑在 packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js 第 3066 行 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> 不会被提升。 看第 3084 行: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 一行行比对。
8.8 本章与已写章节的呼应
React 19 的新 Hook 体系不是凭空长出来的。把它们的源码机制铺开,会发现几乎每一个”新”设计都和我们在前面章节建立起来的基础对应:
- 第 3 章(Fiber 架构与双缓冲) 讲过
work-in-progress与current的镜像关系。useOptimistic的baseState每次渲染都被重置为passthrough、然后按需重放 pending 队列,这条”基线跟随真实数据、增量单独管理”的模式,正是 WIP/current 在 Hook 状态层面的延续——不过这次的”真实态”不是 Fiber,而是 props。 - 第 5 章(协调与 Lane 模型) 讲过 React 18 的优先级分层和 bitmask 算术。本章 8.5.4 节
updateReducerImpl用isSubsetOfLanes(renderLanes, revertLane)判断”Transition 是否完成”,用lane: NoLane确保乐观更新副本永不被优先级过滤,就是 Lane bitmask 语义在 Hook 生命周期里的应用。理解了 5.7 节的”lane 是 bitset 位”,这里的 “0 is a subset of all bitmasks” 才会秒懂。 - 第 6 章(Suspense 与时间切片) 给出了 Suspense 边界和 throw-to-boundary 的原型,本章 8.1.4 节的
SuspenseException常量就是那个原型的 React 19 产物。当时我们说”React 用异常栈展开做控制流”——现在连异常对象本身都换成了”可识别的 sentinel Error”,表达式从模式变成了 API。 - 第 7 章(Hooks 链表与调度) 详细推导了为什么传统 Hook 必须在顶层同顺序调用。本章 8.1.3 节”use 为什么能在 if 里面”的答案——
use(Context)直接读_currentValue、use(Promise)走独立thenableState数组——正是第 7 章 “Hook 链表是一种形变的 Map” 命题的反证:只要有另一套足够独立的索引机制,就不需要依赖调用顺序。
这种前后呼应不是偶然:React 19 的 API 表面上是新的,内核上却严格遵守了 16.8 以来积累下来的不变量。如果有一天你在阅读 React 未来版本的源码时遇到”这个新 Hook 到底凭什么能这么写”的疑问,回到这些基础不变量(双缓冲、Lane bitmask、throw-to-boundary、索引机制)几乎一定能找到答案。这是一个被持续演进了 8 年的大型前端框架最可贵的特质——新特性往往只是旧机制的重新组合。
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 所有高级特性的基础。
思考题
-
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 的传递是否会受到影响?