React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解
本章全部结论均落到
react-19.3.1仓库的真实源码,引用的行号来自 19.3.1 版本。 若本章叙述与官方文档或你已有的印象冲突,以本仓库源码为准,文内会明确标注语义差异。
0. 阅读前必读:本章在系列中的位置
在动手读 React 19 任何一个新 API 之前,请先明确一个核心事实:React 19 的"新特性"几乎全部不是凭空发明的语法糖,而是第 4、5、6、7、8、9、10 章所剖析的底层设施的"上层封装"与"默认打通"。
- 第 4 章(协调算法)解释了 Fiber 节点如何挂载/更新、props 如何在
beginWork/completeWork之间流转;React 19 的 ref 作为普通 prop 正是把原先被单独抽离的ref字段重新还给了 props。 - 第 5 章(调度器)与第 7、8 章(Lane 优先级 / 并发渲染)解释了"可中断渲染"与"过渡(Transition)"的能力;React 19 的 Actions、useOptimistic、useActionState、useTransition 全部建立在
Transition+Lane+Suspense之上。 - 第 9 章(Suspense 与 Suspend)解释了
useThenable/trackUsedThenable如何让组件在等待 Promise 时挂起;React 19 的use原语正是把"读取 Context"和"读取 Promise"统一成了同一个入口。 - 第 10 章(事件系统)解释了从原生事件到 React 合成事件的桥接;React 19 的
<form action>就是在事件系统里新增了一个FormActionEventPlugin,把表单提交事件直接翻译成一次 Action。
因此,本章的写法是:先讲清楚每个特性"调用了哪些底层设施",再讲"它如何在底层设施之上加了一层薄薄的语义封装"。这样你读完之后不会觉得 React 19 是"一堆新概念",而是"同一套引擎的更顺手的挡位"。
1. 动机与背景:为什么是 2019 年的"Action 范式"
1.1 三个长期痛点
React 团队协作的工程师在 RFC 中反复提到三个痛点,它们共同催生了 React 19 的 Action 范式:
痛点 A:异步状态管理的样板代码爆炸。 在 React 18 及以前,一个"提交表单 → 等待 → 成功/失败 → 回滚"的流程,需要开发者手动维护 isPending、error、submit 三个状态,手动在 try/finally 里开关 isPending,手动处理乐观更新与回滚。这三个状态彼此强耦合,却散落在组件各处。
痛点 B:乐观更新(Optimistic UI)的回滚极其脆弱。 乐观更新的本质是"先把界面改成期望结果,等真实结果回来再校正"。一旦失败,必须精确地把状态还原到提交前。在并发渲染下,因为有可中断渲染和"被更高优先级打断"的情况,手动 setState(旧值) 的回滚经常与并发更新交错,产生 race condition。
痛点 C:与服务器组件(RSC)的割裂。 在 RSC 模型中,组件可以在服务器端执行、返回一个"动作"(Action)给客户端调用。但客户端侧的调用约定(如何传参、如何等待、如何表达 pending)在 18 中没有统一抽象。
1.2 React 19 的统一答案
React 19 给出的答案不是三个独立 API,而是一组围绕"Transition(过渡)"这个既有原语建立的语义层:
- 把"提交一个可能异步的动作"统一表达为一次
Transition(第 7、8 章)。 - 把
isPending的追踪交给useTransition的同一个底层状态机(HostTransitionContext+useHostTransitionStatus)。 - 把"乐观更新 + 自动回滚"封装进
useOptimistic,其回滚机制直接复用了 Transition 的revertLane。 - 把"状态 + dispatch + pending"打包成
useActionState,内部用一条串行 Action 队列 + 一个可悬挂(suspend)的 thenable 实现。 - 把"读取 Context 或 Promise"统一为
use原语。
一句话心智模型(后面会反复用到):
React 19 的 Action 特性 = Transition(并发内核)+ Suspense(挂起内核)+ 一条串行队列(顺序保证)+ 自动 revertLane(回滚保证)。
下面这张 mermaid 给出宏观结构。
2. 核心数据结构全景
在逐个拆解 API 之前,先把本章会贯穿始终的几个数据结构摆出来,后面讲到任何实现细节你都能对号入座。
2.1 ThenableState 与 thenableIndexCounter(支撑 use / 挂起)
use 读取 Promise 时,React 需要记录"这个组件在第几个 use 调用处、对哪个 thenable 产生了依赖",以便挂起后重放(replay)。相关模块级变量在 packages/react-reconciler/src/ReactFiberHooks.js:287:
js
// packages/react-reconciler/src/ReactFiberHooks.js:287
let thenableIndexCounter: number = 0;
let thenableState: ThenableState | null = null;
thenableIndexCounter:当前组件渲染过程中,use(thenable)被调用的顺序序号。每次进入渲染被重置(:704、:818、:979等处都有thenableIndexCounter = 0)。thenableState:保存"本 fiber 渲染期间所有挂起的 thenable 及其结果缓存"的结构,由createThenableState()创建。
2.2 ActionStateQueue 与 ActionStateQueueNode(支撑 useActionState)
useActionState 不是简单的 useState,它内部维护一条串行队列 ,保证多个 Action 按顺序执行、前一个完成才执行下一个。定义见 packages/react-reconciler/src/ReactFiberHooks.js:2068:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2068
type ActionStateQueue<S, P> = {
state: Awaited<S>, // 最近一次 action 返回的最新 state
dispatch: Dispatch<P>, // 稳定的 dispatch 方法
action: ((Awaited<S>, P) => S) | null, // 当前渲染的 action 实现;为 null 表示已出错
pending: ActionStateQueueNode<S, P> | null, // 环形链表,记录排队中的 action
};
每个被 dispatch 的 action 会创建一个节点 ActionStateQueueNode(:2084):
js
// packages/react-reconciler/src/ReactFiberHooks.js:2084
type ActionStateQueueNode<S, P> = {
payload: P,
action: (Awaited<S>, P) => S,
next: ActionStateQueueNode<S, P>, // 环形链表
isTransition: boolean, // 本次 dispatch 是否在 transition 内
then: (listener: () => void) => void, // 实现 Thenable 接口
status: 'pending' | 'rejected' | 'fulfilled',
value: any,
reason: any,
listeners: Array<() => void>,
};
注意第 2099 行的 then 方法------每个 action 节点本身就是一个 thenable 。这意味着 useActionState 返回的 state 可以通过 use(thenable) 机制被"悬挂"读取,直到该 action 完成。这是把并发、Suspense 与 Action 缝合在一起的关键一针。
2.3 Update.revertLane(支撑 useOptimistic 自动回滚)
乐观更新的队列节点(Update)上多了一个 revertLane 字段,定义见 packages/react-reconciler/src/ReactFiberHooks.js:169 附近的 Update 类型:
js
// packages/react-reconciler/src/ReactFiberHooks.js:169 (Update 类型节选)
revertLane: Lane,
当一次乐观更新被提交后,它不会像普通更新那样"永久生效",而是被标记为"在某个 revertLane 代表的 Transition 完成时被撤销"。回滚逻辑在 updateReducerImpl 中(:1482 起),后面 4.2 节会逐行讲。
2.4 HostTransitionContext(支撑 useFormStatus)
useFormStatus 底层其实只是读取了一个 Context。这个 Context 叫 HostTransitionContext,在 packages/react-reconciler/src/ReactFiberHooks.js:3474:
js
// packages/react-reconciler/src/ReactFiberHooks.js:3473
function useHostTransitionStatus(): TransitionStatus {
return readContext(HostTransitionContext);
}
HostTransitionContext 的值在 ReactFiberHostContext.js:110 处由 form 这个 host 组件在渲染时写入,因此任意后代组件都能读到"当前 form 是否正在提交、用的是什么 method、data 是什么"。
2.5 Fiber.refCleanup(支撑 ref 清理函数)
React 19 允许函数式 ref 返回一个清理函数。实现上,commit 阶段在挂载 ref 时把返回值存到了 fiber 新增的 refCleanup 字段(packages/react-reconciler/src/ReactFiberCommitEffects.js:798):
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:798
finishedWork.refCleanup = ref(instanceToUse);
卸载/替换时再调用它(:850 起)。这就是 React 19 的"ref 清理函数"特性在运行时的落脚点。
3. Actions 与 <form action>:从事件到 Transition
3.1 入口:FormActionEventPlugin
React 19 在事件系统中新增了 FormActionEventPlugin(packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js)。它的职责只有一件:把表单的提交(submit)事件,翻译成一次 Action 调用,并以 Transition 的方式执行。
插件先从 form 节点上取出 action prop(:71):
js
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:71
let action = coerceFormActionProp(
(getFiberCurrentPropsFromNode(form) as any).action,
);
coerceFormActionProp(:27)的规则很明确:
js
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:27
function coerceFormActionProp(actionProp: mixed) {
if (
actionProp == null ||
typeof actionProp === 'symbol' ||
typeof actionProp === 'boolean'
) {
return undefined; // 不是函数也不是字符串 URL → 当作普通表单(默认提交行为)
} else if (typeof actionProp === 'function') {
return actionProp as any; // 函数 → 客户端 Action
} else {
// 字符串 → 同构 Action / 服务器 Action 的 URL,做转义后作为提交地址
checkAttributeStringCoercion(actionProp, 'action');
return enableTrustedTypesIntegration ? actionProp : '' + (actionProp as any);
}
}
这里出现了一个关键分支:action 是函数时,走"客户端 Action";是字符串时,走"同构/服务器 Action 的提交地址"。这两种路径在浏览器端都由同一个插件统一接管,区别只是前者立即调用函数、后者发网络请求。
3.2 提交即 Transition:startHostTransition
当 action 是函数时,插件会调用 startHostTransition(packages/react-reconciler/src/ReactFiberHooks.js:3266)------注意它在 react-reconciler 包里,说明这是与渲染器无关的内核逻辑:
js
// packages/react-reconciler/src/ReactFiberHooks.js:3266
export function startHostTransition<F>(
formFiber: Fiber,
pendingState: TransitionStatus,
action: (F => mixed) | null,
formData: F,
): void {
if (formFiber.tag !== HostComponent) {
throw new Error('Expected the form instance to be a HostComponent...');
}
const stateHook = ensureFormComponentIsStateful(formFiber);
const queue: UpdateQueue<...> = stateHook.queue;
startHostActionTimer(formFiber);
startTransition(
formFiber,
queue,
pendingState,
NoPendingHostTransition,
action === null
? noop
: () => {
requestFormReset(formFiber); // 完成后自动重置表单
return action(formData);
},
);
}
请把这段代码和第 8 章的 startTransition 对照着看,它直接复用了 startTransition(:3117)。也就是说:点一次 <form action>,本质上就是自动帮你包了一次 startTransition。这就是为什么表单提交天然是非阻塞、可中断、带 pending 的------它走的就是并发内核。
3.3 把 host 组件"伪装"成有状态组件
ensureFormComponentIsStateful(:3309)是个很巧妙的实现:form 是一个 HostComponent(<form> 对应的原生 fiber),它本身没有 hooks 列表。但 React 需要给 form 一个 isPending 状态。于是它直接给这个 host fiber 手工挂载一个 state hook (:3335 起的 stateHook),并把它同时写进 formFiber.memoizedState 和 alternate.memoizedState(:3368),让协调器以为"这个 form 从一开始就有 hook"。
js
// packages/react-reconciler/src/ReactFiberHooks.js:3335 (节选)
const stateHook: Hook = {
memoizedState: NoPendingHostTransition,
baseState: NoPendingHostTransition,
baseQueue: null,
queue: newQueue,
next: null,
};
// 第二个 hook 仅用于"表单是否需要被 reset"
const resetStateHook: Hook = { ... };
stateHook.next = resetStateHook;
formFiber.memoizedState = stateHook;
const alternate = formFiber.alternate;
if (alternate !== null) {
alternate.memoizedState = stateHook;
}
这种"运行时给原生组件注入 hook"的手法,是 React 内部"鸭子类型"哲学的极致体现:只要 fiber 上有 memoizedState 指向一个合法的 hook 链表,协调器就会把它当函数组件一样处理更新。它也为我们理解"host 组件如何拥有 React 状态"打开了一个窗口。
3.4 自动重置表单:requestFormReset
注意 :3304 的 requestFormReset(formFiber)。它在 action 函数返回之后 被调用,意味着:表单字段会在动作完成后自动清空。这是"受控表单"体验的一大改进------以前你得在 onSubmit 里手动 e.target.reset()。
requestFormReset 的实现(:3377)同样复用了底层更新机制,但它有个重要约束:
js
// packages/react-reconciler/src/ReactFiberHooks.js:3378
export function requestFormReset(formFiber: Fiber) {
const transition = requestCurrentTransition();
if (transition === null) {
if (__DEV__) {
console.error(
'requestFormReset was called outside a transition or action. ' +
'To fix, move to an action, or wrap with startTransition.',
);
}
}
...
}
源码明确告诉我们:requestFormReset 必须在 transition / action 内部调用 。如果你在 await 之后、脱离了 transition 作用域的地方调用它,DEV 模式下会报错。这与 dispatchOptimisticSetState 对"乐观更新必须处于 transition 内"的约束(:3737 起)是同一套设计哲学------所有"带着自动回滚语义的乐观操作"都被强制绑定到 Transition 上,否则回滚无法被精确关联到对应的车道。
特别注意:第 10 章讲事件系统时会提到,浏览器原生
reset事件不会触发 React 的requestFormReset;它是 React 内部在 action 完成回调里主动调用的,与 DOM 的form.reset()是两条路径。
3.5 useFormStatus:只是读一个 Context
useFormStatus 听起来很神奇,但源码极其朴素(packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:67):
js
// packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:67
export function useFormStatus(): FormStatus {
const dispatcher = resolveDispatcher();
return dispatcher.useHostTransitionStatus();
}
而 useHostTransitionStatus 我们已经在 2.4 节看到,就是 readContext(HostTransitionContext)。所以 useFormStatus 等价于"读取 form 这个 host 组件写入的 Transition 状态 Context"。这意味着:
- 它只能读到离它最近的那个正在提交的
<form>的状态(Context 语义,就近匹配,见第 9 章 Context 部分)。 - 它返回的对象是
{ pending, data, method, action }(:16起的类型定义)。 - 当没有 form 在提交时,它返回的是一个被
Object.freeze的共享常量NotPending(:41),所以比较status.pending === false是稳定引用。
3.6 底层串联:一次 <form action> 的完整调用链
把上面所有线索串起来,一次点击"提交按钮"引发的全链路如下(同时也是你要记住的调用链):
这一节是理解后续所有"Action 类钩子"的基础:它们都在复用同一条 Transition 通道,区别只在于"状态存在哪、谁负责回滚、谁负责顺序"。
4. useOptimistic:乐观更新与 revertLane 自动回滚
4.1 公开 API 与最简心智模型
useOptimistic 的公开签名(packages/react/src/ReactHooks.js:226):
js
// packages/react/src/ReactHooks.js:226
export function useOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
const dispatcher = resolveDispatcher();
return dispatcher.useOptimistic(passthrough, reducer);
}
它返回 [optimisticState, addOptimistic]。心智模型:
passthrough是"真实源状态"(通常来自服务端或useState);addOptimistic(action)会在当前渲染"叠加"一个乐观结果;一旦底层的 Transition 完成,所有未确认的乐观更新会按revertLane自动被撤销,界面回退到passthrough;随后真实状态更新到达,界面平滑切换到真实结果。
4.2 mount 阶段:一个没有 eager 优化的队列
mountOptimistic(packages/react-reconciler/src/ReactFiberHooks.js:1974):
js
// packages/react-reconciler/src/ReactFiberHooks.js:1974
function mountOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
const hook = mountWorkInProgressHook();
hook.memoizedState = hook.baseState = passthrough;
const queue: UpdateQueue<S, A> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: null, // 关键:乐观更新不做 eager 优化
lastRenderedState: null,
};
hook.queue = queue;
const dispatch: A => void = dispatchOptimisticSetState.bind(
null, currentlyRenderingFiber, true, queue,
) as any;
queue.dispatch = dispatch;
return [passthrough, dispatch];
}
注意 lastRenderedReducer: null。普通 useState 会在 dispatch 时同步 算出 eager state(避免一次额外渲染),但乐观更新刻意不做这件事------因为乐观更新迟早要被回滚,提前计算结果没有意义,反而可能在回滚逻辑上引入复杂度。源码注释(:1984)也直接写了:Optimistic state does not use the eager update optimization.
4.3 update 阶段:每帧把 baseState 重置为 passthrough
这是 useOptimistic 最核心、也最容易被误解的一点。updateOptimisticImpl(:2013):
js
// packages/react-reconciler/src/ReactFiberHooks.js:2013
function updateOptimisticImpl<S, A>(
hook: Hook, current: Hook | null,
passthrough: S, reducer: ?(S, A) => S,
): [S, (A) => void] {
// 每帧都把 baseState 重置为 passthrough!
hook.baseState = passthrough;
const resolvedReducer: (S, A) => S =
typeof reducer === 'function' ? reducer : (basicStateReducer as any);
return updateReducerImpl(hook, currentHook as any as Hook, resolvedReducer);
}
为什么必须每帧 重置 baseState = passthrough?原因在于 React 的 reducer 更新模型(第 7 章 updateReducerImpl):baseState 是"上一次确认状态的基准",所有待处理的更新都叠加在它之上。对于乐观更新,我们不希望上一次的乐观结果成为下一个乐观结果的基准 ------因为乐观结果随时会被回滚。所以每次渲染都从"真实源 passthrough"重新叠加,保证:
- 如果
passthrough没变、也没有新的乐观 action,返回的就是passthrough本身("passthrough"这个名字即"透传"之意)。 - 如果有多个乐观 action 排队,它们会按序叠加在
passthrough之上。
源码在 :2019 的注释把这一点说得很直白:Optimistic updates are always rebased on top of the latest value passed in as an argument... If there are no pending updates, it will be returned as-is.
4.4 dispatch:乐观更新是如何被"挂"上去的
addOptimistic 实际指向 dispatchOptimisticSetState(:3728)。它的关键逻辑是给更新打上 revertLane:
js
// packages/react-reconciler/src/ReactFiberHooks.js:3773 (节选)
const transition = requestCurrentTransition();
...
// 乐观更新提交是同步的(SyncLane),但回滚车道跟它绑定的 transition
const lane =
enableGestureTransition && transition !== null && transition.gesture
? GestureLane
: SyncLane;
const update: Update<S, A> = {
lane: lane,
// 提交后,用与 transition 相同的车道"撤销"这次乐观更新
revertLane: requestTransitionLane(transition),
...
};
注意第 3773 行附近的注释:For regular Transitions an optimistic update commits synchronously. 即:乐观更新本身是 SyncLane 同步提交的 ,这样用户能立刻看到"乐观结果";而它的"撤销"被推迟到 revertLane 对应的 transition 完成时。
4.5 回滚的真相:revertLane 在 reducer 里被跳过
那么"回滚"到底是怎么发生的?秘密在 updateReducerImpl 处理更新队列时(:1482):
js
// packages/react-reconciler/src/ReactFiberHooks.js:1482
if (isSubsetOfLanes(renderLanes, revertLane)) {
// 该乐观更新绑定的 transition 已完成 → 假装这个更新不存在,跳过它
update = update.next;
if (revertLane === peekEntangledActionLane()) {
didReadFromEntangledAsyncAction = true;
}
continue;
} else {
// transition 还没完成 → 应用这个乐观更新,但把它留在队列里以便将来回滚或 rebase
const clone: Update<S, A> = {
lane: NoLane,
revertLane: update.revertLane, // 保留 revertLane,以便后续判断何时撤销
...
};
...
}
这段逻辑是整章最关键的几行之一,务必读透:
- 当一次渲染的
renderLanes包含了该乐观更新的revertLane(即:绑定的 transition 已经结束),React 直接continue跳过这个更新 ------相当于"这个乐观结果作废"。界面因此回到passthrough(或passthrough之上新的真实更新)。 - 当 transition 还没完成,
renderLanes不包含revertLane,则应用乐观更新,但把它的revertLane保留在"克隆体"里,留在队列中。等将来某次渲染renderLanes包含了revertLane,它就会被上面的isSubsetOfLanes分支跳过------这就是"自动回滚"的实现:不是主动 setState 还原,而是"在 transition 完成的那次渲染里,不再应用这个更新"。
这种"用车道(Lane)做时间锚点"的手法,是 React 并发模型里最优雅的设计之一:回滚不需要额外的撤销队列,只需要一个车道标记 。它天然解决了"并发打断"下的回滚难题------无论中途被什么优先级打断,revertLane 始终精确指向"绑定的 transition 何时结束"。
4.6 与 startTransition 的关系
你可能会问:如果 useOptimistic 的 addOptimistic 必须包在 transition 里,那它和 useTransition 是什么关系?
答案在 dispatchOptimisticSetState 开头(:3734):
js
// packages/react-reconciler/src/ReactFiberHooks.js:3734
const transition = requestCurrentTransition();
if (__DEV__) {
if (transition === null) {
if (peekEntangledActionLane() !== NoLane) {
// 有一个挂起的 async action,不报警(React 会把它关联到该 action)
} else {
console.error(
'An optimistic state update occurred outside a transition or action...',
);
}
}
}
即:addOptimistic 必须在 startTransition 或 action 内部调用 ,否则 DEV 报警。当你用 <form action> 或 useActionState 时,它们内部已经帮你包好了 transition,所以你"免费"获得了乐观更新的回滚保证;当你手动用 useOptimistic 时,必须自己用 startTransition 包裹,否则回滚车道无法确定(revertLane 会落到默认车道,行为不符合预期)。
4.7 一个完整的并发回滚时序
下面这张 mermaid 把 4.4~4.6 串成时序,建议在理解源码后对照阅读:
5. useActionState:[state, dispatch, isPending] 的实现
5.1 一个名字,两个入口
先澄清一个源码事实(你 prompt 里也提到了):useActionState 在源码里也叫 useFormState,两者指向同一个实现。
在 packages/react/src/ReactHooks.js:234 中,公开导出的是 useActionState:
js
// packages/react/src/ReactHooks.js:234
export function useActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const dispatcher = resolveDispatcher();
return dispatcher.useActionState(action, initialState, permalink);
}
但在 dispatcher 表里,useActionState 和 useFormState 都映射到了 mountActionState (packages/react-reconciler/src/ReactFiberHooks.js:4839 起):
js
// packages/react-reconciler/src/ReactFiberHooks.js:4839 (DispatcherOnMount 节选)
useFormState<S, P>(action, initialState, permalink) {
...
return mountActionState(action, initialState, permalink); // 与 useActionState 同函数
},
useActionState<S, P>(action, initialState, permalink) {
...
return mountActionState(action, initialState, permalink);
},
所以你在源码里搜 useFormState 找不到"独立实现",因为它就是 useActionState 的曾用名/别名。这一点在写任何 React 19 文章时都值得点明,避免读者在源码里迷路。
5.2 三个返回值,三个内部钩子
mountActionState(:2380)内部不是用一个状态变量,而是组合了三个独立机制:
- state hook:存放真实的 action 返回值(可能是一个 thenable)。
- pending hook :一个
mountStateImpl(false)的乐观 pending 状态(:2425),复用useTransition的 pending 表达。 - action queue hook :存放共享的
ActionStateQueue(:2440),这是串行的核心。
js
// packages/react-reconciler/src/ReactFiberHooks.js:2380 (节选)
function mountActionState<S, P>(action, initialStateProp, permalink) {
...
// 1) state hook:state 被存成一个 thenable,由 use 算法在渲染时解包
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = stateHook.baseState = initialState;
const stateQueue = { pending: null, lanes: NoLanes,
dispatch: null, lastRenderedReducer: actionStateReducer,
lastRenderedState: initialState };
stateHook.queue = stateQueue;
const setState = dispatchSetState.bind(null, currentlyRenderingFiber, stateQueue);
// 2) pending hook:像 transition 的 pending 一样被乐观地追踪
const pendingStateHook = mountStateImpl(false as Thenable<boolean> | boolean);
const setPendingState = dispatchOptimisticSetState.bind(
null, currentlyRenderingFiber, false, pendingStateHook.queue,
);
// 3) action queue hook:所有实例共享,串行执行
const actionQueueHook = mountWorkInProgressHook();
const actionQueue: ActionStateQueue<S, P> = {
state: initialState, dispatch: null, action, pending: null,
};
actionQueueHook.queue = actionQueue;
const dispatch = (dispatchActionState as any).bind(
null, currentlyRenderingFiber, actionQueue, setPendingState, setState,
);
actionQueue.dispatch = dispatch;
// 把 action 函数暂存在 memoizedState,用于探测 action 变化
actionQueueHook.memoizedState = action;
return [initialState, dispatch, false];
}
注意第 2402 行注释:The state is stored in a thenable which is then unwrapped by the use algorithm during render. 这意味着 useActionState 返回的 state 之所以能"在 action 完成前是旧值、完成时变新值",是因为它把 state 存储成一个 thenable,交给 use 的挂起机制去解包。
5.3 串行队列:为什么需要它
useActionState 的核心承诺是:多个 dispatch 按顺序执行,后一个拿到的 state 是前一个的结果 (因为 action 签名是 (prevState, payload) => nextState)。如果多个 dispatch 并发进来了怎么办?答案就是 2.2 节的环形链表 ActionStateQueue.pending。
dispatchActionState(:2106)把新 action 挂到环形链表的尾部:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2158
const last = actionQueue.pending;
if (last === null) {
// 没有排队的 action,这是第一个,立即运行
actionNode.next = actionQueue.pending = actionNode;
runActionStateAction(actionQueue, actionNode);
} else {
// 已有 action 在跑,加入队列尾部
const first = last.next;
actionNode.next = first;
actionQueue.pending = last.next = actionNode;
}
而 runActionStateAction(:2172)运行时,如果当前处于 transition 内(node.isTransition),会重建一个 transition 上下文 再执行 action(:2186 起),并通过 onStartTransitionFinish 把这次更新的"完成"与 React 的 transition 收尾钩子关联起来。action 返回的若是 thenable,则由 handleActionReturnValue(:2270)监听其 then,在 resolves 时调用 onActionSuccess(:2312):
js
// packages/react-reconciler/src/ReactFiberHooks.js:2289
thenable.then(
(nextState) => { onActionSuccess(actionQueue, node, nextState); },
(error) => onActionError(actionQueue, node, error),
);
onActionSuccess 做两件事(:2312):
- 把
node.status置为'fulfilled'并notifyActionListeners(通知use的挂起等待者"可以醒了")。 - 更新
actionQueue.state = nextState,然后从环形链表里弹出自己,运行下一个 pending 节点(:2338)。
错误处理 也在这里:一旦某个 action 抛错,onActionError(:2343)会把后续所有节点都标记为 rejected,并把 actionQueue.action = null------这样后续 dispatch 会被 dispatchActionState 开头检查到 currentAction === null 而直接 return (:2118),实现"一个失败,整条队列熔断"。
5.4 渲染期:用 use 解包 thenable 来悬挂
更新时(updateActionStateImpl,:2481),state 可能是个 thenable:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2496
let state: Awaited<S>;
if (typeof actionResult === 'object' && actionResult !== null &&
typeof actionResult.then === 'function') {
try {
state = useThenable(actionResult); // 悬挂直到 action 完成
} catch (x) {
if (x === SuspenseException) {
throw SuspenseActionException; // 标记这是 Action 类型的 Suspense(用于 Profiler)
} else {
throw x;
}
}
} else {
state = actionResult;
}
这里有一个很关键的设计细节:useActionState 让组件在 action 未完成时"挂起" 。也就是说,如果你在 useActionState 的作用域里渲染 state,而 action 还没返回,整个组件(或其最近的 Suspense 边界)会显示为 fallback。这看起来"激进",但它是刻意的------它让"表单提交期间界面停留在旧值"这件事,由 Suspense 统一表达,而不是让你去手动判断 isPending 来决定渲染什么。
5.5 监听 action 变化:一个被动 effect
useActionState 还处理了一个边界情况:如果 action 函数本身变了(比如父组件传了新的函数引用),需要在 commit 阶段更新队列里的 actionQueue.action。源码在 :2524:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2524
const prevAction = actionQueueHook.memoizedState;
if (action !== prevAction) {
currentlyRenderingFiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive, createEffectInstance(),
actionStateActionEffect.bind(null, actionQueue, action), null,
);
}
actionStateActionEffect(:2539)只在 effect 里做一件事:actionQueue.action = action。这就是把"最新 action 引用"同步进共享队列------不影响正在排队的旧 action(旧 action 节点已固化了当时的 action 引用,见 :2125 注释"概念上就像把 action 发到了远程 worker")。
5.6 useActionState 与 useTransition 的异同
| 维度 | useTransition | useActionState |
|---|---|---|
| 公开返回值 | [isPending, startTransition] |
[state, dispatch, isPending] |
| pending 来源 | 内部 state hook | 内部 pending hook(同机制) |
| 是否能携带返回值 | 否(仅副作用) | 是(state 由 thenable 解包) |
| 串行保证 | 无(多次 start 各自独立) | 有(环形队列串行) |
| 悬挂行为 | 不悬挂,仅 pending | 渲染 state 时悬挂 |
| 底层 | startTransition + HostTransitionContext |
startTransition + 共享队列 + use 解包 |
6. use 原语:统一读取 Context 与 Promise
6.1 公开 API
js
// packages/react/src/ReactHooks.js:207
export function use<T>(usable: Usable<T>): T {
const dispatcher = resolveDispatcher();
return dispatcher.use(usable);
}
use 是 React 19 第一个允许在条件语句、循环里调用 的"类 hook"------因为它本质上不是 hook,而是"读取器"。源码在 ReactFiberHooks.js:1159:
js
// packages/react-reconciler/src/ReactFiberHooks.js:1159
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
// 这是一个 thenable → 走 useThenable
const thenable: Thenable<T> = usable as any;
return useThenable(thenable);
} else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
// 可恢复错误,直接穿过(Fiber 是最终渲染器,无需恢复子树)
return undefined as any;
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
// 这是一个 Context → 走 readContext
const context: ReactContext<T> = usable as any;
return readContext(context);
}
}
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}
三种分支:
- thenable (
usable.then是函数)→useThenable(挂起读取)。 REACT_RECOVERABLE_TYPE→ 直接返回undefined(一种错误恢复机制,第 9 章之外的边角,本文不展开)。REACT_CONTEXT_TYPE→readContext(读取 Context,与useContext等价)。
6.2 useThenable:挂起、缓存位置、重放
useThenable(:1098)是 use 读取 Promise 的核心:
js
// packages/react-reconciler/src/ReactFiberHooks.js:1098
function useThenable<T>(thenable: Thenable<T>): T {
const index = thenableIndexCounter; // 记录这是本组件第几个 use(thenable)
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
const result = trackUsedThenable(
thenableState, thenable, index,
__DEV__ ? currentlyRenderingFiber : null,
);
...
return result;
}
trackUsedThenable(packages/react-reconciler/src/ReactFiberWorkLoop.js 或 ReactFiberSuspense 相关,第 9 章详述)的行为是:
- 若 thenable 已
fulfilled,直接返回.value。 - 若
pending,则抛出一个SuspenseException并附带一个"唤醒函数",让当前渲染挂起,等待 Suspense 边界接管(第 9 章核心机制)。 - 若
rejected,抛出.reason。
重放(replay)逻辑 是 useThenable 后半段(:1124 起)最精妙的部分。注释原文解释了:当某个 use 挂起导致组件被重放时,React 会先用"re-render" dispatcher 重新执行组件;如果挂起的 use 之后还有别的 hook,它们在上一次尝试里还没被处理,所以重放后需要从 re-render dispatcher 切回 mount/update dispatcher。源码通过检查"当前 hook 是否还有剩余"来决定是否切换(:1133):
js
// packages/react-reconciler/src/ReactFiberHooks.js:1133
if (nextWorkInProgressHook !== null) {
// 上一次尝试还有剩余的 hook 没处理
} else {
// 没有剩余 hook 了,退出 re-render 模式,切回正常 mount/update dispatcher
const currentFiber = workInProgressFiber.alternate;
if (__DEV__) {
if (currentFiber !== null && currentFiber.memoizedState !== null) {
ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
} else {
ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
}
} else {
ReactSharedInternals.H =
currentFiber === null || currentFiber.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}
}
这段逻辑保证了:即使组件在 use(thenable) 之后还有 useState、useEffect 等普通 hook,重放也不会破坏 hook 调用顺序的一致性 。这是 use 能够"安全地出现在条件/循环里"的底层支撑------因为即使它挂起,React 也记得"后面还有几个 hook、分别是什么类型"。
6.3 use 与 useContext 的关系
use(Context) 和 useContext(Context) 在功能上等价,都走到 readContext(:1173)。区别是:
useContext在ReactHooks.js:56有一个 DEV 检查,禁止你传Context.Consumer(会报警)。use走的是更通用的use分派,支持 thenable 与 Context 二选一。
这印证了本章开头的论断:use 是"读取器"的统一入口,而 useContext 是它的一个特化别名。
6.4 use 的悬挂与 Suspense 的联动(呼应第 9 章)
useActionState 在 5.4 节调用 useThenable 时,如果 thenable 还没完成,会抛出 SuspenseException(被 updateActionStateImpl 捕获后重新以 SuspenseActionException 抛出,:2511)。这正是 use 与第 9 章 Suspense 的接口契约:use(thenable) 不直接渲染 fallback,而是把"挂起"这件事交给最近的 Suspense 边界去决定。
7. ref 作为普通 prop:forwardRef 为何不再必需
7.1 公开事实
React 19 最大的"写法简化"之一:你不再需要用 forwardRef 就能把 ref 传给函数组件。React.forwardRef 在 19 中仍然可用(向后兼容),但不再是必需。
公开导出仍在 packages/react/src/ReactForwardRef.js:12,内部只是把 render 函数包成一个 $$typeof: REACT_FORWARD_REF_TYPE 的元素类型(:51)。它之所以还工作,是因为协调器对 ForwardRef 节点做了特殊分支(见下)。
7.2 协调器如何处理 ref prop:updateForwardRef
关键变化在 packages/react-reconciler/src/ReactFiberBeginWork.js:404 的 updateForwardRef:它会在把 props 传给 render 函数之前,把 ref 从 props 里摘出来,单独作为第二个参数传给旧的 forwardRef render:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:404
function updateForwardRef(current, workInProgress, Component, nextProps, renderLanes) {
...
const ref = workInProgress.ref;
let propsWithoutRef;
if ('ref' in nextProps) {
// ref 现在就是 props 的一个普通字段,但 forwardRef 期望它不出现在 props 里
propsWithoutRef = {};
for (const key in nextProps) {
if (key !== 'ref') {
propsWithoutRef[key] = nextProps[key];
}
}
} else {
propsWithoutRef = nextProps;
}
...
// 单独把 ref 作为第二参数传入
nextChildren = renderWithHooks(current, workInProgress, render, propsWithoutRef, ref, renderLanes);
}
这段代码的存在本身就是一个"考古证据":React 19 已经把 ref 当作普通 prop 了 ('ref' in nextProps 成立),只是为了让老的 forwardRef 组件不报错,才在 updateForwardRef 里额外做了一次"把 ref 摘出来单独传"的兼容处理。
那么普通函数组件呢?普通函数组件的 nextProps 里就直接带着 ref,updateFunctionComponent(:1453)把它原样传进你的组件函数,于是你的组件函数签名 (props) 里 props.ref 就能拿到了。没有额外的拆分逻辑------这就是"ref 作为普通 prop"的本质:JSX 转换后 ref 不再被 createElement 单独抽走,而是留在 props 中,协调器也原样透传。
7.3 commit 阶段:ref 挂载与清理(ref cleanup 函数)
挂载 ref 的逻辑在 commitAttachRef(packages/react-reconciler/src/ReactFiberCommitEffects.js:757)。React 19 在这里新增了对"函数 ref 返回清理函数"的支持:
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:794
if (typeof ref === 'function') {
if (shouldProfile(finishedWork)) {
try {
startEffectTimer();
finishedWork.refCleanup = ref(instanceToUse); // 返回值存为 refCleanup
} finally {
recordEffectDuration(finishedWork);
}
} else {
finishedWork.refCleanup = ref(instanceToUse);
}
} else {
if (__DEV__) {
if (typeof ref === 'string') {
console.error('String refs are no longer supported.'); // string ref 已移除!
} else if (!ref.hasOwnProperty('current')) {
console.error('Unexpected ref object provided...');
}
}
ref.current = instanceToUse; // 对象 ref 仍写 .current
}
两个值得注意的事实:
- 函数 ref 现在可以返回一个清理函数 ,返回值被存进
finishedWork.refCleanup(:798)。这与useEffect的"返回清理函数"在概念上完全对称。 - String refs 在 React 19 已彻底移除 :
:810的console.error('String refs are no longer supported.')表明,如果你在 JSX 里写ref="myRef",DEV 下会直接报错而不是静默降级。
清理发生在 safelyDetachRef(:842):
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:846
const ref = current.ref;
const refCleanup = current.refCleanup;
if (ref !== null) {
if (typeof refCleanup === 'function') {
try {
...
refCleanup(); // 调用函数 ref 返回的清理函数
} ...
} else if (typeof ref === 'function') {
ref(null); // 没有返回清理函数时,仍按旧语义传 null
} else {
ref.current = null; // 对象 ref 置空
}
}
7.4 useImperativeHandle 的清理语义同步升级
useImperativeHandle 的内部 effect imperativeHandleEffect(:2817)也相应支持返回清理函数:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2821
if (typeof ref === 'function') {
const refCallback = ref;
const inst = create();
const refCleanup = refCallback(inst);
return () => {
if (typeof refCleanup === 'function') {
refCleanup(); // 支持 ref 清理函数
} else {
refCallback(null); // 旧语义兜底
}
};
}
这证明"ref 清理函数"是 React 19 在全链路(原生 ref、函数 ref、imperative handle)统一引入的能力,而非零散修补。
7.5 向后兼容结论
- 用
forwardRef:仍然工作,但updateForwardRef会多一次"摘 ref"的兼容处理(:427起)。 - 不用
forwardRef,直接props.ref:React 19 推荐写法,零成本。 - String ref:彻底移除,DEV 报错。
- 对象 ref(
{current}) :仍完全支持,写.current。 - 函数 ref 返回清理函数:React 19 新增,与 effect 清理对称。
8. 文档元数据:在组件里渲染 <title>/<meta>/<link>
8.1 现象与心智模型
React 19 允许你在任意组件里直接写:
jsx
function Page() {
return (
<div>
<title>我的页面标题</title>
<meta name="description" content="..." />
<link rel="stylesheet" href="/a.css" />
<h1>正文</h1>
</div>
);
}
即使 <title> 写在 <div> 里面,React 也会在 DOM 提交时把它自动提升到 document.head 。这套机制在源码里称为 hoisting(提升) 与 host singletons(宿主单例)。
8.2 单例宿主:head / html / body
html、head、body 在 React DOM 里被标记为"宿主单例"(SingletonType,packages/react-dom-bindings/src/client/ReactDOMComponent.js:1498)。它们的处理逻辑与普通元素不同:整个文档只有一个 <head>,React 会直接复用 document.head 而不是创建新节点。
getHoistableRoot / isHostSingleton(在 ReactFiberConfigDOM.js:4947):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4947
function isHostSingleton(type: string): boolean {
return type === 'html' || type === 'head' || type === 'body';
}
而 head 这个单例的"真实 DOM 节点"就是 ownerDocument.head(ReactFiberConfigDOM.js:4978):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4978
case 'head': {
const head = ownerDocument.head;
if (!head) {
throw new Error(
'React expected a <head> element (document.head) to exist...',
);
}
return head;
}
8.3 元数据节点的识别与排除
当 React 在 head 中做 hydration 或匹配已有节点时,需要区分"哪些 <meta>/<link>/<style>/<script> 是 React 自己提升上去的、哪些是第三方脚本/浏览器扩展注入的"。源码在 ReactFiberConfigDOM.js:4133 起对每种标签做了启发式判断:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:4137
case 'meta': {
// 唯一"退出提升"的方式是给 meta 加 itemprop 属性
if (!element.hasAttribute('itemprop')) {
break; // 当作 hoistable,可复用/提升
}
return element; // 有 itemprop 则视为用户自定义,不提升
}
case 'link': {
const rel = element.getAttribute('rel');
if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) {
break; // 样式资源
} else if (
rel !== anyProps.rel ||
element.getAttribute('href') !== (anyProps.href == null || anyProps.href === '' ? null : anyProps.href) ||
element.getAttribute('crossorigin') !== (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) ||
element.getAttribute('title') !== (anyProps.title == null ? null : anyProps.title)
) {
break; // 用高熵属性(rel/href/crossOrigin/title)区分不同 link
}
return element;
}
注释(:4138、:4149)解释了设计权衡:React 无法 100% 区分"第三方注入的 link"和"自己要提升的 link",于是采用 rel + href + crossOrigin + title 这类高熵属性做近似匹配------绝大多数情况下能正确区分,极少数边缘情况(如扩展在 body 前插入 div)仍可能 hydration 不匹配,但那是历史既有的边界。
8.4 提升的提交时机
元数据节点的"提升"发生在 commit 阶段(第 4 章 commit 的 host 副作用里)。React 19 把这些特殊节点标记为 HostHoistable(commitAttachRef 的 switch 里就包含 HostHoistable 分支,:762)。配合 head 单例,React 在把 <title> 等节点挂载到 fiber 树的同时,把它们插入到真实 document.head,而不是它们在 JSX 里所处的父节点之下。
8.5 hydration 下的处理
SSR 场景下,React 19 的 Fizz 服务端渲染会在 HTML 里直接输出 <title>/<meta>/<link> 到 <head>(见 ReactFizzConfigDOM.js 相关 precedence 处理)。客户端 hydration 时:
- 对
head单例,React 调用clearHead(ReactFiberConfigDOM.js:4042)在必要时清理已有子节点。 - 对元数据节点,用 8.3 节的启发式匹配已有 DOM,避免重复插入或 hydration mismatch。
源码注释(:4135)特别说明 title 默认全部视为可匹配 :因为文档里通常只有一个 title,且如果 <title> 在 <svg> 上下文或带 itemProp,那它本来就不是文档标题。这种"默认信任 + 少量例外"的策略,让绝大多数应用的 SSR/CSR 一致性无缝。
8.6 与第 4、9 章的呼应
元数据提升复用了第 4 章的"host 组件提交"通道,只是目标父节点从 fiber 父节点换成 document.head;它的 hydration 匹配逻辑又复用了第 9 章 Suspense/hydration 的"节点复用一个已有 DOM"机制。再次印证:React 19 的新特性都在复用既有内核,而非另起炉灶。
9. <Context> 直接作为 Provider:语法糖的真相
9.1 现象
React 19 允许:
jsx
const ThemeContext = createContext('light');
// 旧写法
<ThemeContext.Provider value="dark"><App /></ThemeContext.Provider>
// 新写法(等价)
<ThemeContext value="dark"><App /></ThemeContext.Provider>
9.2 源码真相:Provider 就是 Context 自己
打开 packages/react/src/ReactContext.js:35:
js
// packages/react/src/ReactContext.js:35
context.Provider = context; // Provider 直接指向 context 自身
context.Consumer = {
$$typeof: REACT_CONSUMER_TYPE,
_context: context,
};
context.Provider === context 。这意味着 <ThemeContext.Provider> 和 <ThemeContext> 在 JSX 层面对应的是完全相同的 React 元素类型 ($$typeof: REACT_CONTEXT_TYPE,且 _context 通过元素类型的 .Provider/_context 链可达)。协调器在 beginWork 里遇到 REACT_CONTEXT_TYPE 时,走的永远是同一套 Provider 逻辑(写入 context._currentValue、建立 Context 栈),根本不在乎你写的是 .Provider 还是直接写 Context 对象。
9.3 为什么这样设计是安全的
因为 createContext 返回的 context 对象的 $$typeof 就是 REACT_CONTEXT_TYPE,而 <Context> 这种 JSX 写法在 JSX 运行时(ReactJSXElement.js)里就是 jsx(ThemeContext, {value})------第一个参数正是 context 对象本身。协调器从元素类型上无法区分"这是 Context 还是 Context.Provider",因为它们是同一个引用。这是个非常干净的"对象即 Provider"设计,不需要任何额外的解析分支。
9.4 仍保留 .Provider 的原因
向后兼容。大量存量代码、类型定义(如 React.Provider 类型)、以及需要显式表达"这是 Provider"的语义场景,仍然依赖 .Provider 写法。源码让它俩指向同一引用,零成本地同时支持两种写法。
10. effect 返回清理函数:真实语义澄清(避免编造)
你 prompt 里提到"useEffect 支持异步""effect 回调返回清理函数"。这里必须以源码为准做精确澄清,因为这两点容易与社区误传混淆。
10.1 "返回清理函数"不是新东西,且只能是同步函数
effect 回调返回一个清理函数,从 React 16.8 起就支持。ReactFiberCommitEffects.js:175 把 create 的返回值直接存为 inst.destroy:
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:175
destroy = create();
inst.destroy = destroy;
而在 unmount 时(:266)调用 inst.destroy。React 19 在此处没有任何新语义。
10.2 异步 effect 仍然不被支持(源码明确拦截)
社区常误传"React 19 支持了 async useEffect"。源码明确反对 。在 DEV 下,如果清理函数不是函数(包括你返回了一个 Promise/thenable),会报错(:188、:204):
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:204
} else if (typeof destroy.then === 'function') {
addendum =
'\n\nIt looks like you wrote ' + hookName +
'(async () => ...) or returned a Promise. Instead, write the async ' +
'function inside your effect and call it immediately...';
...
console.error(
'%s must not return anything besides a function, which is used for clean-up.%s',
n, a,
);
}
也就是说:effect 回调本身仍是同步执行的;你不能在 useEffect(async () => {...}) 里直接 await,也不能让 effect 返回一个 Promise 当作清理函数。如果你需要异步,标准写法是在 effect 内部定义并立即调用一个 async 函数(源码 addendum 给出的正是这个模板)。
10.3 React 19 真正与 effect 相关的新语义:清理顺序与 StrictMode
React 19 在 StrictMode 下对 effect 的"双调用"(mount → unmount → mount)行为更严格,目的是暴露"忘记清理"的 bug。mountEffectImpl 在 DEV + StrictEffectsMode 下会多加 MountPassiveDevEffect 等 flag(:2701 起),确保开发期能完整演练"挂载-清理-挂载"周期。这是行为强化,不是语法新增。
10.4 与"ref 清理函数"的区别要分清
不要把"ref 清理函数"(第 7.3 节,React 19 新增)与"effect 清理函数"(一直都有)混为一谈:
- ref 清理函数 :函数式 ref
(node) => {...; return () => cleanup}返回的清理,是 React 19 新增。 - effect 清理函数 :
useEffect(() => { return () => cleanup }),一直都有,且清理函数必须是同步函数。
11. 其它破坏性变更与 flushSync 行为
11.1 flushSync 行为
flushSync(packages/react-dom-bindings/src/shared/ReactDOMFlushSync.js)用于在同步时机强制刷新更新。React 19 下它的语义基本延续:在 flushSync 回调内触发的更新会被同步、紧急地提交,不参与并发可中断调度。需注意的是:flushSync 内的更新会跳出并发模型的优先级保护,应仅用于"必须立刻反映到 DOM"的极少数场景(如测量、第三方库集成)。
它在 ReactDOMRoot.js:166 被 createRoot 的产物间接调用(flushSyncWork),属于 react-dom 渲染器对内核调度(flushSyncWork 来自调度器)的桥接。
11.2 函数组件移除 defaultProps
在 React 19 之前,函数组件可以像类组件一样写 MyComponent.defaultProps = {...}。React 19 移除了这一支持。证据在 updateFunctionComponent(packages/react-reconciler/src/ReactFiberBeginWork.js:1453):整段函数里不再有对 Component.defaultProps 的解析调用 ------nextProps 被原样传给 renderWithHooks(:1525)。对比早期版本(会在 mount 阶段调用 resolveDefaultProps),19.3.1 的源码里函数组件路径已无此分支。
官方建议:用默认参数 (function C({x = 1}) {})或解构默认值 替代 defaultProps。类组件的 defaultProps 仍保留(类组件路径不同,本文不展开)。
11.3 移除 string ref(已在 7.3 节给出源码证据)
:810 的 console.error('String refs are no longer supported.') 是铁证。任何 <div ref="name" /> 写法在 React 19 都会 DEV 报错。
11.4 移除 legacy Context(contextTypes)
packages/react-reconciler/src/ReactFiberBeginWork.js:1494 的报错信息:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:1494
console.error(
'%s uses the legacy contextTypes API which was removed in React 19. ' +
'Use React.createContext() with React.useContext() instead.',
componentName,
);
即 React 19 移除了 contextTypes / childContextTypes 这套老 Context API,统一走到 createContext + useContext(亦即第 7 章的 readContext)。
11.5 移除 module 模式工厂组件等
React 19 还移除了一些更早期的边缘 API(如 ReactDOM.render 旧入口已由 createRoot 取代,已在第 1 章铺垫)。这些不在本章核心,但都属于"清理历史包袱、让内核更一致"的同一波动作。
12. 与前序章节的呼应:一切都建立在 Fiber/Lane/并发/Hooks 之上
| 本章特性 | 依赖的底层(章节) | 具体落点 |
|---|---|---|
<form action> |
事件系统(10)、Transition(7/8) | FormActionEventPlugin → startHostTransition → startTransition |
useOptimistic |
Lane(7)、reducer 更新(6) | revertLane + updateReducerImpl 跳过逻辑 |
useActionState |
Transition(7/8)、Suspense(9)、Hooks(3) | 串行队列 + useThenable 悬挂 + pending hook |
use |
Suspense(9)、Context(9) | useThenable/readContext + 重放 dispatcher 切换 |
| ref 作为 prop | 协调(4)、commit(4) | updateForwardRef 摘 ref;commitAttachRef 写 refCleanup |
| 文档元数据 | 协调/commit(4)、hydration(9) | head 单例 + HostHoistable + 启发式匹配 |
<Context> 作 Provider |
Context(9) | context.Provider = context |
| effect/ref 清理函数 | commit 副作用(4) | inst.destroy / refCleanup |
这张表就是本章的核心论点:React 19 没有发明新引擎,它把第 4~10 章的引擎零件,按"Action 范式"重新拼装成了更顺手的 API。
13. 边界场景与坑
13.1 useOptimistic 必须包在 transition 里
如果你在事件处理函数里直接调用 addOptimistic 而不包 startTransition,DEV 会报"optimistic state update occurred outside a transition or action"(:3764)。后果是 revertLane 落到默认车道,回滚时机不可预期。正确写法:
js
// 推荐
function handleClick() {
startTransition(() => {
addOptimistic(action);
saveToServer();
});
}
// 或用 useActionState 的 dispatch / <form action>,它们自带 transition
13.2 useActionState 会让组件悬挂
因为 state 是 thenable 解包(5.4 节),action 未完成时组件会挂起。如果你不想要悬挂行为,应在渲染时基于 isPending 自行决定展示内容,并把 state 的使用限制在"已确认数据"区域,或就近包裹 <Suspense fallback={...}>。
13.3 串行队列的熔断
一旦某个 action 抛错,actionQueue.action 被置 null(:2362),后续所有 dispatch 都会被 :2118 的 if (currentAction === null) return; 静默丢弃。坑 :熔断后即使你换了新 action 函数,也得等 effect(actionStateActionEffect,:2539)把 actionQueue.action 重新置回才恢复------而那个 effect 在"队列已为空"时才会跑。因此错误恢复需要显式处理(如重新挂载组件,或 React 内部机制复位)。
13.4 ref 清理函数的调用时机
refCleanup 在 safelyDetachRef(:846)里、即 ref 被替换/卸载时调用,与 effect 清理同属 commit 副作用阶段。注意:函数 ref 返回非函数 (如返回 undefined)时,refCleanup 为 undefined,走旧语义 ref(null)(:855 的 else 分支)。所以"返回清理函数"和"不返回"两种写法可以混用,但不要返回 Promise(没有特殊处理,会被忽略且不符合契约)。
13.5 文档元数据提升与第三方脚本冲突
8.3 节已说明,React 用启发式匹配 head 里的 <link>/<style>/<script>。如果第三方扩展在 <head> 顶部注入同名 <link>,可能出现 hydration 时匹配错位。规避:给 React 管理的资源显式加 data-precedence(样式)或用稳定高熵属性(href/crossOrigin)。
13.6 use 不能替代 useEffect 获取数据
use(promise) 只适合"promise 已经存在、需要在渲染期读取"的场景(如 RSC 下由父级传入的 promise)。它不能 用来"在组件里 fetch 然后 use"------因为每次渲染都会创建新 promise,导致无限挂起/重渲染。数据获取请继续用 useEffect + state(或 use(读取已稳定的 promise) + Suspense)。
13.7 StrictMode 双调用对 Action 的影响
StrictMode 下 effect 双调用(10.3 节)同样适用于 useActionState 的 actionStateActionEffect 等被动 effect。但 Action 队列本身是"事件驱动"而非"渲染驱动",双调用不会重复触发 action 执行(action 在 dispatch 时入队,不在 effect 里)。这一区分很重要:不要把"effect 双调用"与"action 重放"混为一谈。
14. 性能影响
14.1 useOptimistic 的每帧 baseState 重置成本
updateOptimisticImpl 每帧执行 hook.baseState = passthrough(:2025)是 O(1),开销可忽略。真正成本在 updateReducerImpl 遍历更新队列,但因为乐观更新在 transition 完成后会被 continue 跳过(:1486),队列长度通常很短。
14.2 useActionState 的 thenable 解包与 Suspense
useThenable 每次渲染都会 trackUsedThenable(:1105),对已完成的 thenable 是 O(1) 命中缓存;对挂起的 thenable 触发挂起------这是有意的"等待成本" ,换来的是"界面在 action 期间自然停留"。若你高频 dispatch 且每个 action 都触发悬挂,可能产生较多 Suspense 边界切换;此时应评估是否真的需要"渲染 state 时悬挂",或改用纯 useTransition + 手动 state。
14.3 revertLane 的空间成本
每个乐观更新节点多存一个 revertLane(一个 Lane 位掩码,通常是 31 位整数),空间开销极小。但它让 React 能在不引入额外撤销栈的情况下实现回滚,是"用位运算换复杂度"的典型优化。
14.4 文档元数据提升的 DOM 操作
提升发生在 commit 阶段,与常规 host 挂载同批进行,不会额外触发 layout。但因 head 是共享单例,多个并发子树同时提升 <title> 时,最终以 fiber 树提交顺序的"最后一个胜出"为准------这正是"单一 title"语义期望的行为。
14.5 总体结论
React 19 这些特性在性能上**几乎都是"免费"或"极低开销"**的,因为它们复用既有内核、避免引入新的全局状态。主要性能关注点是:useActionState 的悬挂频率(业务层可控)与并发场景下 action 队列长度(通常很短)。
15. 用法与最佳实践(结合源码解释"为什么这样写才对")
15.1 表单提交:优先 <form action>
jsx
// 正解:自动获得 transition、pending、提交后重置、可中断
function Form({action}) {
return (
<form action={action}>
<input name="name" />
<SubmitButton />
</form>
);
}
源码依据:startHostTransition(:3266)自动包 transition + requestFormReset(:3304)。你不需要手写 isPending 或 e.preventDefault()。
15.2 跨组件读表单状态:useFormStatus
useFormStatus(:67)只读 Context,零成本、就近匹配。适合做"提交中"按钮禁用态:
jsx
function SubmitButton() {
const {pending} = useFormStatus();
return <button disabled={pending}>提交</button>;
}
15.3 乐观 UI:useOptimistic 包在 transition 内
jsx
function Thread({messages}) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMsg) => [...state, newMsg],
);
function send() {
startTransition(() => { // 必须包 transition!
addOptimistic(draft);
postMessage(draft);
});
}
return optimistic.map(...);
}
源码依据::3737 强制 transition;:2025 每帧 rebase 到 messages(真实源)。
15.4 需要 state + dispatch + pending:用 useActionState
jsx
const [state, dispatch, isPending] = useActionState(
async (prev, formData) => { ...; return next; },
initialState,
);
源码依据::2380 的三钩子组合;:2506 用 useThenable 解包 state。
15.5 读取 Context 或已存在的 Promise:用 use
jsx
const theme = use(ThemeContext); // 等价 useContext
const data = use(resourcePromise); // 仅在 promise 已存在时
注意 use 可在条件/循环里调用(6.2 节重放机制保证 hook 顺序一致)。
15.6 ref 新写法
jsx
function Child({ref}) { // 直接当 prop 拿,无需 forwardRef
return <div ref={ref} />;
}
// 函数 ref 返回清理
<div ref={(node) => {
if (node) register(node);
return () => unregister(); // React 19 新支持
}} />;
源码依据::798 存 refCleanup,:846 调用。
15.7 反模式清单
- ❌
useEffect(async () => { await ... })------ 源码会报错(:204)。 - ❌ 在事件里裸调
addOptimistic不包 transition ------ DEV 报错(:3764)。 - ❌
ref="string"------ 移除(:810)。 - ❌ 函数组件
defaultProps------ 已在 19 移除(:1453无解析)。 - ❌ 在组件内
use(fetch(...))------ 每次渲染新 promise,无限挂起(见 13.6)。
15A. ref 作为普通 prop 的完整源码链路:从 JSX 到 commit
§7 已经给出了结论和两三个落点。本节把「ref 变成普通 prop」这条链从 JSX 运行时一路追到 commit,说明每一步在源码里到底发生了什么 ,以及为什么 forwardRef 还活着。这一节是对第 4 章(协调)与第 6 章(commit 副作用)的直接延伸。
15A.1 结论先行
一句话:在 19.3.1 里,ref 就是 props 上的一个普通字段;element.ref 只是为兼容保留的、访问即报错的「影子属性」。 整条链路一共四站:
(完整链路:JSX 运行时 → coerceRef 写入 fiber.ref → beginWork 透传 → commit 的 commitAttachRef。)
四站分别对应四个模块:react/src/jsx、react-reconciler/ReactChildFiber、react-reconciler/ReactFiberBeginWork、react-reconciler/ReactFiberCommitEffects。只要这四站串起来,"为什么不需要 forwardRef"就不再是玄学。
15A.2 第一站:JSX 运行时不再「抽走」ref
很多人以为「ref 曾经被 JSX 抽走」是 createElement 干的。历史上确实如此,但 19.3.1 已经改了。看 jsxProd(packages/react/src/jsx/ReactJSXElement.js:291)里构造 props 的那段:
js
// packages/react/src/jsx/ReactJSXElement.js:322
let props;
if (!('key' in config)) {
props = config;
} else {
// We need to remove reserved props (key, prop, ref). ...
props = {};
for (const propName in config) {
// Skip over reserved prop names
if (propName !== 'key') {
props[propName] = config[propName];
}
}
}
注释里明明写着 "remove reserved props (key, prop, ref)",但代码只跳过了 key。也就是说 ref 已经不再是「保留字段」,它会被原样复制进 props。注释是历史遗留,源码才是事实(写作规范第六条:以源码为准)。这种「注释与代码不一致」的地方,恰好是读源码最有价值的收获。
createElement 也一样(ReactJSXElement.js:668):过滤名单是 key / __self / __source------里面没有 ref 。注释「we don't want them to appear as props」值得咀嚼:__self/__source 仍然要过滤,ref 却留下了,恰好说明 ref 的地位从「保留字段」降格成了「普通 prop」。顺带说一句,__self/__source 之所以要过滤,是因为旧 JSX 转换(Babel 6 时代)会把它们塞进 config,而现代运行时不再需要它们。
15A.3 ReactElement:ref 成为「影子属性」
ReactElement 工厂(:170)不再给 ref 单独的语义:
js
// packages/react/src/jsx/ReactJSXElement.js:170
function ReactElement(type, key, props, owner, debugStack, debugTask) {
// Ignore whatever was passed as the ref argument and treat `props.ref` as
// the source of truth. ...
const refProp = props.ref;
const ref = refProp !== undefined ? refProp : null;
关键句:props.ref 是唯一事实来源(source of truth) ,element.ref 只是由它派生出来的兼容字段。DEV 下这个字段被定义成一个会报警的 getter(:201):
js
// packages/react/src/jsx/ReactJSXElement.js:143
function elementRefGetterWithDeprecationWarning() {
if (__DEV__) {
console.error('Accessing element.ref was removed in React 19. ref is now a ' +
'regular prop. It will be removed from the JSX Element type in a future release.');
const refProp = this.props.ref;
return refProp !== undefined ? refProp : null;
}
}
于是三种访问的行为被精确区分开:
- 读
props.ref:正常,19 的推荐写法。 - 读
element.ref:DEV 报错(按组件名去重,只报一次),生产静默返回派生值,将来会彻底删除。 - DEV 下
element.ref不可枚举 (:202):Jest 用getOwnPropertyDescriptors比较对象时不会误触发警告(例如expect(<div ref={ref} />).toEqual(<span ref={ref} />))。
生产环境则干脆把 ref 当作普通属性(:225):
(生产环境下 ref 就是 element 上的普通属性,见 packages/react/src/jsx/ReactJSXElement.js:225 的 { $$typeof, type, key, ref, props }。)
15A.4 第二站:创建 Fiber 时从 props.ref 取值
真正决定「ref 挂在哪个 fiber 上」的,是协调子节点时的 coerceRef(packages/react-reconciler/src/ReactChildFiber.js:291):
js
// packages/react-reconciler/src/ReactChildFiber.js:291
function coerceRef(workInProgress: Fiber, element: ReactElement): void {
// TODO: This is a temporary, intermediate step. ...
const refProp = element.props.ref;
workInProgress.ref = refProp !== undefined ? refProp : null;
}
两处 TODO 说得很坦率:这是「临时/过渡」实现 ------理想状态下 ref 应该在对应组件自己的 begin 阶段解析(HostComponent / ClassComponent 各自处理),而不是在创建 fiber 时统一 coerceRef。19.3.1 选了后者,但关键点在于:它读的是 element.props.ref(props!),不是 element.ref。这再一次证明「props.ref 才是事实来源」。
注意 coerceRef 只在 createFiberFromElement(ReactFiber.js:739)等创建路径上被调用。更新已有 fiber 时不会重新 coerceRef,而是由 createWorkInProgress(ReactFiber.js:412)把 workInProgress.ref = current.ref、workInProgress.refCleanup = current.refCleanup 直接继承。fiber 上这两个字段在初始化时是 null(ReactFiber.js:154、:155)------refCleanup 是 fiber 的新字段,专门为 §7.3 的「ref 清理函数」服务。
15A.5 第三站:beginWork 如何对待 props.ref
分两条路。
(1)普通函数组件:原样透传。 updateFunctionComponent(packages/react-reconciler/src/ReactFiberBeginWork.js:1453)把 nextProps 直接交给 renderWithHooks(:1529、:1539),中间没有任何摘 ref 的动作 。于是你的组件函数 function C({ref}) {} 能直接从 props 里拿到它------这正是「不再需要 forwardRef」的实现基础。
(2)forwardRef 组件:把 ref 摘出去,作为第二参数。 updateForwardRef(:404)里那段「新建 propsWithoutRef、逐个复制非 ref 字段」的循环,与 §7.2 引用的同源代码完全一致(见 ReactFiberBeginWork.js:424)。
注意 :428 的注释:ref is just a prop now(ref 现在就是个 prop)。所以 forwardRef 的兼容不是「另一套机制」,而是「把 props 里的 ref 再摘出去一次 」,以维持旧的 (props, ref) 双参数签名。而 'ref' in nextProps 这个判断本身也说明:在 19 里 ref 通常会出现在 props 上。
小结:
forwardRef没有消失,也没有被改造,它只是多了一次「抵消新行为」的兼容处理。用forwardRef的组件行为不变,不用的组件也直接能拿 ref。 这也是为什么forwardRef的公开实现(packages/react/src/ReactForwardRef.js:12)几乎一字未改,只在 DEV 里多了一条「forwardRefrender 函数不支持 defaultProps」的检查(:42):
(这条报错在 packages/react/src/ReactForwardRef.js:42,__DEV__ 下当 render.defaultProps != null 时触发。)
15A.6 第四站:commit 阶段写 ref、存 cleanup
commitAttachRef(packages/react-reconciler/src/ReactFiberCommitEffects.js:757)先按 fiber 类型取「要挂载的实例」:
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:761
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(finishedWork.stateNode);
break;
// ... ViewTransitionComponent / Fragment 两个分支略
default:
instanceToUse = finishedWork.stateNode;
}
同一个 switch 里同时出现 HostHoistable(被提升的 <link>/<meta> 等)与 HostSingleton(html/head/body),正好呼应 §8 的文档元数据------能被 ref 引用的,正是这些「特殊 tag」的公共实例(public instance) 。这也解释了为什么 <title ref={...}> 这类写法在 19 里是有意义的。
然后是三种 ref 形态的分流(此段与 §7.3 的 ReactFiberCommitEffects.js:794 同源):
三件事:
- 函数 ref :调用它,把返回值 存进
fiber.refCleanup------这就是 React 19 新增的「ref 清理函数」。 - string ref:DEV 报「String refs are no longer supported.」(§7.3、§11.3)。
- 对象 ref :写
.current;若连current都没有,给出「Unexpected ref object」提示。
卸载/替换走 safelyDetachRef(:842),逻辑是一棵优先级从高到低的 if-else(骨架见 §7.3 的 ReactFiberCommitEffects.js:846)。
务必注意 finally 里那两行:调用完 cleanup 后,把 current.refCleanup 和 alternate.refCleanup 都置 null,目的是「prevent double invocation」------在 StrictMode 的双调用或并发打断重放中,防止同一个 cleanup 被执行两次。这类「用完即清」的写法在 React 内核里很常见。
useImperativeHandle 也同步升级(packages/react-reconciler/src/ReactFiberHooks.js:2817):
(imperativeHandleEffect 的完整代码见 §7.4 与 packages/react-reconciler/src/ReactFiberHooks.js:2817,此处不再重复。)
它本身就是一个 layout effect 的 create 函数,返回值(清理函数)被 effect 机制收走(§10),从而让「imperative handle 的 ref 清理」和「effect 清理」共用同一条通道。
15A.7 兼容矩阵与常见误用
| 写法 | 19.3.1 行为 | 源码落点 |
|---|---|---|
function C({ref}) {} |
✅ 推荐,ref 是普通 prop | updateFunctionComponent ReactFiberBeginWork.js:1529 |
forwardRef((p, ref) => ...) |
✅ 兼容,多一次「摘 ref」 | updateForwardRef ReactFiberBeginWork.js:427 |
element.ref 读取 |
⚠️ DEV 报错,将来删除 | ReactJSXElement.js:149 |
props.ref 读取 |
✅ | ReactJSXElement.js:175 |
对象 ref {current} |
✅ 写 .current |
ReactFiberCommitEffects.js:821 |
| 函数 ref 返回 cleanup | ✅ React 19 新增 | ReactFiberCommitEffects.js:798 |
ref="string" |
❌ DEV 报错 | ReactFiberCommitEffects.js:810 |
forwardRef 上挂 defaultProps |
❌ DEV 报错 | ReactForwardRef.js:44 |
一个高频误区:「函数 ref 返回 cleanup」与「useEffect 返回 cleanup」虽然写法对称,但调用时机不同 。ref cleanup 在 safelyDetachRef(ref 被替换/卸载/解绑)时调用,属于 layout 阶段;effect cleanup 则按 useEffect(被动)或 useLayoutEffect(layout)各自的通道走。两者不要混为一谈(详见 §10.4)。
15B. Actions 全链路再剖析:FormData、串行队列与表单重置的提交时机
§3 给出了调用链的骨架。这一节把「骨」填满:事件插件到底怎么判断该不该阻止默认行为、FormData 在什么时候被构造、pending 状态怎么流到后代、以及 requestFormReset 为什么必须在 commit 的最后才执行。
15B.1 事件入口的完整判断
FormActionEventPlugin.extractEvents(packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:52)只在两个条件下介入:事件是 submit(:61),且 fiber 与原生 target 一致(:64):
js
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:61
if (domEventName !== 'submit') { return; }
if (!maybeTargetInst || maybeTargetInst.stateNode !== nativeEventTarget) {
// If we're inside a parent root that itself is a parent of this root, then
// its deepest target won't be the actual form that's being submitted.
return;
}
然后解析 action,并处理「提交按钮覆盖表单 action」的规则:
js
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:71
let action = coerceFormActionProp(
(getFiberCurrentPropsFromNode(form) as any).action,
);
let submitter = (nativeEvent as any).submitter;
let submitterAction;
if (submitter) {
const submitterProps = getFiberCurrentPropsFromNode(submitter);
submitterAction = submitterProps
? coerceFormActionProp((submitterProps as any).formAction)
: (submitter.getAttribute('formAction') as any as string | null);
if (submitterAction !== null) {
// The submitter overrides the form action.
action = submitterAction;
submitter = null;
}
}
两个容易被忽略的点:
- 提交按钮的
formAction会覆盖<form action>(:84)------与 HTML 规范一致。 - 当提交按钮的 action 是函数时,
submitter被置null(:89),这样new FormData(form, submitter)就不会带上按钮自身的name=value:那是给服务器 判断「哪个按钮提交的」用的,客户端 action 不需要(ReactDOMComponent.js:182的 DEV 警告印证了这点)。
15B.2 三种提交分支
submitForm(:101)的 if/else 就是全部策略:
js
// packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js:101
function submitForm() {
if (nativeEvent.defaultPrevented) {
if (didCurrentEventScheduleTransition()) {
// 只设 pending,不执行 action
startHostTransition(formInst, pendingState, /* action */ null, new FormData(form, submitter));
}
} else if (typeof action === 'function') {
// A form action was provided. Prevent native navigation.
event.preventDefault();
startHostTransition(formInst, pendingState, action, new FormData(form, submitter));
} else {
// 字符串 action:交给浏览器原生提交
}
}
于是可以精确归纳出三种情形:
- 函数 action :React
preventDefault(),走 transition,自动 pending。preventDefault只发生在 action 是函数时。 - 字符串 action:React 不介入,浏览器原生提交------这就是 MPA / 渐进增强路径。
- 已被
preventDefault但本次事件调度过 transition :只设 pending、传action = null,兼容用户自己startTransition的写法。
FormData 只在这两处各构造一次,随后沿着 startHostTransition 一路传下去。pendingState 在 DEV 下被 Object.freeze(:117、:146),防止用户代码意外改动它。
还有一个公开入口 dispatchReplayedFormAction(:169)复用了同一套逻辑:它用于「表单在 hydration 期间被重放提交」的场景,把 pendingState({pending: true, data: formData, method: form.method, action})连同 formData 一并交给 startHostTransition(签名见 FormActionEventPlugin.js:169)。
15B.3 从 startHostTransition 到 startTransition
startHostTransition(ReactFiberHooks.js:3266)只做三件事:确保 form fiber「有状态」、取它的 state queue、用 startTransition 跑 action:
(startHostTransition 的完整代码见 §3.2 与 packages/react-reconciler/src/ReactFiberHooks.js:3266,此处不再重复。)
这里有一个值得纠正的常见误解:requestFormReset(formFiber) 是在 action 被调用之前就执行的 (:3303)。它做的事只是「排一次状态更新,把 resetState 换成新对象」,真正的 form.reset() 发生在 commit(15B.7)。所以「action 完成后自动清空」这个体感是对的,但 reset 的请求发生在更早。
15B.4 渲染期:form 是怎么「变成有状态组件」的
ensureFormComponentIsStateful(:3309)在 form fiber 上手工挂两条 hook:一条存 transition/pending 状态,一条存 resetState 这个空对象。两条 hook 同时写进 fiber.memoizedState 和 alternate.memoizedState(:3368、:3371),伪装成「这个 form 一直都有 hook」:
(ensureFormComponentIsStateful 的完整代码见 §3.3 与 packages/react-reconciler/src/ReactFiberHooks.js:3335,此处不再重复。)
渲染时,updateHostComponent 会检查 memoizedState(ReactFiberBeginWork.js:1996):
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:1996
// 唯一会让 host fiber 变有状态的原因,就是 form action
if (workInProgress.memoizedState !== null) {
const newState = renderTransitionAwareHostComponentWithHooks(current, workInProgress, renderLanes);
// isPrimaryRenderer ? HostTransitionContext._currentValue = newState
// : HostTransitionContext._currentValue2 = newState
}
而 renderTransitionAwareHostComponentWithHooks(ReactFiberHooks.js:859)只是把 TransitionAwareHostComponent(:874)当成一个普通函数组件来跑(renderWithHooks):
js
// packages/react-reconciler/src/ReactFiberHooks.js:874
export function TransitionAwareHostComponent(): TransitionStatus {
const dispatcher: any = ReactSharedInternals.H;
const [maybeThenable] = dispatcher.useState();
// pending 是 thenable 时会悬挂
const nextState = typeof maybeThenable.then === 'function'
? useThenable(maybeThenable) : maybeThenable;
// The "reset state" is an object. If it changes, that means something
// requested that we reset the form.
const [nextResetState] = dispatcher.useState();
const prevResetState = currentHook !== null ? currentHook.memoizedState : null;
if (prevResetState !== nextResetState) {
currentlyRenderingFiber.flags |= FormReset;
}
return nextState;
}
这段是整个表单特性的枢纽:
- pending 状态本质是一个
useState,用useThenable解包------所以异步 action 期间 form 会悬挂。 - reset 是一次
useState的对象身份比较(prevResetState !== nextResetState),触发时打上FormResetflag。
关于 FormReset 这个 flag 有个细节(packages/react-reconciler/src/ReactFiberFlags.js:50):
js
// packages/react-reconciler/src/ReactFiberFlags.js:50
export const FormReset = Snapshot;
FormReset 复用了 Snapshot 位 。这在后面 commit 时有重要影响:正因为这个位是「一址多用」的,recursivelyResetForms 不能简单地靠 subtreeFlags & FormReset 判断子树里有没有 form(可能会误判),所以 React 用一个模块级变量 needsFormReset 单独记账(见 15B.7)。
15B.5 pending 如何流到后代:HostTransitionContext
pushHostContext(packages/react-reconciler/src/ReactFiberHostContext.js:96)在 form fiber 上把 memoizedState 当作 transition 状态推入 Context:
js
// packages/react-reconciler/src/ReactFiberHostContext.js:96
function pushHostContext(fiber: Fiber): void {
const stateHook: Hook | null = fiber.memoizedState;
if (stateHook !== null) {
// Propagate the current state to all the descendents.
// We use Context as an implementation detail for this.
const transitionStatus: TransitionStatus = stateHook.memoizedState;
if (isPrimaryRenderer) {
HostTransitionContext._currentValue = transitionStatus;
} else {
HostTransitionContext._currentValue2 = transitionStatus;
}
push(hostTransitionProviderCursor, fiber, fiber);
}
}
popHostContext 时把值重置回 NotPendingTransition(:155)。这就是为什么 useFormStatus 只能读到「最近的、正在提交的那个 form」 ------它就是 Context 的就近语义;也解释了源码注释反复强调「form 不允许嵌套」(:150):因为 Context 只有一份 _currentValue,嵌套时会互相覆盖。
HostTransitionContext 本身在 ReactFiberConfigDOM.js:7013 被定义成一个「裸」的 Context 对象(注意它的 Provider/Consumer 都是 null,因为它只被内核通过 _currentValue 直接读写,从不走 JSX):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7012
export const NotPendingTransition: TransitionStatus = NotPending;
export const HostTransitionContext: ReactContext<TransitionStatus> = {
$$typeof: REACT_CONTEXT_TYPE,
Provider: null as any,
Consumer: null as any,
_currentValue: NotPendingTransition,
_currentValue2: NotPendingTransition,
_threadCount: 0,
};
这正好和 §9 的 <Context> 直接作 Provider 形成对照:createContext 造出来的 Context 里 Provider === context;而 HostTransitionContext 是手工拼的,Provider 留 null。
15B.6 requestFormReset 的两层三跳
requestFormReset 在源码里有两个同名函数,别混淆。
-
react-dom-bindings 的公共门面 (
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js:81):实现只有一行ReactDOMSharedInternals.d.r(form),委托给当前渲染器注册的r方法。 -
DOM 渲染器的实现 (
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5236):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5236
function requestFormReset(form: HTMLFormElement) {
const formInst = getInstanceFromNodeDOMTree(form);
if (formInst !== null && formInst.tag === HostComponent && formInst.type === 'form') {
requestFormResetOnFiber(formInst);
} else {
// This form was either not rendered by this React renderer. Try the next one.
previousDispatcher.r(/* requestFormReset */ form);
}
}
这里有一个很实用的知识点 :当你调用公开的 ReactDOM.requestFormReset(formElement) 时,React 需要从 DOM 元素反查它属于哪个 React 树 (getInstanceFromNodeDOMTree)。如果这个 <form> 根本不是 React 渲染的,它会沿着渲染器链往外找------这是多渲染器(iframe / 微前端)共存时的兼容设计。
- 内核侧 (
ReactFiberHooks.js:3377):先校验必须处于 transition(否则 DEV 报错:3391),然后同样通过ensureFormComponentIsStateful+dispatchSetStateInternal把 resetState 换成{}:
(requestFormReset 的完整代码见 §3.4 与 packages/react-reconciler/src/ReactFiberHooks.js:3377,此处不再重复。)
:3407 那段「Hack alert」很关键:如果传进来的 formFiber 是 workInProgress,它的 stateHook.next 可能是中间态 ,这时要去 alternate(当前树)上取 hook------这正是第 3 章双缓冲体系带来的「同一 fiber 两个版本」问题在内核里的具体表现。
15B.7 reset 为什么在 commit 的最后才执行
FormReset flag 在 mutation 阶段被收集(packages/react-reconciler/src/ReactFiberCommitWork.js:2316):
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2316
if (flags & FormReset) {
needsFormReset = true;
if (__DEV__) {
if (finishedWork.type !== 'form') {
console.error('Unexpected host component type. Expected a form. ...');
}
}
}
真正执行在 HostRoot 分支处理完所有 mutation 之后 (:2407):
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2407
if (needsFormReset) {
// A form component requested to be reset during this commit. We do this
// after all mutations in the rest of the tree so that `defaultValue`
// will already be updated. ...
// Theoretically we could check finishedWork.subtreeFlags & FormReset,
// but the FormReset bit is overloaded with other flags.
needsFormReset = false;
recursivelyResetForms(finishedWork);
}
注释把原因写得非常清楚:必须在整棵树的所有 DOM 更新之后才 reset,这样 action 从服务端带回的新 defaultValue 已经写进了 DOM,reset 才能把输入框恢复到「新默认值」而不是「旧默认值」。这正是「用服务端返回的数据更新表单」这个常见模式的实现基础。
recursivelyResetForms(:2866)→ resetFormOnFiber(:2876)→ resetFormInstance:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2866
function recursivelyResetForms(parentFiber: Fiber) {
if (parentFiber.subtreeFlags & FormReset) {
let child = parentFiber.child;
while (child !== null) {
resetFormOnFiber(child);
child = child.sibling;
}
}
}
function resetFormOnFiber(fiber: Fiber) {
recursivelyResetForms(fiber);
if (fiber.tag === HostComponent && fiber.flags & FormReset) {
resetFormInstance(fiber.stateNode);
}
}
最终落到 packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7023:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:7022
export type FormInstance = HTMLFormElement;
export function resetFormInstance(form: FormInstance): void {
ReactBrowserEventEmitterSetEnabled(true);
form.reset();
ReactBrowserEventEmitterSetEnabled(false);
}
这个「启用合成事件 → 调原生 form.reset() → 再关掉」的三明治值得咀嚼:form.reset() 会触发原生 reset 事件,React 临时开启事件系统只是为了让 onReset 监听器能收到 (第 10 章的 ReactBrowserEventEmitterSetEnabled)。React 没有为 reset 写自己的实现,它调的就是浏览器原生 form.reset() ------因此 reset 只对非受控表单域(defaultValue/defaultChecked)真正有意义;完全受控的 input 在 reset 后仍会被 state 覆盖回去。
15B.8 完整时序
15C. useActionState 深解:mountActionState 三 hook、dispatchActionState 与 permalink
15C.1 三条 hook,各司其职
mountActionState(:2380)挂三条 hook,顺序不能乱:stateHook(存 action 返回值,可能是 thenable ,reducer 为 actionStateReducer,:2376)、pendingStateHook(mountStateImpl(false),pending 走乐观更新)、actionQueueHook(queue 位存共享的 ActionStateQueue,memoizedState 位暂存 action 以探测变化)。dispatch 由 dispatchActionState(:2106)bind 而成(:2448)。
15C.2 isPending 的真相
关键在 dispatchActionState(packages/react-reconciler/src/ReactFiberHooks.js:2142):
js
// packages/react-reconciler/src/ReactFiberHooks.js:2142
const prevTransition = ReactSharedInternals.T;
if (prevTransition !== null) {
// Optimistically update the pending state, similar to useTransition.
// This will be reverted automatically when all actions are finished.
setPendingState(true);
setState(actionNode);
} else {
actionNode.isTransition = false;
setState(actionNode);
}
三点必须记住:
setPendingState(true)只在ReactSharedInternals.T !== null(处在 transition 中)时执行 ;T就是「当前 transition 上下文」(第 8 章)。setPendingState=dispatchOptimisticSetState(fiber, false, pendingStateHook.queue)(:2426):isPending不是普通 state,而是一个乐观更新。- pending 的回滚不需要手动写
setPendingState(false):revertLane绑定在这次 transition 上(§4.5),transition 结束时updateReducerImpl跳过它,state 自动回false。
由此可以回答一个高频疑问:「在 action 里 await 之后 isPending 还是 true 吗?」------是 。因为 isPending 的回落由 transition 的 revertLane 决定,而不是由你的 await 决定;只要 transition 没结束,它就是 true。同时因为 state 是 thenable(§5.4 用 useThenable 解包),渲染期读 state 会悬挂,最近的 Suspense 边界显示 fallback。
15C.3 串行队列与熔断
useActionState 的 action 签名是 (prevState, payload) => nextState,后一次必须拿到前一次的结果 ,所以多个 dispatch 不能并发。实现是一条环形链表(ActionStateQueueNode,:2084):
(环形链表入队代码见 §5.3 与 packages/react-reconciler/src/ReactFiberHooks.js:2158,此处不再重复。)
onActionSuccess(:2312)负责「弹出自己、推进下一个」:若 first === last(链表只剩一个节点)则 actionQueue.pending = null,否则 last.next = next 后 runActionStateAction(actionQueue, next)(:2338)。runActionStateAction(:2172)在 node.isTransition 为真时重建 transition 上下文 (fork startTransition,:2190),并用 onStartTransitionFinish 把这次 action 的 Promise 并入 transition 完成条件(:2217)。
错误则熔断(:2343):onActionError 把队列里所有节点标 rejected 并通知监听者,最后 actionQueue.action = null(:2362);于是后续 dispatchActionState 开头(:2118)if (currentAction === null) return; 会静默丢弃。恢复要靠 actionStateActionEffect(:2539)在 commit 时写回 actionQueue.action(仅在 action !== prevAction 时才 push,:2526)。
15C.4 permalink:客户端不消费、服务端才用
permalink 的结论必须精确:客户端 reconciler 里它全程不被读取 ------mountActionState(:2380)、updateActionStateImpl(:2481)、rerenderActionState(:2546)都只在签名里接收它;grep permalink packages/react-reconciler 只命中形参声明与 dispatcher 透传。真正消费它的是服务端 Fizz (packages/react-server/src/ReactFizzHooks.js:689):
js
// packages/react-server/src/ReactFizzHooks.js:670
function createPostbackActionStateKey(
permalink: string | void, componentKeyPath: KeyNode | null, hookIndex: number,
): string {
if (permalink !== undefined) {
// Don't bother to hash a permalink-based key since it's already short.
return 'p' + permalink;
} else {
const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
return 'k' + createFastHash(JSON.stringify(keyPath));
}
}
并且在 $$FORM_ACTION 里直接改写表单提交地址 (:766):metadata.action = permalink。所以 permalink 的语义是:给服务端 Action 提供稳定的、可分享的提交 URL(MPA 渐进增强用) ,同时让 SSR 端据此生成/识别 postback 状态键。纯客户端 action 下这个参数没有任何运行时作用。
15D. useOptimistic 深解:每帧 rebase 与 revertLane 的精确语义
§4 已给出 mountOptimistic(:1974)、updateOptimisticImpl(:2013)、dispatchOptimisticSetState(:3728)。本节补三处最容易读错的地方。
15D.1 「每帧重置 baseState」与 rerender 路径
rerenderOptimistic(:2034)处理「渲染期更新导致的 re-render」,它的注释解释了为什么 useOptimistic 不能像 useState 那样用 forked rerender:
js
// packages/react-reconciler/src/ReactFiberHooks.js:2038
// Unlike useState, useOptimistic doesn't support render phase updates.
// Also unlike useState, we need to replay all pending updates again in case
// the passthrough value changed.
即:因为 passthrough 可能每帧都变,所有 pending 的乐观更新必须在新 base 上重放 ,所以 rerender 复用 mount/update 的实现,而不是另写一套。配合 updateOptimisticImpl 的 hook.baseState = passthrough(:2025),就形成了「每帧 rebase」的完整语义。
15D.2 revertLane 的精确语义
js
// packages/react-reconciler/src/ReactFiberHooks.js:3773
// For regular Transitions an optimistic update commits synchronously.
const lane = enableGestureTransition && transition !== null && transition.gesture
? GestureLane : SyncLane;
const update: Update<S, A> = {
lane: lane,
// After committing, the optimistic update is "reverted" using the same
// lane as the transition it's associated with.
revertLane: requestTransitionLane(transition),
...
};
lane = SyncLane:乐观更新同步提交,用户立刻看到结果。revertLane = requestTransitionLane(transition):回滚车道取自当前 transition。
回滚判定在 updateReducerImpl(:1477):
(revertLane 跳过逻辑的完整分支见 §4.5 与 packages/react-reconciler/src/ReactFiberHooks.js:1477,此处不再重复。)
三个务必记住的点:
- 回滚不是
setState(旧值),而是「在 transition 完成的那次渲染里,不再应用这条更新」。 - 未回滚时会把更新克隆 进 baseQueue,
lane: NoLane但保留revertLane------这是「将来还能被回滚」的凭证。 - 当
revertLane === peekEntangledActionLane()时置didReadFromEntangledAsyncAction,把「乐观更新回滚」与「等异步 action 完成」串起来(ReactFiberAsyncAction.js,第 8 章)。
15D.3 与 startTransition 的强制配对
dispatchOptimisticSetState 开头的 DEV 检查(:3736)区分了两种「没有 transition」的情况:
(这段 DEV 警告见 §4.6 与 packages/react-reconciler/src/ReactFiberHooks.js:3759,此处不再重复。)
- 有挂起的 async action :不报警,React 会把这次乐观更新关联到该 action 的 lane------所以
await之后再addOptimistic也是合法的。 - 完全没有:报警。
另外 :3791 还处理渲染期调用:startTransition 在渲染期调用只警告(因为兼容性,:3796 注释明说这是 breaking change 才需要警告),而 addOptimistic(throwIfDuringRender = true)直接 throw new Error('Cannot update optimistic state while rendering.')------源码里明确区分了两种严格程度。
15E. use 原语深解:两条读路径、thenableState 重放与「条件调用」的源码根因
15E.1 两条读路径与 useContext 的差异
use(ReactFiberHooks.js:1159)只有三个分支:thenable → useThenable;REACT_RECOVERABLE_TYPE → 返回 undefined;REACT_CONTEXT_TYPE → readContext。所以 use(Context) 与 useContext(Context) 等价。差异在公开层:useContext(packages/react/src/ReactHooks.js:53)在 DEV 会拒绝 Context.Consumer:
js
// packages/react/src/ReactHooks.js:53 ------ DEV 下拒绝把 Context.Consumer 传给 useContext
if (Context.$$typeof === REACT_CONSUMER_TYPE) {
console.error('Calling useContext(Context.Consumer) is not supported and will cause bugs. ' +
'Did you mean to call useContext(Context) instead?');
}
return dispatcher.useContext(Context);
use 不做这个检查,因为它压根不处理 REACT_CONSUMER_TYPE(会走到最后的 throw new Error('An unsupported type was passed to use()'),:1178)。
15E.2 thenableState 与「位置」索引
useThenable(:1098)用一个序号 定位 thenable(thenableIndexCounter,:287 定义),而不是 hook 链表:
js
// packages/react-reconciler/src/ReactFiberHooks.js:1100
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
const result = trackUsedThenable(
thenableState, thenable, index,
__DEV__ ? currentlyRenderingFiber : null,
);
createThenableState(packages/react-reconciler/src/ReactFiberThenable.js:95)在 DEV 下是 {didWarnAboutUncachedPromise, thenables: []},生产下就是一个数组(:41 的 ThenableStateProd)。trackUsedThenable(:125)的核心:
js
// packages/react-reconciler/src/ReactFiberThenable.js:134
const trackedThenables = getThenablesFromState(thenableState);
const previous = trackedThenables[index];
if (previous === undefined) {
trackedThenables.push(thenable);
} else {
if (previous !== thenable) {
// Reuse the previous thenable, and drop the new one. We can assume
// they represent the same value, because components are idempotent.
...
}
}
这一句「Reuse the previous thenable, and drop the new one 」就是「不要在组件里 use(fetch(...))」的源码根据:第二次渲染若拿到新的 Promise,React 会丢掉新 Promise、复用第一次的 ,于是你会一直悬挂或读到旧结果。DEV 还会给一次 uncached promise 警告(:145 起)。
状态机(:212)三种走向:fulfilled → 返回 thenable.value(同步解包,零成本 );rejected → throw thenable.reason;其他 → 打 status 为 pending、挂 then 监听器,然后抛 SuspenseException(:317、:338)。SuspenseException(:57)是一条伪装成 Error 的哨兵 ,专门用于中断渲染;SuspenseActionException(:72)是它的 useActionState 专用变体(§5.4)。
15E.3 重放与 dispatcher 回切
useThenable 后半段(:1112--:1155)处理的是:挂起后组件以 re-render dispatcher 被重放,若这次 use 之后还有 hook 没被处理过,就必须从 rerender dispatcher 切回 mount/update dispatcher,否则后面的 hook 会走错分支:
js
// packages/react-reconciler/src/ReactFiberHooks.js:1126
const nextWorkInProgressHook = workInProgressHook === null
? workInProgressFiber.memoizedState : workInProgressHook.next;
if (nextWorkInProgressHook === null) {
// 没有剩余 hook:退出 "re-render" 模式,把 dispatcher 切回 mount/update
const currentFiber = workInProgressFiber.alternate;
ReactSharedInternals.H = currentFiber === null || currentFiber.memoizedState === null
? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
}
15E.4 为什么 use 能在条件里调用
在源码层面有三条支撑,缺一不可:
use不往 hook 链表里写任何东西 :它不走mountWorkInProgressHook/updateWorkInProgressHook,所以调用次数与位置变化不破坏「hook 顺序一致性」校验(第 7 章)。- 它的「位置」由
thenableIndexCounter单独维护 ,挂在 fiber 的thenableState上------只要「执行到的那些 thenable」按顺序一致即可。 - 重放时按 index 复用 (
trackedThenables[index]),不依赖 hook 链表。
但别误读成「随便写都没问题」:19.3.1 里存在 enableConditionalUseWarning 与专属警告页 https://react.dev/warnings/conditional-use-of-use(ReactFiberThenable.js:334),也就是说------换个位置的「条件 use」是允许的,但「这次渲染调了、下次没调」仍会在 DEV 被记录并警告。
15E.5 客户端 vs 服务端的 use
服务端(packages/react-server)也导出 use,但实现完全不同:Fizz 的 use 服务于流式渲染,没有客户端意义上的「挂起-重放」。这正是本章要强调的「条件导出」概念------packages/react/src/ReactClient.js 与 ReactServer.js 的导出表并不相同:
- 客户端 (
ReactClient.js:76)导出全套 hook:useState/useEffect/useOptimistic/useActionState/useSyncExternalStore/useTransition... 外加captureOwnerStack。 - 服务端 (
ReactServer.js:43)只导出use、useId、useCallback、useDebugValue、useMemo与createElement/forwardRef/memo/cache等;useState/useEffect在react-server下根本不存在。
js
// packages/react-server/src/ReactFizzHooks.js:662
function useOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
resolveCurrentlyRenderingComponent();
return [passthrough, unsupportedSetOptimisticState];
}
这些差异是「RSC 里不能写 useState」这条规则的源码落点,而不是什么约定俗成。
15F. 资源提示 API:preload / preinit / preconnect / preloadModule / preinitModule / prefetchDNS
这几个 API 在 19.3.1 确实存在 (不是传言也不是新提案),源码在 packages/react-dom/src/shared/ReactDOMFloat.js,公开入口是 react-dom(packages/react-dom/index.js:11 就导出了 prefetchDNS / preconnect / preload / preloadModule / preinit / preinitModule)。
15F.1 六个函数的签名与行号
| 函数 | 签名(节选) | 行号 |
|---------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|-----------------------|--------|
| prefetchDNS(href) | DNS 预取,只接受一个参数 | ReactDOMFloat.js:24 |
| preconnect(href, {crossOrigin}) | 预连接 | :58 |
| preload(href, {as, crossOrigin, integrity, nonce, type, fetchPriority, referrerPolicy, imageSrcSet, imageSizes, media}) | 预加载资源 | :89 |
| preloadModule(href, {as, crossOrigin, integrity, nonce, fetchPriority}) | 预加载 ES 模块 | :154 |
| `preinit(href, {as: 'style' | 'script', precedence, crossOrigin, integrity, fetchPriority, nonce})` | 预初始化(会真的插入并执行) | :211 |
| preinitModule(href, {as?: 'script', ...}) | 预初始化模块 | :268 |
15F.2 它们是「乐观的、静默失败」的
每个函数的末尾注释都写着同一句(ReactDOMFloat.js:53):
js
// packages/react-dom/src/shared/ReactDOMFloat.js:53
// We don't error because preconnect needs to be resilient to being called in a variety of scopes
// and the runtime may not be capable of responding. The function is optimistic and not critical
// so we favor silent bailout over warning or erroring.
所以在生产环境里,参数类型不对时它们什么都不做 ,不会抛错;只有 DEV 会 console.error 给出提示。这一设计理念和 preload 的定位一致:资源提示是「锦上添花」,绝不该因为一个拼错的 href 让整个应用崩掉。
15F.3 真正的落点:渲染器 dispatcher
这些函数本身只做参数校验 + 转发(ReactDOMFloat.js:123):
js
// packages/react-dom/src/shared/ReactDOMFloat.js:123
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */
.L(/* preload */ href, as, { ... });
d 这个 dispatcher 在 ReactFiberConfigDOM.js:5208 注册:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5208
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */ = {
f /* flushSyncWork */: disableLegacyMode ? flushSyncWork : previousDispatcher.f,
r: requestFormReset,
D /* prefetchDNS */: prefetchDNS,
C /* preconnect */: preconnect,
L /* preload */: preload,
m /* preloadModule */: preloadModule,
X /* preinitScript */: preinitScript,
S /* preinitStyle */: preinitStyle,
M /* preinitModuleScript */: preinitModuleScript,
};
注意 f 与 r 和资源函数注册在同一个对象上 :f 是 flushSync 走的 flushSyncWork(packages/react-dom/src/shared/ReactDOMFlushSync.js:39),r 是 requestFormReset(15B.6)。一张 dispatcher 表同时服务「同步刷新」「表单重置」「资源预加载」,这是渲染器无关接口(ReactFiberConfig)的一种轻量落地方式。
15F.4 去重:两张全局表
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:5174
const preloadPropsMap: Map<string, PreloadProps | PreloadModuleProps> =
new Map();
const preconnectsSet: Set<string> = new Set();
preload / preloadModule 用 Map 去重(同一 href 只发一次 <link rel="preload">),preconnect / prefetchDNS 用 Set 去重。这也解释了它们对外承诺的「幂等」------你可以在多个组件里重复调用 preload(href, {...}),不会产生重复标签。
15F.5 preinit 与文档元数据的同源性
preinit(href, {as:'style'}) 最终走到 preinitStyle,它与 §8 的 <link rel="stylesheet" precedence> 共用同一套插入逻辑 ------insertStylesheet(ReactFiberConfigDOM.js:6111):
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:6111
function insertStylesheet(instance: Element, precedence: string, root: HoistableRoot): void {
const nodes = root.querySelectorAll(
'link[rel="stylesheet"][data-precedence],style[data-precedence]',
);
const last = nodes.length ? nodes[nodes.length - 1] : null;
let prior = last;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodePrecedence = node.dataset.precedence;
if (nodePrecedence === precedence) { prior = node; }
else if (prior !== last) { break; }
}
if (prior) {
(prior.parentNode as any as Node).insertBefore(instance, prior.nextSibling);
} else {
const parent = root.nodeType === DOCUMENT_NODE
? ((root as any as Document).head as any as Element)
: (root as any as ShadowRoot);
parent.insertBefore(instance, parent.firstChild);
}
}
这段代码定义了 precedence 的插入语义 :所有带 data-precedence 的样式节点构成「有序组」,同 precedence 的插在最后一个同组节点之后;找不到同名组就插到 head 最前面。这就是 React 19 里「CSS precedence」不是「比较名字大小」而是真实的 DOM 顺序的实现依据------顺序决定层叠(cascade),而不是名字。
stylesheetPropsFromRawProps(:5893)还做了一次属性改写:把 precedence 搬到 'data-precedence',并把原 precedence 置 null------这样它既能参与 DOM 选择器匹配(insertStylesheet 靠 data-precedence 找组),又不会被当作普通 HTML 属性透传到浏览器(precedence 不是标准属性)。
15G. captureOwnerStack 深解:owner 栈的来源与 DEV-only 语义
15G.1 公开面
captureOwnerStack 在客户端与服务端都导出(packages/react/src/ReactClient.js:136、ReactServer.js:66),实现只有十几行(packages/react/src/ReactOwnerStack.js:12):
js
// packages/react/src/ReactOwnerStack.js:12
export function captureOwnerStack(): null | string {
if (__DEV__) {
const getCurrentStack = ReactSharedInternals.getCurrentStack;
if (getCurrentStack === null) return null;
// current stack 在这里就是 owner stack
return getCurrentStack();
}
return null;
}
生产环境永远返回 null------它是个纯 DEV 工具。这一点必须说清楚,否则容易误以为可以在生产里用它做日志埋点。
15G.2 owner 栈从哪来
ReactSharedInternals.getCurrentStack 由渲染器设置。ReactCurrentFiber.js:84:
js
// packages/react-reconciler/src/ReactCurrentFiber.js:84
export function setCurrentFiber(fiber: Fiber | null) {
if (__DEV__) {
ReactSharedInternals.getCurrentStack =
fiber === null ? null : getCurrentFiberStackInDev;
isRendering = false;
}
current = fiber;
}
而 getCurrentFiberStackInDev(:32)返回的正是 owner 栈 :它调用 getOwnerStackByFiberInDev(current)。current 是「当前正在渲染/提交的 fiber」,由 runWithFiberInDEV(:46)在调用用户代码前后 setCurrentFiber 维护。于是:
- 在组件渲染期 调用
captureOwnerStack():current是正在渲染的组件,拿到的是它的 owner 链(「谁创建了我」)。 - 在 commit 的用户回调 (ref、effect)里:
runWithFiberInDEV会把current设成对应 fiber,同样能拿到 owner 栈。 - 在渲染之外 (普通事件、定时器里):
resetCurrentFiber(:76)已把getCurrentStack置回null,因此返回null。
15G.3 与 DevTools 的关系
owner 栈是 React DevTools 组件面板里「是谁渲染了我」那一栏 的数据来源,也是错误堆栈里 at Component (file:line) 帧的来源。captureOwnerStack 把这个内部能力开放给用户代码,让自定义校验、日志、测试断言能打印出同样风格的组件栈。它不依赖 DevTools 是否安装 ------只要在 DEV 构建里就能用;但在生产构建里它是死代码(__DEV__ 为 false 时直接被压缩掉)。
15H. 破坏性变更的源码证据(补强 §11)
§11 已给出 defaultProps / string ref / legacy context 的证据。这里补齐其余几项,并纠正一处常见的过度简化。
15H.1 legacy 模式整体关闭
js
// packages/shared/ReactFeatureFlags.js:205
export const disableLegacyMode: boolean = true;
这是 19.3.1 里一条「一票否决」的开关。它的影响面在 beginWork 的 switch 里最清楚:IncompleteClassComponent(ReactFiberBeginWork.js:4416)与 IncompleteFunctionComponent(:4434)在 disableLegacyMode 为真时直接 break (落到报错),因为这两个 tag 只在 legacy 模式下使用。flushSync 也依赖它(ReactDOMFlushSync.js:61):legacy 构建里它被替换成直接抛错的版本。
15H.2 ReactDOM.render / unmountComponentAtNode / findDOMNode
诚实的源码结论分两半:
- 公开
react-dom入口已不再导出它们 (导出列表只剩browser / createPortal / flushSync / prefetchDNS / preconnect / preload / preloadModule / preinit / preinitModule / requestFormReset / useFormState / useFormStatus / version),调用ReactDOM.render(...)得到的是 TypeError。 - 带报错文案的实现只在 FB 变体 (
ReactDOMRootFB.js:396/:440):ReactDOM.render was removed in React 19...与unmountComponentAtNode was removed in React 19...。
js
// packages/react-dom/src/client/ReactDOMRootFB.js:396
'ReactDOM.render was removed in React 19. Use createRoot instead.',
// packages/react-dom/src/client/ReactDOMRootFB.js:440
'unmountComponentAtNode was removed in React 19. Use root.unmount() instead.',
findDOMNode 在新代码里也只挂在 internals 上 (ReactDOMClient.js:43 定义,:50 处 Internals.findDOMNode = findDOMNode;),并不作为公开 API 导出。
所以准确表述是 :「React 19 移除了这些 API」------对 OSS 构建而言,是从导出表里删除;FB 构建里额外给了一条报错文案。不要写成「调用时会抛 'was removed in React 19'」------OSS 构建不会,那是 FB 变体的行为。
15H.3 ReactDOMServer.renderToNodeStream
renderToNodeStream / renderToStaticNodeStream 在整个仓库里搜不到实现 (grep 只命中 prerenderToNodeStream 这类新名字)。19.3.1 的流式方案是 renderToReadableStream(Web Stream)与 prerenderToNodeStream / resumeAndPrerenderToNodeStream(Node)。renderToString / renderToStaticMarkup 仍保留在 legacy server 入口(ReactDOMLegacyServerBrowser.js:42),但不支持 Suspense。
15H.4 react-test-renderer 弃用
packages/react-test-renderer/README.md 第一行就是 # react-test-renderer (DEPRECATED)。运行时警告在 packages/react-test-renderer/src/ReactTestRenderer.js:486:
js
// packages/react-test-renderer/src/ReactTestRenderer.js:486
console.error(
'react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer',
);
而且 react-test-renderer/shallow 已被硬删除(packages/react-test-renderer/npm/shallow.js):构造 ReactShallowRenderer 直接 throw ... has been removed。官方推荐迁移到 @testing-library/react(见 README)。
15H.5 需要纠正的一条:createElement 仍会解析 defaultProps
§11.2 说「函数组件的 defaultProps 已移除」。这句话在协调器 层面是对的(updateFunctionComponent 不再调 resolveDefaultProps),但有一个容易被忽略的例外 ------createElement 自己仍然会合并 type.defaultProps(ReactJSXElement.js:703):
js
// packages/react/src/jsx/ReactJSXElement.js:703
// Resolve default props
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
这段是无条件的。所以更精确的结论是:
- 用
<C />(JSX 运行时 →jsxProd)且C是函数组件:defaultProps不会 被jsxProd处理(jsxProd里没有这段),也不会被协调器处理 → 失效。 - 用
React.createElement(C)手工调用:defaultProps仍会被合并 (因为这段在createElement里)。 - 类组件的
defaultProps两条路径都保留(协调器里走resolveClassComponentProps,ReactFiberClassComponent.js:1204)。
另外,isSimpleFunctionComponent(ReactFiber.js:308)里有一句 type.defaultProps === undefined------defaultProps 的存在仍会影响 fiber 的 tag 选择 (带 defaultProps 的函数组件不会走 SimpleMemoComponent 快路径)。
16. 源码索引表(本章关键文件 + 函数 + 行号速查)
| 文件 | 函数 / 符号 | 行号 | 作用 |
|---|---|---|---|
packages/react/src/ReactHooks.js |
use |
207 | 公开导出 use |
packages/react/src/ReactHooks.js |
useOptimistic |
226 | 公开导出 |
packages/react/src/ReactHooks.js |
useActionState |
234 | 公开导出(源码别名 useFormState) |
packages/react/src/ReactContext.js |
createContext |
35 | context.Provider = context 关键一行 |
packages/react/src/ReactForwardRef.js |
forwardRef |
12 | 仍可用,向后兼容 |
packages/react-reconciler/src/ReactFiberHooks.js |
use |
1159 | use 分派:thenable/context/recoverable |
packages/react-reconciler/src/ReactFiberHooks.js |
useThenable |
1098 | 挂起读取 thenable + 重放 |
packages/react-reconciler/src/ReactFiberHooks.js |
thenableIndexCounter/thenableState |
287 | 挂起位置追踪 |
packages/react-reconciler/src/ReactFiberHooks.js |
mountOptimistic |
1974 | 乐观更新挂载 |
packages/react-reconciler/src/ReactFiberHooks.js |
updateOptimisticImpl |
2013 | 每帧 baseState=passthrough |
packages/react-reconciler/src/ReactFiberHooks.js |
ActionStateQueue/Node |
2068/2084 | 串行队列结构 |
packages/react-reconciler/src/ReactFiberHooks.js |
dispatchActionState |
2106 | 入队 + 熔断检查 |
packages/react-reconciler/src/ReactFiberHooks.js |
runActionStateAction |
2172 | 执行 action(重建 transition) |
packages/react-reconciler/src/ReactFiberHooks.js |
handleActionReturnValue |
2270 | 监听 thenable 结果 |
packages/react-reconciler/src/ReactFiberHooks.js |
onActionSuccess/onActionError |
2312/2343 | 成功推进队列 / 错误熔断 |
packages/react-reconciler/src/ReactFiberHooks.js |
mountActionState |
2380 | 三钩子组合 |
packages/react-reconciler/src/ReactFiberHooks.js |
updateActionStateImpl |
2481 | 渲染期 useThenable 解包 |
packages/react-reconciler/src/ReactFiberHooks.js |
startTransition |
3117 | 内核 transition(支持 async thenable) |
packages/react-reconciler/src/ReactFiberHooks.js |
startHostTransition |
3266 | 表单提交即 transition |
packages/react-reconciler/src/ReactFiberHooks.js |
ensureFormComponentIsStateful |
3309 | 给 host fiber 注入 hook |
packages/react-reconciler/src/ReactFiberHooks.js |
requestFormReset |
3377 | 表单自动重置(须 transition 内) |
packages/react-reconciler/src/ReactFiberHooks.js |
mountTransition |
3425 | useTransition 挂载 |
packages/react-reconciler/src/ReactFiberHooks.js |
useHostTransitionStatus |
3473 | 读 HostTransitionContext |
packages/react-reconciler/src/ReactFiberHooks.js |
dispatchOptimisticSetState |
3728 | 乐观更新 + revertLane |
packages/react-reconciler/src/ReactFiberHooks.js |
updateReducerImpl(revertLane) |
1482 | 回滚跳过逻辑 |
packages/react-reconciler/src/ReactFiberHooks.js |
imperativeHandleEffect |
2817 | ref 清理函数支持 |
packages/react-reconciler/src/ReactFiberAsyncAction.js |
entangleAsyncAction |
71 | 并发 async action 纠缠 |
packages/react-reconciler/src/ReactFiberAsyncAction.js |
peekEntangledActionLane |
189 | 当前纠缠车道 |
packages/react-reconciler/src/ReactFiberBeginWork.js |
updateForwardRef |
404 | 摘 ref 兼容处理 |
packages/react-reconciler/src/ReactFiberBeginWork.js |
updateFunctionComponent |
1453 | 函数组件无 defaultProps 解析 |
packages/react-reconciler/src/ReactFiberCommitEffects.js |
commitAttachRef |
757 | 挂载 ref + refCleanup |
packages/react-reconciler/src/ReactFiberCommitEffects.js |
safelyDetachRef |
842 | 调用 refCleanup |
packages/react-reconciler/src/ReactFiberCommitEffects.js |
effect destroy 校验 | 188/204 | 清理函数须为函数、禁 Promise |
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js |
useFormStatus |
67 | 实为 useHostTransitionStatus |
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js |
requestFormReset |
81 | 委托给 ReactDOMCurrentDispatcher |
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js |
coerceFormActionProp |
27 | action 函数/URL 区分 |
packages/react-dom-bindings/src/client/ReactDOMComponent.js |
SingletonType |
1498 | html/head/body 单例 |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
head 匹配/启发式 | 4133/4978 | 元数据提升与 hydration |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
clearHead |
4042 | head 清理 |
packages/react/src/jsx/ReactJSXElement.js |
elementRefGetterWithDeprecationWarning |
143 | element.ref 读取即报错(15A.3) |
packages/react/src/jsx/ReactJSXElement.js |
createElement 合并 defaultProps |
703 | 手工 createElement 仍解析 defaultProps(15H.5) |
packages/react-reconciler/src/ReactFiber.js |
isSimpleFunctionComponent |
308 | type.defaultProps === undefined 影响 tag(15H.5) |
packages/react-reconciler/src/ReactFiberCommitWork.js |
recursivelyResetForms / resetFormOnFiber |
2866/2876 | 全树 DOM 更新后再重置(15B.7) |
packages/react-reconciler/src/ReactFiberFlags.js |
FormReset = Snapshot |
50 | flag 复用(一址多用)(15B.4) |
packages/react-reconciler/src/ReactFiberHostContext.js |
pushHostContext |
96 | form 状态压入 HostTransitionContext(15B.5) |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
requestFormReset |
5236 | 从 DOM 反查 fiber 再委托(15B.6) |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
resetFormInstance |
7023 | 三明治包裹原生 form.reset()(15B.7) |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
insertStylesheet |
6111 | data-precedence 决定真实 DOM 顺序(15F.5) |
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js |
preloadPropsMap / preconnectsSet |
5174 | 资源去重两张全局表(15F.4) |
packages/react-dom/src/shared/ReactDOMFloat.js |
preconnect / preload / preinit |
58/89/211 | 资源提示公开实现(15F.1) |
packages/react-reconciler/src/ReactFiberHooks.js |
rerenderOptimistic |
2038 | 每帧 rebase 重放(15D.1) |
packages/react-reconciler/src/ReactFiberThenable.js |
trackUsedThenable 复用旧 thenable |
134 | 「禁止 use(fetch())」的根据(15E.2) |
packages/react-server/src/ReactFizzHooks.js |
createPostbackActionStateKey |
670 | permalink 仅服务端消费(15C.4) |
packages/react/src/ReactOwnerStack.js |
captureOwnerStack |
12 | DEV-only owner 栈(15G.1) |
packages/react-reconciler/src/ReactCurrentFiber.js |
setCurrentFiber |
84 | owner 栈来源(15G.2) |
17. 自测 FAQ
Q1:useActionState 和 useFormState 是什么关系? A:同一个实现的两个名字。源码(ReactFiberHooks.js:4839 起)的 dispatcher 表里,useFormState 和 useActionState 都映射到 mountActionState。useFormState 是早期命名,useActionState 是 React 19 正式公开名。
Q2:useOptimistic 的"自动回滚"是真的 setState(旧值) 吗? A:不是。它给乐观更新打上 revertLane(:3783),当绑定 transition 完成时,updateReducerImpl 在 :1482 通过 isSubsetOfLanes(renderLanes, revertLane) 判定"该更新作废"并 continue 跳过------即"不应用"而非"主动还原"。这是用车道做时间锚点的优雅设计。
Q3:为什么 useOptimistic 的 addOptimistic 必须包 transition? A:因为回滚依赖 revertLane 精确关联某个 transition 的完成时机(:3737)。无 transition 时 revertLane 落到默认车道,回滚时机不可预期,DEV 会报错。
Q4:useActionState 会让界面"卡住"吗? A:action 未完成时,useThenable 会让组件挂起(:2506),最近的 <Suspense> 显示 fallback。这是刻意设计。若不想悬挂,用 useTransition + 手动 state 替代,或就近包 Suspense。
Q5:React 19 支持 async function useEffect() 吗? A:不支持 。源码(:204)明确拦截"返回 Promise 的清理函数"并报警。effect 仍须同步,异步逻辑应写在 effect 内部立即调用的 async 函数里。
Q6:函数式 ref 返回清理函数是什么? A:React 19 新增。函数 ref (node) => { ...; return () => cleanup } 的返回值会被存入 fiber.refCleanup(:798),在 ref 替换/卸载时调用(:846)。这与 effect 清理函数概念对称,但是 React 19 新增能力(effect 清理一直都有)。
Q7:<Context> 直接作 Provider 是怎么实现的? A:createContext 里 context.Provider = context(:35)。两者是同一引用,协调器对 REACT_CONTEXT_TYPE 走同一套 Provider 逻辑,与写 .Provider 完全等价。
Q8:string ref 在 React 19 还能用吗? A:不能。源码(:810)DEV 下 console.error('String refs are no longer supported.')。
Q9:函数组件 defaultProps 还能用吗? A:不能。React 19 移除。updateFunctionComponent(:1453)不再解析 Component.defaultProps,nextProps 原样传入。请用默认参数/解构默认值。
Q10:use(promise) 能用来发请求吗? A:不能。每次渲染创建新 promise 会导致无限挂起/重渲染(见 13.6)。use 只适合读取"已存在且稳定"的 promise(如 RSC 传入的)。
Q11:<form action={fn}> 提交后为什么表单会自动清空? A:startHostTransition(:3304)在 action 返回后调用 requestFormReset(formFiber),复用底层更新机制重置表单字段。
Q12:requestFormReset 为什么必须在 transition/action 内调用? A:同乐观更新(:3378 起),脱离 transition 作用域时 DEV 报错,因为 reset 的更新车道需要关联当前 transition 才能保证时序正确。
Q13:多个 useActionState 的 dispatch 并发会乱序吗? A:不会。ActionStateQueue 是环形链表串行队列(:2084、:2158),前一个完成才跑下一个,保证 (prevState, payload) => nextState 的顺序语义。
Q14:某个 action 抛错会怎样? A:队列熔断。onActionError(:2343)把所有后续节点标 rejected 并置 actionQueue.action = null;之后 dispatchActionState(:2118)检测到 null 直接 return,后续 dispatch 被丢弃。
Q15:文档元数据 <title> 写在 <div> 里为何跑到 <head>? A:React 19 把 html/head/body 视为宿主单例(:1498),元数据节点在 commit 阶段被提升(HostHoistable)插入 document.head(:4978),hydration 时用启发式匹配避免重复(:4133)。
Q16:<Context> 与 <Context.Provider> 完全等价吗? A:等价。createContext 内部执行 context.Provider = context(ReactContext.js:35),二者是同一引用。协调器对 REACT_CONTEXT_TYPE 走同一套 Provider 逻辑(ReactFiberBeginWork.js:3719 的 updateContextProvider),没有第二份实现。这也是为什么 19 可以直接写 <Theme value={...}>。
Q17:forwardRef 组件里的 defaultProps 还有效吗? A:无效,且 DEV 会报错。ReactForwardRef.js:42 在 __DEV__ 下检查 render.defaultProps != null 并报警。这与函数组件 defaultProps 失效同源(§11.2、15H.5):请改用参数默认值。
Q18:preload(href, opts) 重复调用会产生多个 <link> 吗? A:不会。渲染器用两张全局表去重------preloadPropsMap(Map,键为 href)管 preload/preloadModule,preconnectsSet(Set)管 preconnect/prefetchDNS(ReactFiberConfigDOM.js:5174)。因此这些 API 对外承诺幂等:同 href 只发一次标签。
Q19:preinit(href, {as:'style'}) 与渲染 <link rel="stylesheet" precedence> 有什么区别? A:底层同一套插入逻辑(都走 insertStylesheet,ReactFiberConfigDOM.js:6111),但 preinit 是命令式 的:不渲染任何 React 节点,直接在 document.head 插入并立即加载/执行 (ReactDOMFloat.js:211),因此更适合「不在 JSX 渲染树里表达」的资源;<link precedence> 则是声明式、参与 HostHoistable 提升(§8)。
Q20:captureOwnerStack() 能在生产环境里拿来做错误上报吗? A:不能。它只在 __DEV__ 为真时可用,生产构建里直接 return null 并被压缩成死代码(ReactOwnerStack.js:12)。它面向的是开发期自定义校验、日志与测试断言;生产环境的错误栈请依赖渲染器本身。
18. 本章要点小结
- React 19 的 Action 特性是既有内核的封装:全部建立在 Transition(第 7/8 章)、Lane、Suspense(第 9 章)、Fiber 协调(第 4 章)之上,没有发明新引擎。
<form action>= 自动包 transition :FormActionEventPlugin→startHostTransition→startTransition,并提交后自动 reset。useOptimistic回滚靠revertLane:每帧把baseState重置为passthrough(:2025),transition 完成时updateReducerImpl跳过该更新(:1482)实现"自动回滚"。useActionState= 三钩子 + 串行队列 +use解包 :state 以 thenable 存储,渲染期useThenable悬挂;队列保证顺序,错误熔断。use统一读取 Context 与 thenable :useContext是它的特化别名;挂起重放机制(:1124)保证 hook 顺序一致。- ref 成为普通 prop :
updateForwardRef仅做兼容摘取(:427);commitAttachRef支持 ref 清理函数(:798),String ref 移除(:810)。 - 文档元数据自动提升 :
head等宿主单例 + HostHoistable + 启发式匹配。 <Context>即 Provider :context.Provider = context(:35)。- effect 清理函数一直都有,且仍须同步 ;异步 effect 仍被拦截(
:204);新增的是 ref 清理函数。 - 破坏性变更 :函数组件
defaultProps移除、String ref 移除、legacy Context 移除------都在源码中有明确落点。
本章所有行号均取自
react-19.3.1的 19.3.1 版本。若你阅读的版本不同,请以实际文件为准;凡与官方文档或社区传言冲突处,本章以源码为准。