React 19.3.1 源码深度剖析-第 12 章(下)RSC 与 Flight 协议
本篇是《第 12 章 服务端渲染与 React Server Components》的下半部分。上半部分(第 12 章 A)已讲透 CSR/SSR/流式 SSR 的演进、Fizz 架构与 hydration。
本篇继续:React Server Components 的客户端/服务器边界、
use client/use server的机制、Flight 序列化协议、RSC 与 SSR 的双流合流、preinit/preload/preconnect,以及边界坑与 FAQ。阅读前置:第 12 章 A、第 6 章(Commit 阶段)、第 9 章(Suspense)。
12.6 RSC 架构:服务器组件与客户端组件的边界
12.6.1 边界是什么
RSC 的世界里,组件被分成两个执行环境:
- 服务器组件(Server Component) :只在服务器执行。可以直接
async、直连数据库/文件系统、持有密钥。它的「返回值」不是 DOM,而是 React 元素(其中可以包含客户端组件的引用)。 - 客户端组件(Client Component) :带
'use client'指令的模块及其传递依赖。它们在两端都执行(服务器上为了 SSR 首屏,客户端上为了水合与交互),可以用useState/useEffect/事件处理器。
判定发生在序列化时,ReactFlightServer.js:2362 的 renderElement:
js
if (
typeof type === 'function' &&
!isClientReference(type) &&
!isOpaqueTemporaryReference(type)
) {
// This is a Server Component.
return renderFunctionComponent(request, task, key, type, props, validated);
} else if (type === REACT_FRAGMENT_TYPE && key === null) {
// ...
「函数且不是 client reference」→ 服务器组件,直接调 renderFunctionComponent 执行;否则走 renderClientElement(:2185)序列化成「客户端元素引用」。一个关键推论:服务器组件不能把「自身」传给客户端------序列化时普通函数会抛错(见 12.7.7)。
12.6.2 react-server 条件导出:API surface 即边界
边界的第二道闸在包导出层。packages/react/package.json 的 exports:
json
{
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},
"./jsx-runtime": {
"react-server": "./jsx-runtime.react-server.js",
"default": "./jsx-runtime.js"
}
}
react.react-server.js → packages/react/src/ReactServer.js,它的导出清单就是「服务器组件能用的全部 API」:
js
export {
Children,
REACT_ACTIVITY_TYPE as Activity,
REACT_FRAGMENT_TYPE as Fragment,
REACT_PROFILER_TYPE as Profiler,
REACT_STRICT_MODE_TYPE as StrictMode,
REACT_SUSPENSE_TYPE as Suspense,
REACT_VIEW_TRANSITION_TYPE as ViewTransition,
cloneElement,
createElement,
createRef,
use,
forwardRef,
isValidElement,
lazy,
memo,
cache,
cacheSignal,
useId,
useCallback,
useDebugValue,
useMemo,
version,
captureOwnerStack, // DEV-only
};
对比 packages/react/src/ReactClient.js(客户端入口),差异一目了然:服务器侧没有 useState、useEffect、useLayoutEffect、useRef、useReducer、useTransition、useDeferredValue、useSyncExternalStore、useOptimistic、useActionState、startTransition、createContext、Component、PureComponent、SuspenseList、unstable_scope 等 。这不是文档约定,是 import 直接失败------模块里就没有这些符号。use、cache、cacheSignal、useId、useCallback、useMemo、useDebugValue 是白名单:它们要么无状态(useMemo 直接 nextCreate())、要么只是数据读取(use)、要么是请求级缓存(cache)。
而且即便绕过导出白名单,运行时还有第三道闸。Flight 的 HooksDispatcher(packages/react-server/src/ReactFlightHooks.js:66):
js
export const HooksDispatcher: Dispatcher = {
readContext: unsupportedContext as any,
use,
useCallback<T>(callback: T): T {
return callback;
},
useContext: unsupportedContext as any,
useEffect: unsupportedHook as any,
useImperativeHandle: unsupportedHook as any,
useLayoutEffect: unsupportedHook as any,
useInsertionEffect: unsupportedHook as any,
useMemo<T>(nextCreate: () => T): T {
return nextCreate();
},
useReducer: unsupportedHook as any,
useRef: unsupportedHook as any,
useState: unsupportedHook as any,
useDebugValue(): void {},
useDeferredValue: unsupportedHook as any,
useTransition: unsupportedHook as any,
useSyncExternalStore: unsupportedHook as any,
useId,
useHostTransitionStatus: unsupportedHook as any,
useFormState: unsupportedHook as any,
useActionState: unsupportedHook as any,
useOptimistic: unsupportedHook as any,
useMemoCache(size: number): Array<any> { /* React Compiler 的 memo cache */ },
useCacheRefresh(): <T>(?() => T, ?T) => void {
return unsupportedRefresh;
},
useEffectEvent: unsupportedHook as any,
};
function unsupportedHook(): void {
throw new Error('This Hook is not supported in Server Components.');
}
function unsupportedRefresh(): void {
throw new Error(
'Refreshing the cache is not supported in Server Components.',
);
}
function unsupportedContext(): void {
throw new Error('Cannot read a Client Context from a Server Component.');
}
三层防御(exports 白名单 → dispatcher 抛错 → 序列化报错)共同保证了「服务器组件里没有状态和副作用」不是约定而是强约束。原因也清楚:服务器组件只执行一次,输出被序列化后「死」在字节流里,没有第二次渲染去同步状态;effect 在服务器没有 DOM 可挂;context 是客户端的响应式机制,服务器上没有订阅者。
useId 在 Flight 侧还刻意换了一套前缀以区分环境(ReactFlightHooks.js:126):
js
function useId(): string {
if (currentRequest === null) {
throw new Error('useId can only be used while React is rendering');
}
const id = currentRequest.identifierCount++;
// use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client
return '_' + currentRequest.identifierPrefix + 'S_' + id.toString(32) + '_';
}
Fizz 用 R/r、Flight 用 S------同一个页面里 SSR 生成的 id 与 RSC 生成的 id 不会混淆,identifierPrefix 选项则进一步隔离多个渲染请求。
12.6.3 use client / use server:指令如何被 bundler 识别
指令本身只是源码顶部的字符串字面量。识别它的是 bundler 插件。React 仓库自带的参考实现是 packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js 的 transformModuleIfNeeded(:648):
js
async function transformModuleIfNeeded(
source: string,
url: string,
loader: LoadFunction,
): Promise<string> {
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (
source.indexOf('use client') === -1 &&
source.indexOf('use server') === -1
) {
return source;
}
// ... acorn.parse(source, {ecmaVersion: '2024', sourceType: 'module', ...})
let useClient = false;
let useServer = false;
const body = program.body;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
break;
}
if (node.directive === 'use client') {
useClient = true;
}
if (node.directive === 'use server') {
useServer = true;
}
}
if (!useClient && !useServer) {
return source;
}
if (useClient && useServer) {
throw new Error(
'Cannot have both "use client" and "use server" directives in the same file.',
);
}
// ...
注意它用 node.directive(AST 里的指令序言属性)而不是字符串查找来判定位置------'use client' 出现在函数体里不算指令。同一文件两个指令直接抛错。这个 loader 同时被 webpack 插件(ReactFlightWebpackPlugin.js)与 Node 直接运行 ESM 的 loader(ReactFlightWebpackNodeRegister.js)复用,是「框架无关」的参考实现;Next.js 等框架有自己的等价物。
'use client' 文件被编译时,bundler 会把每个导出替换为 registerClientReference(impl, moduleId, exportName) 的调用(客户端 bundle 里则是原样保留的模块)。于是服务器上「import 一个客户端组件」拿到的不是函数,而是一个带 $$typeof/$$id/$$async 标记的对象------ Flight 据此序列化「引用」而不是「代码」。
12.6.4 registerClientReference / registerServerReference 源码
packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js:
js
const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference');
export function isClientReference(reference: Object): boolean {
return reference.$$typeof === CLIENT_REFERENCE_TAG;
}
export function registerClientReference<T>(
proxyImplementation: any,
id: string,
exportName: string,
): ClientReference<T> {
return registerClientReferenceImpl(
proxyImplementation,
id + '#' + exportName,
false,
);
}
function registerClientReferenceImpl<T>(
proxyImplementation: any,
id: string,
async: boolean,
): ClientReference<T> {
return Object.defineProperties(proxyImplementation, {
$$typeof: {value: CLIENT_REFERENCE_TAG},
$$id: {value: id},
$$async: {value: async},
});
}
客户端引用 = 一个对象(通常是抛错函数做 proxyImplementation)+ 三个不可枚举标记。$$id 的格式是 moduleId#exportName。$$async 标记该引用是否是 async 模块(供 then 场景的 getReference 特殊处理,见下文)。
服务端引用(Server Action 的引用):
js
export function registerServerReference<T: Function>(
reference: T,
id: string,
exportName: null | string,
): ServerReference<T> {
const $$typeof = {value: SERVER_REFERENCE_TAG};
const $$id = {
value: exportName === null ? id : id + '#' + exportName,
configurable: true,
};
const $$bound = {value: null, configurable: true};
return Object.defineProperties(
reference as any,
__DEV__
? ({
$$typeof, $$id, $$bound,
$$location: {value: Error('react-stack-top-frame'), configurable: true},
bind: {value: bind, configurable: true},
toString: serverReferenceToString,
} as PropertyDescriptorMap)
: ({$$typeof, $$id, $$bound, bind: {value: bind, configurable: true},
toString: serverReferenceToString} as PropertyDescriptorMap),
);
}
几个细节:
- 自定义
bind(:65)保留引用身份:普通fn.bind()会丢掉$$typeof,于是 React 重写了 bind,把$$bound(预置参数)concat 上去------这是 Server Action 「.bind 传默认参数」功能的根基。 toString返回'function () { [omitted code] }':避免把服务端实现源码泄漏到客户端 bundle 或报错信息里。createClientModuleProxy(moduleId)(:349)返回一个 Proxy:任何属性访问都返回一个新的 client reference(target.$$id + '#' + name),而「点进客户端模块再调用」会被拦下:
js
const expression = String(target.name) + '.' + String(name);
throw new Error(
`Cannot access ${expression} on the server. ` +
'You cannot dot into a client module from a server component. ' +
'You can only pass the imported name through.',
);
这条错误信息精确表达了 RSC 的边界语义:服务器组件只能「传递导入名」,不能使用客户端模块的值。getReference 里对 Provider、then、__esModule、toJSON 等名字有专门分支(比如 Provider 返回自身,让 <ClientContext.Provider> 可用)。
12.6.5 'use client' 的传递性
「传递性」来自 bundler 的模块图,而不是 React 运行时:'use client' 模块 A import 的模块 B 会被打进客户端 bundle,B 也就成了「客户端模块」。有两个推论经常踩坑:
- 服务器组件 import 的任何东西,如果传递依赖了
'use client',整条链都进客户端。想拆细就得把边界文件保持「干净」(只 import 真正无副事的共享模块)。 export * from不会传递指令 :ReactFlightWebpackNodeLoader.js:202的注释明确说「If export * is used, the other file needs to explicitly opt into "use server" too」。use server的传递同样依赖显式声明------因为 re-export 的文件必须自己带指令才会被当成 action 模块。
React 源码里还有一处「隐性传递」值得知道:ReactFlightServer.js:2196 的 renderClientElement 会把服务器组件的 keyPath 拼到客户端元素的 key 上(key = keyPath + ',' + key),而 Flight 客户端的 getTaskName(ReactFlightClient.js:1351)在生成调试栈时把函数类型的 type 一律显示为 "use client"------因为对客户端来说,任何跨边界过来的函数都是「从 use client 边界进入的」。
12.7 Flight 协议:把 React 树变成行格式流
12.7.1 行格式:一切内容的统一信封
Flight(在源码里也叫 RSC payload / wire format)的定义特征:整个协议就是「一行一行的文本」 。每一行 = 十六进制 id + : + tag + JSON 或二进制内容 + \n。核心的发射函数在 packages/react-server/src/ReactFlightServer.js:
js
function serializeRowHeader(tag: string, id: number) {
return id.toString(16) + ':' + tag;
}
function encodeReferenceChunk(
request: Request,
id: number,
reference: string,
): Chunk {
const json = stringify(reference);
const row = id.toString(16) + ':' + json + '\n';
return stringToChunk(row);
}
function emitModelChunk(request: Request, id: number, json: string): void {
const row = id.toString(16) + ':' + json + '\n';
const processedChunk = stringToChunk(row);
request.completedRegularChunks.push(processedChunk);
}
function emitImportChunk(
request: Request,
id: number,
json: string,
debug: boolean,
): void {
const row = serializeRowHeader('I', id) + json + '\n';
const processedChunk = stringToChunk(row);
if (__DEV__ && debug) {
request.completedDebugChunks.push(processedChunk);
} else {
request.completedImportChunks.push(processedChunk);
}
}
function emitTextChunk(
request: Request,
id: number,
text: string,
debug: boolean,
): void {
// ...
const textChunk = stringToChunk(text);
const binaryLength = byteLengthOfChunk(textChunk);
const row = id.toString(16) + ':T' + binaryLength.toString(16) + ',';
const headerChunk = stringToChunk(row);
// See emitTypedArrayChunk for why the pair is preceded by a sentinel.
if (__DEV__ && debug) {
request.completedDebugChunks.push(
NEXT_TWO_CHUNKS_ARE_ATOMIC, headerChunk, textChunk,
);
} else {
request.completedRegularChunks.push(
NEXT_TWO_CHUNKS_ARE_ATOMIC, headerChunk, textChunk,
);
}
}
已知的 tag 全集(服务端发射 + 客户端解析两侧对照):
| 行形态 | 服务端发射处 | 客户端处理 | 含义 |
|---|---|---|---|
id:JSON(无 tag) |
emitModelChunk |
resolveModel(ReactFlightClient.js:3309) |
模型行:任意 JSON 值 |
id:I{...} |
emitImportChunk |
resolveModule(:3386) |
客户端模块引用(chunk URL + export 名) |
id:T<len>,<bytes> |
emitTextChunk |
resolveText(:3334) |
大字符串(≥1024 字符,外置避免双重转义) |
id:<大写字母><len>,<bytes> |
emitTypedArrayChunk(ReactFlightServer.js:5116) |
resolveTypedArray(ReactFlightClient.js:3360) |
TypedArray/DataView/ArrayBuffer,字母表记类型(O=Int8、o=Uint8、A=ArrayBuffer、V=DataView......) |
id:"$ref" |
encodeReferenceChunk |
parseModelString 的 $ 前缀分支 |
纯引用行(如 symbol) |
:H<code><json> |
emitHintChunk |
resolveHint(:3963) |
资源提示(preload/preinit),无 id |
id:E{...} |
emitErrorChunk |
resolveErrorModel(:3929) |
错误行(digest + DEV 下的 message/stack) |
id:D{...} |
emitDebugChunk |
resolveDebugModel |
DEV-only 组件调试信息 |
id:W... |
emitConsoleChunk |
resolveConsoleEntry |
DEV-only 服务端 console 转发 |
id:R / id:r |
serializeReadableStream |
startReadableStream(:3464) |
ReadableStream 起点(r=bytes) |
id:X / id:x |
serializeAsyncIterable |
startAsyncIterable(:3654) |
AsyncIterable 起点 |
id:C<json> |
(流结束标记) | stopStream(:3792) |
流关闭指令 |
注意 emitTextChunk/emitTypedArrayChunk 里的 NEXT_TWO_CHUNKS_ARE_ATOMIC 哨兵:header 与二进制体必须连续写出,否则背压翻转时下一次 drain 可能在两者之间插入 Import/Hint 行,客户端就会把错误字节框进本行。这是一个很典型的「协议层防交错」设计,注释(ReactFlightServer.js:5145)把因果关系写得非常清楚。
二进制行与文本行的边界由长度 而非分隔符决定:T<len>, 之后的 len 个字节就是内容;JSON 行则由 \n 终止。这决定了客户端解析器必须同时支持「按长度读」和「按换行读」两种模式(见 12.7.4)。
12.7.2 服务端:createRequest 到 flushCompletedChunks
RequestInstance 与 createRequest
Flight 的 RequestInstance(ReactFlightServer.js:686)与 Fizz 的同名概念平行但字段不同:
js
this.type = type; // RENDER=20 / PRERENDER=21
this.status = OPENING;
this.bundlerConfig = bundlerConfig; // ClientManifest:客户端模块映射
this.cache = new Map(); // request 级 cache(React.cache)
this.cacheController = new AbortController(); // 渲染结束即 abort,清理未用缓存
this.nextChunkId = 0;
this.pendingChunks = 0;
this.hints = hints;
this.abortableTasks = abortSet;
this.pingedTasks = pingedTasks;
this.completedImportChunks = [];
this.completedHintChunks = [];
this.completedRegularChunks = [];
this.completedErrorChunks = [];
this.writtenSymbols = new Map();
this.writtenClientReferences = new Map();
this.writtenServerReferences = new Map();
this.writtenObjects = new WeakMap();
this.writtenImportStrings = new Map();
this.writtenImportStringsSize = 0;
this.temporaryReferences = temporaryReferences;
this.identifierPrefix = identifierPrefix || '';
this.identifierCount = 1;
// ...
const rootTask = createTask(
this, model, null, false, createRootFormatContext(), abortSet,
timeOrigin, null, null, null,
);
pingedTasks.push(rootTask);
去重用的四张表是 Flight 的核心资产:
writtenObjects: WeakMap------序列化过的对象 → 引用串($id或parentId:propName);writtenClientReferences/writtenServerReferences------引用 → chunk id;writtenSymbols/writtenImportStrings------symbol / 长 URL 字符串去重。
Task、renderModel 与 renderModelDestructive
createTask(ReactFlightServer.js:2862)创建任务,retryTask(:6268)执行它:
js
function retryTask(request: Request, task: Task): void {
if (task.status !== PENDING) {
return;
}
const prevCanEmitDebugInfo = canEmitDebugInfo;
task.status = RENDERING;
const parentSerializedSize = serializedSize;
try {
// Track the root so we know that we have to emit this object even though it
// already has an ID. This is needed because we might see this object twice
// in the same resolveModel walk if it is cyclic.
modelRoot = task.model;
// ...
const resolvedModel = renderModelDestructive(
request, task, emptyRoot, '', task.model,
);
// ...
task.keyPath = null;
task.implicitSlot = false;
// ...
if (typeof resolvedModel === 'object' && resolvedModel !== null) {
request.writtenObjects.set(resolvedModel, serializeByValueID(task.id));
emitChunk(request, task, resolvedModel);
} else {
const json: string = stringify(resolvedModel);
emitModelChunk(request, task.id, json);
}
task.status = COMPLETED;
request.abortableTasks.delete(task);
callOnAllReadyIfReady(request);
} catch (thrownValue) {
// 挂起:task 回到 PENDING,记 thenableState,x.then(ping, ping)
// 错误:erroredTask(request, task, x)
} finally {
serializedSize = parentSerializedSize;
}
}
renderModelDestructive(:3774)是最大的分派函数,按值类型走:
REACT_ELEMENT_TYPE→ 返回'$'(客户端 parse 时还原成 symbol,ReactFlightClient.js:2679);- 元素(
case REACT_ELEMENT_TYPE)→ 先查writtenObjects去重;再renderElement; - lazy → 先
init(payload)解析再递归; - client reference(
isClientReference(value))→serializeClientReference; - thenable →
serializeThenable(:1109),返回$@id/$wid; - 普通对象 → 检查原型链(
proto !== ObjectPrototype && (proto === null || getPrototypeOf(proto) !== null)抛「Only plain objects...」),否则原样返回(由后续resolveModel+stringify处理,见下); - 数组 →
renderFragment(:2059); - Map/Set/FormData/Error/各 TypedArray/Blob/可迭代/ReadableStream/AsyncIterable/Date → 各自的
serializeXxx; - 字符串 → 长度 ≥1024 走
serializeLargeTextString(外置T行),否则escapeStringValue($$转义); - number/bigint/undefined →
$Infinity/$n.../$undefined; - 函数 → 只有 client/server reference 合法,否则抛错(见 12.7.7);
- symbol → 只接受
Symbol.for,发射 symbol 行,返回$S<name>或$id。
这里有一个容易被忽视的设计:普通对象不被递归序列化,而是「原样返回」交给 resolveModel + JSON.stringify 。emitChunk(:6151)里看得最清楚:
js
// For anything else we need to try to serialize it using JSON.
// We resolve the model tree first in pure JS to avoid the C++->JS boundary
// overhead of JSON.stringify's replacer callback.
const resolvedModel = resolveModel(request, task, {'': value}, '', value);
const json: string = stringify(resolvedModel);
emitModelChunk(request, task.id, json);
resolveModel(:2948)用纯 JS 递归把「树里可能嵌着的元素/thenable/引用」提前解析成「带 $ 前缀的 JSON 值」,然后一次性 stringify。注释解释了动机:避免 JSON.stringify 的 replacer 回调跨 JS/C++ 边界(Hermes/JSC 上开销大)。这也意味着行内的 JSON 是完整自洽的 :所有跨行引用在 stringify 之前就变成了 $xx 字符串。
renderFunctionComponent:服务器组件的执行
renderFunctionComponent(:1797)执行组件本体。DEV 分支很长(调试信息、console patch、componentStorage),生产路径只有几行:
js
} else {
componentDebugInfo = null as any;
prepareToUseHooksForComponent(prevThenableState, null);
// The secondArg is always undefined in Server Components since refs error early.
const secondArg = undefined;
result = Component(props, secondArg);
}
之后 processServerComponentReturnValue(:1664)处理返回值(例如把返回 Promise 的组件当作 async 组件处理、把返回 thenable 的元素包 lazy),最后:
js
// Track this element's key on the Server Component on the keyPath context..
const prevKeyPath = task.keyPath;
const prevImplicitSlot = task.implicitSlot;
if (key !== null) {
if (key === REACT_OPTIMISTIC_KEY || prevKeyPath === REACT_OPTIMISTIC_KEY) {
task.keyPath = REACT_OPTIMISTIC_KEY;
} else {
task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
}
} else if (prevKeyPath === null) {
task.implicitSlot = true;
}
const json = renderModelDestructive(request, task, emptyRoot, '', result);
task.keyPath = prevKeyPath;
task.implicitSlot = prevImplicitSlot;
return json;
keyPath 机制值得多说:服务器组件的 key 不会被序列化输出(服务器组件本身不进客户端),但它的 key 会拼接 到内部客户端元素的 key 上(renderClientElement,:2196)。这样客户端 reconciler 在对比时能把「跨边界的 key 链」当作一个整体处理,reorder 不会错乱。implicitSlot 处理「外层无 key」的情况:给内层显式 key 的元素套一层隐式 slot,避免与同集合里其他 slot 的 key 撞车。REACT_OPTIMISTIC_KEY 是「乐观 key」:一旦某层用了它,整条 key 链都变乐观(useOptimistic 的 key 前缀机制,与第 8 章的乐观更新呼应)。
挂起:use() 与 ThenableState
Flight 的 use(ReactFlightHooks.js:129)→ trackUsedThenable(ReactFlightThenable.js):
js
export function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number,
): T {
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.
thenable.then(noop, noop);
thenable = previous;
}
}
// 用 expando 跟踪 status/value/reason ...
switch (thenable.status) {
case 'fulfilled': { return thenable.value; }
case 'rejected': { throw thenable.reason; }
default: {
// ...给 thenable 挂 .then 更新 status...
// Check one more time in case the thenable resolved synchronously
switch (thenable.status) { ... }
// Suspend.
suspendedThenable = thenable;
throw SuspenseException;
}
}
}
与第 9 章 reconciler 的 ReactFiberThenable 是同一套「thenable 仪表化」思想:给 promise 加 status/value/reason expando,同步可读;thenableState(一个数组)按 use 调用序号记住每个位置用的 thenable,重试时同位置复用旧 thenable(「组件是幂等的,所以同位置必是同一个值」------这是 React 对「渲染中数据一致性」的核心假设)。挂起时抛的 SuspenseException(ReactFlightThenable.js:29)是一个不透明 错误对象,防止用户代码 catch 到真实 thenable;getSuspendedThenable() 是渲染器取回真实 thenable 的唯一通道。
重试语义在 retryTask 的 catch 里:thenable → task.status = PENDING; task.thenableState = getThenableStateAfterSuspending(); x.then(ping, ping)。同一个 task 被复用 ,重试时 prepareToUseHooksForComponent(prevThenableState, ...) 把 thenableState 装回去,已 resolve 的 use 直接返回值,只有没好的继续挂起。这意味着服务器组件的「增量重试」是精确到 use 调用序号的。
emitChunk 的特殊直发
emitChunk 对字符串和 TypedArray 有直发优化(不走 resolveModel):字符串走 emitTextChunk(T 行)、TypedArray 走 emitTypedArrayChunk(带类型字母的行)。好处是二进制零拷贝、大字符串不双重转义。
flushCompletedChunks:五类 chunk 的输出顺序
flushCompletedChunks(:6508)与 Fizz 的 flushCompletedQueues 结构对称,顺序是:imports → hints → debug → regular → errors:
js
// We emit module chunks first in the stream so that
// they can be preloaded as early as possible.
const importsChunks = request.completedImportChunks;
// ...
// Next comes hints.
const hintChunks = request.completedHintChunks;
// ...
// Debug meta data comes before the model data because it will often end up blocking the model from
// completing since the JSX will reference the debug data.
if (__DEV__ && request.debugDestination === null) { /* debugChunks */ }
// Next comes model data.
const regularChunks = request.completedRegularChunks;
// ...
// Finally, errors are sent. The idea is that it's ok to delay
// any error messages and prioritize display of other parts of
// the page.
const errorChunks = request.completedErrorChunks;
理由都写在注释里:模块引用最先发(客户端可以立刻 preload JS);错误最后发(宁可晚点看到错误,先显示内容)。pendingChunks === 0 时:cacheController.abort(...)(通知所有 cache() 的信号清理)、close(destination)。注意 DEV 下 debug channel 可能比主流活得更久(keepDebugAlive 给 HMR 用),主循环对此有专门分支(:6697)。
startWork(:6739)与 Fizz 的差异:Flight 总是 scheduleMicrotask 起第一轮(requestStorage.run(request, performWork, request) 用 AsyncLocalStorage 传播 request 上下文,Node 上 resolveRequest() 才拿得到),并额外 scheduleWork 把状态从 OPENING 推到 OPEN。
12.7.3 引用与去重:同一个东西只传一次
Flight 协议的精髓在「引用」。服务端有一串序列化原语(ReactFlightServer.js:3070 起):
js
function serializeByValueID(id: number): string {
return '$' + id.toString(16);
}
function serializeLazyID(id: number): string {
return '$L' + id.toString(16);
}
function serializePromiseID(id: number): string {
return '$@' + id.toString(16);
}
function serializeWeakPromiseID(id: number): string {
return '$w' + id.toString(16);
}
function serializeServerReferenceID(id: number): string {
return '$h' + id.toString(16);
}
function serializeSymbolReference(name: string): string {
return '$S' + name;
}
function serializeTemporaryReference(request: Request, reference: string): string {
return '$T' + reference;
}
function serializeDate(date: Date): string {
return '$D' + date.toJSON();
}
function serializeBigInt(n: bigint): string {
return '$n' + n.toString(10);
}
function serializeNumber(number: number): string | number {
if (Number.isFinite(number)) {
if (number === 0 && 1 / number === -Infinity) { return '$-0'; }
return number;
} else if (number === Infinity) { return '$Infinity'; }
else if (number === -Infinity) { return '$-Infinity'; }
else { return '$NaN'; }
}
function serializeUndefined(): string {
return '$undefined';
}
function serializeMap(request: Request, map: Map<ReactClientValue, ReactClientValue>): string {
const entries = Array.from(map);
const id = outlineModel(request, entries);
return '$Q' + id.toString(16);
}
前缀语义对照表:
| 前缀 | 含义 | 客户端还原 |
|---|---|---|
$ |
by-value 引用:id 处有一个模型行 | getChunk(id) 等它 |
$L |
lazy 引用:包成 React.lazy,可挂起 |
createLazyChunkWrapper |
$@ |
Promise 引用 | chunk 本身(ReactPromise) |
$w |
弱 Promise:流关了也不报错,永远 pending | getWeakChunk |
$h |
Server Reference 引用 | getOutlinedModel(loadServerReference) |
$S<name> |
全局 symbol | Symbol.for(name) |
$T<ref> |
临时引用(temporary reference) | readTemporaryReference |
$D/$n/$Infinity/$NaN/$undefined/$-0 |
Date/BigInt/Infinity/NaN/undefined/-0 | 对应构造 |
$Q/$W/$B/$K |
Map/Set/Blob/FormData 的 outlined 模型 | createMap/createSet/createBlob/createFormData |
$$x |
转义:字符串 $x |
value.slice(1) |
去重的三个层次:
- 对象级 :
renderModelDestructive开头的writtenObjects.get(value)。命中且不是modelRoot→ 直接返回已有引用。更妙的是「父引用 + 属性名」的间接去重(:4067):
js
} else if (parentPropertyName.indexOf(':') === -1) {
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
const parentReference = writtenObjects.get(parent);
if (parentReference !== undefined) {
// If the parent has a reference, we can refer to this object indirectly
// through the property name inside that parent.
let propertyName = parentPropertyName;
if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) {
// For elements, we've converted it to an array but we'll have converted
// it back to an element before we read the references so the property
// needs to be aliased.
switch (parentPropertyName) {
case '1': propertyName = 'type'; break;
case '2': propertyName = 'key'; break;
case '3': propertyName = 'props'; break;
case '4': propertyName = '_owner'; break;
}
}
writtenObjects.set(value, parentReference + ':' + propertyName);
}
}
元素在协议里是数组 [REACT_ELEMENT_TYPE, type, key, props],索引 1/2/3/4 就是 type/key/props/_owner。所以「同一个组件类型被 100 个元素引用」只需要传一次 moduleId#export,其余全是 $1:type 这种短串。注意元素属性名带冒号会跳过去重(防止 $id:prop 串二义)。
- 客户端引用级 :
serializeClientReference(:3164):
js
const clientReferenceKey: ClientReferenceKey =
getClientReferenceKey(clientReference);
const writtenClientReferences = request.writtenClientReferences;
const existingId = writtenClientReferences.get(clientReferenceKey);
if (existingId !== undefined) {
if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') {
// If we're encoding the "type" of an element, we can refer
// to that by a lazy reference instead of directly since React
// knows how to deal with lazy values. This lets us suspend
// on this component rather than its parent until the code has
// loaded.
return serializeLazyID(existingId);
}
return serializeByValueID(existingId);
}
try {
const clientReferenceMetadata: ClientReferenceMetadata =
resolveClientReferenceMetadata(request.bundlerConfig, clientReference);
const json = stringifyImportMetadata(request, clientReferenceMetadata, false);
request.pendingChunks++;
const importId = request.nextChunkId++;
emitImportChunk(request, importId, json, false);
writtenClientReferences.set(clientReferenceKey, importId);
if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') {
return serializeLazyID(importId);
}
return serializeByValueID(importId);
} catch (x) {
// 解析 manifest 失败:发一个 error chunk,引用指向它
}
同一个 server reference / client reference 在整个 request 里只发一次 ,之后全部是 $id。注意 parentPropertyName === '1'(元素 type 槽位)时的特判:用 $L(lazy)而非 $(by-value)。语义差别很大------lazy 让「加载这个组件代码」变成可挂起点,React 会在该组件位置挂起等待 chunk,而不是让父级阻塞。这直接改善了「客户端组件代码未下载完」时的呈现策略。
- 行大小限制与 outline/defer :
MAX_ROW_SIZE = 3200(:2256)。renderModelDestructive里元素或 lazy 超限时deferTask(:2268):
js
function deferTask(request: Request, task: Task): ReactJSONValue {
// Like outlineTask but instead the item is scheduled to be serialized
// after its parent in the stream.
const newTask = createTask(
request, task.model, task.keyPath, task.implicitSlot, task.formatContext,
request.abortableTasks, /* time */ ..., /* debug */ ...
);
pingTask(request, newTask);
return serializeLazyID(newTask.id);
}
即「本行放不下了,拆成新 task 稍后发,本行只留一个 $L 引用」。这就是 Flight 输出不会出现「一行几个 MB」的原因:任何行都有 3200 字符软上限,超过就拆。outlineTask(:2291)与之类似但同步重试(用于「需要一个 id 来挂调试信息」的场景):如果同步完成了就返回 $id(by-value),否则 $Lid。serializeLargeTextString(:3376)对 ≥1024 字符的字符串同样外置。
bundler 元数据里的长 URL 还有一层专门去重:MIN_DEDUPLICATED_IMPORT_STRING_LENGTH = 16、MAX_DEDUPLICATED_IMPORT_STRINGS_SIZE = 32768(:2262/:2266)------同一路由里每个客户端引用都带相同的 chunk URL,这些重复字符串被 outline 成共享 id。
12.7.4 客户端:从字节流到 React 元素
Response 与 chunk 状态机
createResponse(ReactFlightClient.js:3106)创建 ResponseInstance(:2995),核心状态是 _chunks: Map<number, SomeChunk>。每个 chunk 有状态机:PENDING → RESOLVED_MODEL/RESOLVED_MODULE → INITIALIZED(或 BLOCKED/ERRORED/HALTED/PENDING_WEAK)。getChunk(response, id)(:1661)按 id 取,没有就建 pending chunk 并登记「等这个 id」------先引用后到达是常态,因为服务端乱序发射。
行解析:一个字节级状态机
processBinaryChunk(:5319)与 processStringChunk(:5484)是同一状态机的二进制/字符串两个版本:
js
export type StreamState = {
_rowState: RowParserState,
_rowID: number, // parts of a row ID parsed so far
_rowTag: number, // 0 indicates that we're currently parsing the row ID
_rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline.
_buffer: Array<Uint8Array>, // chunks received so far as part of this row
// ...
};
状态流转(processBinaryChunk 内):
ROW_ID:逐字节rowID = (rowID << 4) | (byte > 96 ? byte - 87 : byte - 48),遇:进ROW_TAG;ROW_TAG:读一个字节。若是T/A/O/o/b/U/S/s/L/l/G/g/M/m/V→ROW_LENGTH(按长度读);若是A-Z、#、r、x→ROW_CHUNK_BY_NEWLINE;其他 → tag 记为 0,也按 newline 读(「unknown tag so it was probably part of the data」------即无 tag 的 JSON 行);ROW_LENGTH:同样 hex 累积,遇,进ROW_CHUNK_BY_LENGTH;ROW_CHUNK_BY_NEWLINE/ROW_CHUNK_BY_LENGTH:找行尾(indexOf(10)或i + rowLength)。找到 →processFullBinaryRow;找不到 → 剩余字节进_buffer,等下一个网络 chunk。
这里有个重要细节:ROW_LENGTH 的长度是字节 长度,而 processStringChunk 里对 T 行的校验 if (rowLength < chunk.length || chunk.length > rowLength * 3) 说明了字符串 chunk 的约束------Flight Server 给的字符串 chunk 必须原样传递,不能拆(UTF-8 一个字符 1~3 字节,所以 chunk.char 数必须落在 [len/3, len])。这是「字符串流与二进制流不能混用同一协议层」的根源,processStringChunk 开头的注释把这一点说得很透。
processFullStringRow:tag 分派
processFullStringRow(:5216)把完整行按 tag 分派到各 resolveXxx(表见 12.7.1)。默认分支(:5307)是 resolveModel------即「无 tag 的 JSON 行」。resolveModel → resolveModelChunk(:952)→ initializeModelChunk(:1136)用 parseModelString/parseModelTuple 把 JSON 解析成「带引用的对象图」。
parseModelString:$ 前缀的逆运算
parseModelString(:2656)是协议的另一半。摘录关键分支:
js
function parseModelString(
response: Response,
parentObject: Object,
key: string,
value: string,
): any {
if (value[0] === '$') {
if (value === '$') {
// A very common symbol.
if (initializingHandler !== null && key === '0') {
// ...元素开始:push 一个新的 initializingHandler
}
return REACT_ELEMENT_TYPE;
}
switch (value[1]) {
case '$': { return value.slice(1); } // 转义字符串
case 'L': { // Lazy 节点
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return createLazyChunkWrapper(chunk, 0);
}
case '@': { /* Promise */ const chunk = getChunk(response, id); return chunk; }
case 'w': { /* Weak Promise */ return getWeakChunk(response, id); }
case 'S': { return Symbol.for(value.slice(2)); }
case 'h': { // Server Reference
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, loadServerReference);
}
case 'T': { /* Temporary Reference */ ... }
case 'Q': { return getOutlinedModel(response, ref, parentObject, key, createMap); }
case 'W': { /* Set */ }
case 'B': { /* Blob */ }
case 'K': { /* FormData */ }
case 'Z': { /* Error */ }
case 'i': { /* Iterator */ }
case 'I': { return Infinity; }
// ...
parseModelTuple(:2962)处理元素数组:[REACT_ELEMENT_TYPE, type, key, props](DEV 多两个槽)。数组第 0 位是 $(还原成 REACT_ELEMENT_TYPE),第 1/2/3/4 位分别可能带 $ 引用。
createElement:把「阻塞」变成 lazy
客户端重建元素的关键在 createElement(:1484):
js
if (initializingHandler !== null) {
const handler = initializingHandler;
// We pop the stack to the previous outer handler before leaving the Element.
initializingHandler = handler.parent;
if (handler.errored) {
// Something errored inside this Element's props. We can turn this Element
// into a Lazy so that we can still render up until that Lazy is rendered.
const erroredChunk: ErroredChunk<...> = createErrorChunk(response, handler.reason);
// ...
return createLazyChunkWrapper(erroredChunk, validated);
}
if (handler.deps > 0) {
// We have blocked references inside this Element but we can turn this into
// a Lazy node referencing this Element to let everything around it proceed.
const blockedChunk: BlockedChunk<...> = createBlockedChunk(response);
handler.value = element;
handler.chunk = blockedChunk;
const lazyNode = createLazyChunkWrapper(blockedChunk, validated);
// ...
return lazyNode;
}
}
// ...
return element;
即:如果元素的 props 里还有没到达的引用(handler.deps > 0),这个元素本身先变成 React.lazy ,等所有依赖 chunk 到达再解包。这样「引用先于内容到达」导致的阻塞不会传染------外围结构可以立刻渲染,只有真正需要该元素的位置挂起。fulfillReference(:1754)负责在依赖 chunk 到达时沿着 path(['3', 'children', '0'] 这种)把值填回 parentObject[key],并在 parentObject[0] === REACT_ELEMENT_TYPE 时同步更新已创建元素的 props/_owner/_debugStack。循环引用(A 的 props 引用 B、B 又引用 A)由 resolveBlockedCycle(:696)处理。
resolveModule:客户端组件的代码加载
resolveModule(:3386)→ initializeModuleChunk(:1276)→ requireModule(chunk.value)------chunk.value 是 manifest 里的模块标识(webpack chunk id + export),由 bundler 提供的 moduleLoading 回调加载。加载完的「模块命名导出」就是真正的客户端组件函数,替换掉序列化时的引用占位。loadServerReference(:2066)是对称逻辑:如果客户端有 server manifest(SSR 场景),直接 requireModule 真函数;否则(纯 CSR)返回 createBoundServerReference------一个调用时 POST 到服务端的代理。
12.7.5 Flight 协议格式全景图
一次最小往返的字节级示例(生产模式,假设 id 分配顺序为 import=1、root=2、action=3):
ruby
1:I{"chunk":"main","name":"default","async":false}
2:["$","$L1",null,{"children":"hello"}]
3:h3
:HP{"href":"/main.js","as":"script"}
逐行解释:1:I{...} 是客户端模块引用(webpack chunk main 的 default 导出);2:[...] 是根模型------一个元素,type 是 $L1(lazy 引用 id 1,即「等 main.js 加载好」),key 是 null,props 是 {"children":"hello"};3:h3 是一个 server action 引用($h3 指向 id 3 行,而 id 3 的内容在别处给出,通常是 {id, bound} 元数据);:HP{...} 是资源提示------preload main.js。注意没有 id 的 :H 行直接以 : 开头。
12.7.6 序列化限制:什么不能过界
renderModelDestructive 的拒绝路径就是 RSC 的「序列化契约」。按源码逐个列出(均在 ReactFlightServer.js):
- 函数(:4292):
js
if (typeof value === 'function') {
if (isClientReference(value)) { return serializeClientReference(...); }
if (isServerReference(value)) { return serializeServerReference(...); }
if (request.temporaryReferences !== undefined) { /* $T */ }
if (enableTaint) { /* 被 taint 的函数直接抛泄漏错误 */ }
if (isOpaqueTemporaryReference(value)) {
throw new Error('Could not reference an opaque temporary reference. ...');
} else if (/^on[A-Z]/.test(parentPropertyName)) {
throw new Error(
'Event handlers cannot be passed to Client Component props.' +
describeObjectForErrorMessage(parent, parentPropertyName) +
'\nIf you need interactivity, consider converting part of this to a Client Component.',
);
} else if (__DEV__ && (jsxChildrenParents.has(parent) || ...)) {
// Functions are not valid as a child of Client Components...
throw new Error(
'Functions are not valid as a child of Client Components. This may happen if ' +
'you return ' + componentName + ' instead of <' + componentName + ' /> from render. ...'
);
} else {
throw new Error(
'Functions cannot be passed directly to Client Components ' +
'unless you explicitly expose it by marking it with "use server". ' +
'Or maybe you meant to call this function rather than return it.' +
describeObjectForErrorMessage(parent, parentPropertyName),
);
}
}
三条错误信息对应三种常见误用:把 onClick={fn} 从服务器组件传下去、把组件函数当 children 传、传普通函数。唯一合法通道是 'use server'(Server Action)与 client reference。
- Symbol (:4359):只接受
Symbol.for(name)(全局符号注册表里的),否则抛「Only global symbols received from Symbol.for(...) can be passed」。因为 symbol 身份无法跨环境传输。 - 类实例 / 非简单对象(:4204):
js
// Verify that this is a simple plain object.
const proto = getPrototypeOf(value);
if (
proto !== ObjectPrototype &&
(proto === null || getPrototypeOf(proto) !== null)
) {
throw new Error(
'Only plain objects, and a few built-ins, can be passed to Client Components ' +
'from Server Components. Classes or null prototypes are not supported.' +
describeObjectForErrorMessage(parent, parentPropertyName),
);
}
DEV 下还会检查 isSimpleObject(有方法的抛「Classes or other objects with methods are not supported」)与 symbol 属性(:4234)。方法不能序列化------因为方法意味着行为,行为属于代码,代码不过界。
- ref :
renderElement开头(:2346)直接抛「Refs cannot be used in Server Components, nor passed to Client Components.」------React 19 把 ref 变成普通 prop 后,这个限制依然存在于 RSC 边界(服务器没有实例可 ref)。 - taint :
enableTaint开启时experimental_taintObjectReference/taintUniqueValue注册过的值/对象/字符串/二进制,序列化时抛「throwTaintViolation」(:659)------这是 React 内建的「敏感数据防泄漏」机制,TaintRegistryObjects/TaintRegistryValues/TaintRegistryByteLengths三张表在写入前检查。
反过来,支持 的类型清单(从 renderModelDestructive 的分支读出):null、布尔、有限数字、字符串、undefined、bigint、Date、Symbol.for symbol、普通对象(plain object)、数组、Map、Set、FormData、Error(digest + DEV 堆栈)、全部 TypedArray/DataView/ArrayBuffer、Blob、可迭代对象(转数组)、ReadableStream(R/r 行)、AsyncIterable(X/x 行)、Promise/thenable($@)、React 元素、lazy、client/server reference、temporary reference。
12.7.7 Server Action:use server 的完整回路
'use server' 文件里的导出被 bundler 包成 registerServerReference(fn, moduleId, exportName)。服务器组件把它当普通 prop 传给客户端组件时,serializeServerReference(:3306)产出 $h<id>,id 行内容是 {id, bound, name, env, location}(DEV 才有后三个)。客户端 loadServerReference(:2066)在 CSR 场景返回 createBoundServerReference(metaData, callServer, encodeFormAction, ...)------调用它即 callServer(id, args),由框架把参数用 encodeFormAction 编码后 POST 到服务端。服务端 ReactFlightDOMServerNode.js 的 decodeReply(:675)/decodeReplyFromBusboy(:540)/decodeAction 把请求体还原成真实参数,调用真函数,返回值再走一遍 Flight 序列化回客户端。decodeReply 复用 Flight 客户端 的解析器(createResponse + resolveField,来自 ReactFlightReplyServer.js)------同一个协议同时承载「下行渲染」与「上行调用」,这是设计上很优雅的一点。React 19 的 <form action={serverAction}> 与 useActionState/useFormStatus 都建立在这条回路上(ReactFlightActionServer.js 的 decodeAction/decodeFormState)。
12.8 RSC + SSR:两条流的合流
12.8.1 数据流全景
单独的 RSC 或单独的 SSR 都不构成 React 19 的完整形态。真实架构里两者串起来:
需要澄清一个常见误解:React 仓库本身不提供「RSC 流 → SSR」的粘合层 。packages/react-server-dom-webpack/src/server/ 只负责产出 Flight 流;packages/react-dom/src/server/ 只负责把 React 元素渲染成 HTML。把 Flight 流在同一进程里「同时」解析成元素树(喂给 renderToPipeableStream)并转发给浏览器(喂给客户端 createFromNodeStream)的代码在框架层(Next.js 的 renderToReadableStream + createServerReference 管线、React Router 的 RSCStaticHandler 等)。React 侧为此提供的「钩子」是:
- 服务端
ReactFlightDOMServerNode.js的renderToPipeableStream/renderToReadableStream(Flight 流出口); - 客户端
ReactFlightDOMClientNode.js的createFromNodeStream、ReactFlightDOMClientBrowser.js的createFromReadableStream/createFromFetch; - SSR 侧的
ReactFlightClientConfigDOM.js的preinitModuleForSSR/preinitScriptForSSR(:113/:127)------框架在 SSR 期间调它们把 RSC 流里出现的客户端 chunk 提前preinit进 HTML<head>。这就是「RSC 让 SSR 知道该预加载哪些 JS」的通道。
12.8.2 Flight 流里的 Suspense 语义
Flight 没有 Fizz 那样的「边界 + fallback」。RSC 的 Suspense 语义分两层:
- 服务器组件里的
<Suspense>:在 Flight 序列化时没有特殊处理 (renderElement只把它当普通元素类型序列化,因为Suspense的$$typeof是REACT_SUSPENSE_TYPE而不是函数)。它的 fallback/内容都会被完整序列化。真正「分段」发生在这些元素到达 Fizz 之后------即 SSR 阶段。 use(promise)挂起 :serializeThenable(ReactFlightServer.js:1109)把 promise 变成一个 chunk,父级引用是$@id。如果 promise 在渲染结束时还没 resolve,request 不会结束(pendingChunks不归零),客户端拿到$@id后同样可以use它并挂起。配合enableFlightWeakThenables,$w前缀表示「允许永远不 resolve」的弱 promise(流关闭时保持 pending 而不是报错,见ReactFlightClient.js:2716与reportGlobalError的PENDING_WEAK分支)。
12.8.3 prerender / resume:React 19 的静态化新机制
React 19 在 react-dom/server 引入了四个 prerender 相关 API(ReactDOMFizzStaticNode.js):
js
export {
prerender,
prerenderToNodeStream,
resumeAndPrerender,
resumeAndPrerenderToNodeStream,
ReactVersion as version,
};
prerender(:173)与普通流式渲染的差异全在 createPrerenderRequest(ReactFizzServer.js:670):
js
export function createPrerenderRequest(...): Request {
const request = createRequest(...);
// Start tracking postponed holes during this render.
request.trackedPostpones = {
workingMap: new Map(),
rootNodes: [],
rootSlots: null,
};
return request;
}
trackedPostpones 打开「空洞记录」: prerender 时如果内容还没好,Fizz 不会 傻等,而是把「本该在这里的内容」记成一个 ReplayNode 空洞(KeyNode → ReplayNode 的 Map),继续输出其余部分。产物 {postponed, prelude} 里 postponed = getPostponedState(request)(ReactFizzServer.js:6651)携带:
resumableState:预渲染阶段已经发过哪些资源(styleResources/scriptResources等),resume 时避免重复;nextSegmentId:接续的段 id 起点;replayNodes/replaySlots:空洞的结构指纹;rootFormatContext/progressiveChunkSize。
resume(ReactDOMFizzStaticNode.js:302)→ resumeAndPrerenderRequest(ReactFizzServer.js:802)→ resumeRequest(:706):
js
request.nextSegmentId = postponedState.nextSegmentId;
if (typeof postponedState.replaySlots === 'number') {
// We have a resume slot at the very root. This is effectively just a full rerender.
// ... 创建普通 root task
return request;
}
const replay: ReplaySet = {
nodes: postponedState.replayNodes,
slots: postponedState.replaySlots,
pendingTasks: 0,
};
const rootTask = createReplayTask(
request, null, replay, children, -1, null, null, request.abortableTasks, ...
);
request.pingedTasks.push(rootTask);
return request;
ReplayTask(blockedSegment === null)遍历时不写任何字节 ,只做一件事:按 replayNodes 记录的「键路径 → 结构」核对当前渲染出的树是否与预渲染时一致。renderSuspenseBoundary 的 replay 分支(replaySuspenseBoundary,ReactFizzServer.js:1797):
js
resumedBoundary.parentFlushed = true;
// We restore the same id of this boundary as was used during prerender.
resumedBoundary.rootSegmentID = id;
// ...
task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
try {
// We use the safe form because we don't handle suspending here. Only error handling.
renderNode(request, task, content, -1);
if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) {
throw new Error(
"Couldn't find all resumable slots by key/index during replaying. " +
"The tree doesn't match so React will fallback to client rendering.",
);
}
task.replay.pendingTasks--;
if (resumedBoundary.pendingTasks === 0 && resumedBoundary.status === PENDING) {
resumedBoundary.status = COMPLETED;
request.completedBoundaries.push(resumedBoundary);
return;
}
} catch (thrownValue: mixed) {
resumedBoundary.status = CLIENT_RENDERED;
// ...
// The parent already flushed in the prerender so we need to schedule this to be emitted.
request.clientRenderedBoundaries.push(resumedBoundary);
// ...
}
核对逻辑:每匹配上一个 replay slot 就 pendingTasks--;渲染完还有剩余 slot 没匹配上 → 抛「The tree doesn't match so React will fallback to client rendering」。这是 React 对「预渲染产物与运行时数据必须结构一致」的形式化保证 :键路径(keyPath)+ 槽位索引对不上,宁可客户端重渲也不发错内容。匹配上的边界复用 prerender 时的 rootSegmentID,completedBoundaries 里输出的 $RC 指令与 prelude 里的 B: id 严格对齐------flushCompletedQueues 的 finally 块里对 postponedState.nextSegmentId 的 re-sync(ReactFizzServer.js:6334)就是为了保证 resume 阶段分配的新 id 不与 prelude 撞车。
resumeAndPrerender 是「resume + 继续 track postpones」的组合(resumeAndPrerenderRequest 再设一次 trackedPostpones),支持多级静态化:prerender → resume 发现还有空洞 → 再 prerender 一次,直到没有 postponed 或显式停止。
与第 13 章(React 19 新特性)的呼应:这套机制让「构建期渲染 + CDN 缓存 + 请求期补齐」成为可能,postponed 状态可以随 HTML 一起缓存,用户请求到达时只渲染真正动态的部分。
12.8.4 19.3.1 现状核对
以源码为准,明确标注几件事:
prerender/resume/resumeAndPrerender在 19.3.1 确实存在 于react-dom/server(Node 与 Browser 入口;Edge 入口ReactDOMFizzStaticEdge.js只导出prerender/resumeAndPrerender的 Web 版)。Bun 入口(ReactDOMFizzServerBun.js)只提供renderToReadableStream等,无 prerender。renderToString/renderToStaticMarkup不在react-dom/server,只在react-dom/server-legacy(源码注释说「We're going to delete it soon」,ReactDOMLegacyServerImpl.js:92)。resumeModel这个名字在 19.3.1 源码中不存在 ;对应能力是 Fizz 的resumeRequest/resume与 Flight 的createPrerenderRequest/prerenderToNodeStream(ReactFlightDOMServerNode.js:438,返回{prelude})。若社区文章提到resumeModel,指的是 Flight prerender 的 resume 语义,19.3.1 里由上述 API 承担。use cache指令、Partial Prerendering(PPR)的完整形态属于框架层能力;React 侧只提供cache/cacheSignal(ReactServer.js导出,packages/react/src/ReactCacheServer.js)与上述 postpone/resume 原语。
12.9 资源预加载:preinit / preload / preconnect
12.9.1 统一的 dispatcher 分发
ReactDOM.preload 等函数定义在 packages/react-dom/src/shared/ReactDOMFloat.js,全部走 ReactDOMSharedInternals.d(ReactDOMCurrentDispatcher)------一个由当前渲染环境填充的 dispatcher 对象:
js
export function preload(href: string, options: PreloadOptions) {
// DEV 参数校验......
if (
typeof href === 'string' &&
typeof options === 'object' &&
options !== null &&
typeof options.as === 'string'
) {
const as = options.as;
const crossOrigin = getCrossOriginStringAs(as, options.crossOrigin);
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */
.L(/* preload */ href, as, {
crossOrigin,
integrity: ...,
nonce: ...,
type: ...,
fetchPriority: ...,
referrerPolicy: ...,
imageSrcSet: ...,
imageSizes: ...,
media: ...,
});
}
// We don't error because preload 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.
}
dispatcher 方法名(单字母)一览:D=prefetchDNS、C=preconnect、L=preload、m=preloadModule、X=preinitScript、S=preinitStyle、M=preinitModuleScript。三个环境各填各的:
- 客户端 DOM :
ReactFiberConfigDOM.js的preloadInstance/preloadResource(:6575/:6588)等,直接往 document head 插<link>。 - Fizz SSR :
ReactFizzConfigDOM.js:106处替换 dispatcher:
js
const previousDispatcher = ReactDOMSharedInternals.d; /* ReactDOMCurrentDispatcher */
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */ = {
f /* flushSyncWork */: previousDispatcher.f,
r /* requestFormReset */: previousDispatcher.r,
D /* prefetchDNS */: prefetchDNS,
C /* preconnect */: preconnect,
L /* preload */: preload,
m /* preloadModule */: preloadModule,
X /* preinitScript */: preinitScript,
S /* preinitStyle */: preinitStyle,
M /* preinitModuleScript */: preinitModuleScript,
};
- RSC(Flight 服务器) :
ReactDOMFlightServerHostDispatcher.js(:82/:119/:146/:180/:204)的preload/preloadModule/preinitStyle/preinitScript/preinitModuleScript,内部emitHintChunk------把资源提示编成:H行随 Flight 流下发,客户端resolveHint(ReactFlightClient.js:3963)再交给ReactFlightClientConfigDOM.js的 dispatcher,最终在 SSR 或 CSR 落地。这就是「服务器组件里调 preload 能预加载客户端资源」的完整链路:Flight 流既是组件树也是资源清单。
12.9.2 Fizz 侧的 preinit:去重 + 状态机
preinitStyle(ReactFizzConfigDOM.js:6672)展示了 SSR 侧资源管理的两个核心机制:
js
function preinitStyle(
href: string,
precedence: ?string,
options?: ?PreinitStyleOptions,
): void {
const request = resolveRequest();
if (!request) {
// In async contexts we can sometimes resolve resources from AsyncLocalStorage. If we can't we can also
// possibly get them from the stack if we are not in an async context. Since we were not able to resolve
// the resources for this call in either case we opt to do nothing. ...
previousDispatcher.S(/* preinitStyle */ href, precedence, options);
return;
}
const resumableState = getResumableState(request);
const renderState = getRenderState(request);
if (href) {
precedence = precedence || 'default';
const key = getResourceKey(href);
let styleQueue = renderState.styles.get(precedence);
const hasKey = resumableState.styleResources.hasOwnProperty(key);
const resourceState = hasKey
? resumableState.styleResources[key]
: undefined;
if (resourceState !== EXISTS) {
// We are going to create this resource now so it is marked as Exists
resumableState.styleResources[key] = EXISTS;
// ...创建 StyleQueue、resource(state: PENDING)......
if (resourceState) {
// When resourceState is truty it's a Preload state. We cast it for clarity
const preloadState: Preloaded | PreloadedWithCredentials = resourceState;
if (preloadState.length === 2) {
adoptPreloadCredentials(resource.props, preloadState);
}
const preloadResource = renderState.preloads.stylesheets.get(key);
if (preloadResource && preloadResource.length > 0) {
// The Preload for this resource was created in this render pass and has not flushed yet so we
// need to clear it to avoid it flushing.
preloadResource.length = 0;
} else {
// Either the preload resource from this render already flushed in this render pass
// or the preload flushed in a prior pass (prerender). In either case we need to mark
// this resource as already having been preloaded.
resource.state = PRELOADED;
}
}
// ...
styleQueue.sheets.set(key, resource);
// Notify the request that there are resources to flush even if no work is currently happening
flushResources(request);
}
}
}
机制拆解:
resolveRequest()找当前渲染 :依赖 AsyncLocalStorage(supportsRequestStorage时)或调用栈。找不到就静默走 previousDispatcher(客户端)或啥也不做------这就是注释说的「optimistic and not critical,favor silent bailout」。- 跨 pass 去重靠
resumableState:styleResources[key] = EXISTS是「本 request(含 prerender→resume 的整个生命周期)已创建过」。所以 prerender 里 preload 过的样式,resume 时不会重复发。这是resumableState与renderState的分工:前者跨 pass 持久(序列化进 postponed state),后者单次渲染内有效(stylesMap、preloadsMap)。 - preload 与 preinit 的协同 :如果先
preload后preinit,且 preload 的<link>还没 flush,就清零它(避免重复标签);如果已经 flush 过(或来自上一次 prerender),resource 直接标PRELOADED,preinit 只发样式内容不发 preload 链接。 precedence(React 19 的样式优先级) :同 precedence 的样式进同一个StyleQueue,按data-precedence控制层叠与去重顺序;这解决了一个经典 SSR 难题------「样式的插入顺序必须与客户端一致,否则 FOUC」。flushResources(request):资源可以在「没有任何 pending task」时到达(例如组件渲染完才异步调 preload),主动触发一次 flush 检查。
preinitScript(:6761)结构相同但更简单:scriptResources[key] = EXISTS 后 pushScriptImpl 把 <script async src> 推进 renderState.scripts。preinitModuleScript(:6821)走 moduleScriptResources。
12.9.3 hoistable:资源为什么能「提升」到 head
renderState.hoistableChunks、writeHoistables、writeHoistablesForBoundary(ReactFizzConfigDOM.js:5400)与边界的 contentState/fallbackState(HoistableState)共同实现「在 Suspense 边界里渲染的 <title>/<link>/<style> 出现在正确位置」:
- 渲染过程中遇到的 hoistable 资源不直接写进当前 segment,而是记进当前边界的
contentState(或 fallback 的fallbackState); - 边界完成 flush 时,
writeHoistablesForBoundary(destination, boundary.contentState, renderState)把它的资源提升到边界指令之前发出; - 如果边界被 outlining(先发 fallback 后补内容),fallback 的资源提升到 shell 里(fallback 先可见,它的样式要先在),内容的资源随
$RC指令一起(writeCompletedBoundaryInstruction里writeStyleResourceDependenciesInJS/Attr,:5114/:5117)。
hasSuspenseyContent(boundary.contentState, flushingInShell)(isEligibleForOutlining 的调用点)判断的正是「这个边界里有没有 script/link 之类可疑内容」------有的话倾向于 outlining,因为内联它会让「阻塞渲染的资源」混进流中间。
12.10 边界与坑
12.10.1 hydration mismatch:成因、恢复与调试
mismatch 的错误信息本身(ReactFiberHydrationContext.js:399)就是一份检查清单。按出现频率排:
typeof window !== 'undefined'分支 :服务端走一支、客户端走另一支,结构必然不同。正确做法是把「只关心一端」的部分各自包进'use client'/ 服务器组件,或用useEffect推迟到水合后再改。- 非确定性值 :
Date.now()、Math.random()、crypto.randomUUID()。服务端渲染一次、客户端水合一次,值不同。解决:值从服务端传下来(props),或放进useEffect。 - locale/时区格式 :
toLocaleDateString()在服务器(UTC)与浏览器(本地时区)输出不同。React 的 DEV 警告专门点了这一条。 - 外部数据无快照:首屏 HTML 用了 A 数据,水合时 store 已是 B 数据。
- 非法 HTML 嵌套 :
<div>里嵌<p>再嵌<div>,浏览器解析器会「纠正」DOM,React 拿到的是修正后的结构。注意这与 React 无关------浏览器在脚本执行前就重排了。validateDOMNesting(react-dom-bindings/src/client/validateDOMNesting.js)在 DEV 下提前警告。 - 浏览器扩展 :扩展往
<head>插节点、改属性。React 的 mismatch 信息也提到这条(「a browser extension installed which messes with the HTML before React loaded」)。
恢复行为(见 12.5.3):就近 Suspense 边界 ForceClientRender 重渲;根级 mismatch 整树同步客户端重渲。DEV 下的 describeDiff(ReactFiberHydrationDiffs.js)会给出 server/client 的属性级 diff,这是排查利器;生产只有 digest。
反模式警示 :不要用 suppressHydrationWarning 掩盖真正的数据不一致(它只该用于「有意的、无意义的差异」如 timestamp);不要为了消 mismatch 把整个页面包在一个巨大 Suspense 里------那会让水合退化成整树客户端渲染,丢失选择性水合的好处。
12.10.2 SSR 中不能用的 API
| API | 原因 | 源码位置 |
|---|---|---|
window/document/localStorage/sessionStorage |
服务器没有这些全局;代码里直接引用会在渲染时 ReferenceError | ------ |
useState/useReducer/useRef |
Flight dispatcher unsupportedHook 抛错 |
ReactFlightHooks.js:106 |
useEffect/useLayoutEffect/useInsertionEffect |
同上(Fizz 里是 noop,Flight 里直接抛) | ReactFlightHooks.js:74-77 |
useContext/readContext |
unsupportedContext:客户端 context 无法在服务器读取 |
ReactFlightHooks.js:116 |
createContext |
react-server 条件导出里没有 |
packages/react/src/ReactServer.js |
事件处理器 props(onClick 等) |
序列化抛错 | ReactFlightServer.js:4327 |
ref |
renderElement 开头抛错 |
ReactFlightServer.js:2346 |
| 类实例 / 带方法对象 | 非 plain object 抛错 | ReactFlightServer.js:4210 |
Date 以外的大多数内置类(Map/Set/FormData/Blob/Error/TypedArray 除外) |
白名单序列化 | ReactFlightServer.js:4101 起 |
| 非全局 symbol | Symbol.for 白名单 |
ReactFlightServer.js:4368 |
cache() 的刷新 |
unsupportedRefresh |
ReactFizzHooks.js:860(Flight 侧在 ReactFlightHooks.js:101 引用) |
在 Fizz(非 RSC 的 SSR)里规则松一些:useState 可用(服务器上初始值渲染,水合后由客户端接管),useEffect 被 noop 掉------ReactFizzHooks.js:884 的 dispatcher 在 supportsClientAPIs 为 true 时给了完整实现。这解释了一个常见困惑:「为什么 SSR 里 useState 不报错,RSC 里报错」------因为 Fizz 的组件仍会被水合,有第二次渲染;RSC 组件不会。
12.10.3 流式 SSR 的经典陷阱
- 根级没有 Suspense :
flushCompletedQueues在pendingRootTasks > 0时什么都不发(ReactFizzServer.js:6186)。整个页面等最慢的数据 → 流式退化成同步 SSR。最佳实践:<Suspense>包住「header 以下」的所有内容,根 fallback 保持轻量。 - shell 超过 512KB :
getBlockingRenderMaxSize触发skipBlockingShell,React 会放弃「shell 完整才发」的策略,允许不完整也先画(ReactFizzServer.js:6207)。这通常意味着页面缺边界。 - fallback 里做重工作:fallback 是低优先级 task,但如果 fallback 自身同步渲染很重,照样拖慢 shell(它在 shell 之前被同步渲染------prerender/defer 分支)或占满工作循环(正常分支)。
- 在渲染中写全局可变状态 :Fizz 的 task 可能重试(
retryTask),组件会被调用多次。组件必须幂等------这也是trackUsedThenable复用旧 thenable 的前提假设。 onError返回值的双刃剑 :onError返回字符串会作为 digest 发给客户端(生产环境用它避免泄漏堆栈);返回 null/undefined 用默认消息。忘记提供onError时用defaultErrorHandler(ReactFizzServer.js:529),它会console.error并尝试绑定环境名徽标。- 流中断 :客户端断开 →
createCancelHandler(Node)或cancel(Web)→stopFlowing+abort。已渲染未发出的内容被丢弃;onBrowserBailout(ReactFizzServer.js:1418)专门上报「浏览器 bailout」事件。 - HTTP 压缩中间件与 flush :Node 的
flushBuffered会调destination.flush()------zlib 流有这个方法,但某些自定义 Writable 没有,不致命(有typeof保护)。
12.10.4 RSC 的坑
- 把服务器组件当普通组件 import 进客户端 :
'use client'文件里 import 一个没带指令的模块------如果那个模块是「纯 UI」,bundler 会把它打进客户端 bundle(它变成了客户端模块);如果它 import 了fs,直接构建失败。边界的传递性(12.6.5)意味着「干净」很重要。 - props 传函数 :除了
'use server'的 action,函数一律抛错(12.7.6)。需要交互就把那一块拆成客户端组件。 'use client'放在文件中间 :指令序言必须在模块最顶部(loader 用node.directive判定,ReactFlightWebpackNodeLoader.js:704),放在函数体里无效。- 同一个文件两个指令:loader 直接抛「Cannot have both...」(:719)。
- 序列化
Symbol/class 实例/Map的键 :Map 的 key 也会被序列化(serializeMap把 entries 转数组再 outline),非可序列化 key 同样炸。 - 大小行
T/二进制行的混用 :二进制 chunk 与字符串 chunk 不能互换通道(12.7.4 的processStringChunk校验)。框架把 Flight 流嵌进 HTML 时通常转成字符串 push(如 Next 的self.__next_f.push([...])),此时所有 chunk 必须是字符串;二进制数据(TypedArray 行)在嵌 HTML 的场景要用框架提供的 base64 通道或独立 fetch。 - 客户端组件里
import server-only:server-only包(packages/server-only之类)在客户端构建时报错------这是防「服务器专用代码泄漏进 bundle」的编译期闸门,与 RSC 边界互补。 - request 级缓存的失效语义 :
request.cacheController.abort()在pendingChunks === 0时触发(ReactFlightServer.js:6691),cache()的信号随之 abort。意味着「渲染结束后再读缓存」会拿到 aborted 信号------React 19 用这个机制做「未使用的预取自动清理」。
12.10.5 性能影响量化
- Fizz 的内存 :整个请求的 HTML 在 flush 前都驻留内存(
segment.chunks),progressiveChunkSize(默认 12.8KB)控制单批体积;getBlockingRenderMaxSize(默认 512KB)是 shell 的红线。一个没有 Suspense 的 5MB 页面会把这 5MB 全攒在服务端------这就是「大页面必须切边界」的量化理由。 - outlining 阈值 :
isEligibleForOutlining的 500 字节(ReactFizzServer.js:519)与progressiveChunkSize共同决定「内联 vs 外置」。小边界内联省一次$RC指令;大边界外联保流速。 - Flight 的行大小 :
MAX_ROW_SIZE = 3200(ReactFlightServer.js:2256)。拆行带来引用开销(每个$L4 字节 + 客户端 lazy 包装),但换来「首行尽早可解析」------客户端可以边收边初始化元素,不必等整棵大树。 - 去重收益 :
writtenClientReferences+parentReference:propName间接引用,让「100 处使用同一客户端组件」的边际成本约等于每个元素一次["$","$L1",...](约 20 字节);长 URL 的MIN_DEDUPLICATED_IMPORT_STRING_LENGTH=16阈值去重对典型 webpack 元数据(chunk URL 数十字节)收益显著。 - hydration 成本 :认领式水合的成本 ≈ 遍历 + 比对,无 DOM 写操作;选择性水合把「首屏可交互」从「全树水合完」提前到「用户碰的边界水合完」。
SelectiveHydrationLane是独立车道,不会被普通更新挤掉(getBumpedLaneForHydrationByLane)。
12.11 与前面章节的呼应
与第 3、4 章(Fiber 与 Reconciliation) :SSR 绕开 reconciler 的根本原因在 12.4.1------Fiber 是「先建整棵树再提交」的双缓冲结构,而 SSR 要「边遍历边吐字节」。但两者在客户端必须汇合:hydrateRoot 让 Fiber 树的 beginWork/completeWork 在「已有 DOM」上运行(12.5.2),ForceClientRender 恢复走的是标准 reconciler 的错误路径(第 4 章的 throw/unwind 逻辑)。Flight 客户端产出的元素树则与第 2 章的元素结构完全一致(createElement 在 ReactFlightClient.js:1484 产出的就是标准 element 对象),所以它能直接喂给 hydrateRoot 或客户端的 createContainer。
与第 5 章(Scheduler) :SSR 不使用 Scheduler 的时间切片。Fizz 的调度是 scheduleWork(Node 上 setImmediate,ReactServerStreamConfigNode.js:31)+ scheduleMicrotask(queueMicrotask),粒度是「任务(Task)」而非「时间片」;一个任务跑到挂起/完成/出错为止。Flight 同理。第 5 章的 Lane 优先级体系在 SSR 没有对应物------服务端并发模型是「多请求各自独立」,请求间的公平性由进程/线程池解决,而非 Lane。唯一的交集在客户端:选择性水合复用 Lane(SelectiveHydrationLane,ReactFiberLane.js:102),把「水合一个边界」调度成普通更新。
与第 6 章(ReactDOM 与 Commit) :hydration 就是 commit 的一种模式。第 6 章讲的 commitPlacement/commitUpdate/commitDeletion 在水合期间大部分被抑制(认领已有节点),但 ForceClientRender 边界的重渲会触发真实 deletion(ReactFiberCommitWork.js:345 的检查)。listenToAllSupportedEvents(第 10 章事件系统)在 hydrateRoot 里对 container 调用一次,事件委托与水合共享同一棵 DOM。
与第 7 章(Hooks) :服务端的 hooks 是「另一套 dispatcher」。Fizz 的 HooksDispatcher(ReactFizzHooks.js:884)保留 useState/useReducer 等真实实现(服务端渲染 + 水合后接管),effect 全 noop;Flight 的 dispatcher(ReactFlightHooks.js:66)只留白名单,其余抛错。useId 在两处有不同的前缀策略(R/r vs S,见 12.6.2),保证同页面多渲染源 id 不冲突。useMemoCache(React Compiler 的 memo cache)在两侧都可用------编译器产出的代码因此可以跨环境运行(呼应第 11 章)。
与第 8 章(并发特性) :REACT_OPTIMISTIC_KEY 在 Flight 的 keyPath 拼接里被特殊处理(ReactFlightServer.js:1973:乐观 key 会「感染」整条 key 链),这是 useOptimistic 的 key 前缀机制能跨服务器/客户端边界工作的原因。useTransition/useDeferredValue 在服务器不可用(Flight dispatcher 抛错),它们的语义只存在于客户端 Fiber 世界。
与第 9 章(Suspense) :第 9 章讲的 SuspenseBoundary 状态机(PENDING → COMPLETED/CLIENT_RENDERED 等)在 Fizz 里有一份「镜像实现」------SuspenseBoundary 类型(ReactFizzServer.js:261)的 status 常量与 reconciler 的 SuspenseState 语义一一对应。use(promise) 抛 SuspenseException → Fizz 转 task 重试、reconciler 转 thenable 重试,两条路径共享同一套「thenable 仪表化」思想(ReactFizzThenable/ReactFiberThenable/ReactFlightThenable 三份实现,源码注释明说「changes to one module should be reflected in the others」)。流式 SSR 的「先 fallback 后内容」本质上是把第 9 章的「avoid hiding already revealed content」原则应用到字节流:内容永远追加,fallback 永远可替换。
与第 10 章(事件系统) :选择性水合与事件重放是同一机制的两面(12.5.4)。ReactDOMEventReplaying.js 在边界未水合时把离散/连续事件入队,边界水合完成(attemptExplicitHydrationTarget 把 blockedOn 置空)后 replayUnblockedEvents 重放。表单 action 的 queueChangeEvent/scheduleReplayQueueIfNeeded(:484/:559)也在这里,Server Action 的表单提交流水线依赖它。
与第 11 章(React Compiler) :编译器自动 memo 化的代码在服务器同样运行。useMemoCache/useCacheRefresh 在 Flight dispatcher 有对应实现(ReactFlightHooks.js:93),useCacheRefresh 在服务器抛错(unsupportedRefresh)------编译器产出的 useCacheRefresh 调用只会出现在客户端代码路径,因此不冲突。
与第 13 章(React 19 新特性) :本章的 prerender/resume、<Activity> 边界(Fizz 的 pushStartActivityBoundary、水合的 tryHydrateActivity)、useFormStatus/useActionState 的 SSR 通路(ReactFizzHooks.js 的 useActionState、pushFormStateMarkerIsMatching)、ViewTransition 的 SSR 编码(getViewTransitionFormatContext、upgradeToViewTransitionsInstruction)都会在第 13 章展开;本章提供它们的底层机制。
12.12 源码索引表
12.12.1 入口与分流
| 文件 | 关键导出 | 行号 | 说明 |
|---|---|---|---|
packages/react-dom/src/server/react-dom-server.node.js |
re-export | 10-16 | Node 入口:FizzServerNode + FizzStaticNode |
packages/react-dom/src/server/react-dom-server.browser.js |
re-export | 10-11 | 浏览器/Web 入口 |
packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js |
renderToString/renderToStaticMarkup |
18-42 | legacy 包入口 |
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js |
renderToStringImpl |
35-102 | 同步渲染实现(复用 Fizz + abort) |
packages/react-dom/src/server/ReactDOMFizzServerNode.js |
renderToPipeableStream |
135-170 | Node Writable 流式出口 |
packages/react-dom/src/server/ReactDOMFizzServerNode.js |
renderToReadableStream |
211-297 | Web Streams 出口(Node 版) |
packages/react-dom/src/server/ReactDOMFizzServerNode.js |
resume/resumeToPipeableStream |
320-426 | React 19 恢复出口 |
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js |
renderToReadableStream/resume |
78-222 | 浏览器版 |
packages/react-dom/src/server/ReactDOMFizzStaticNode.js |
prerender/prerenderToNodeStream/resumeAndPrerender |
118-366 | 预渲染 API |
packages/react-server-dom-webpack/npm/server.node.js |
Flight 服务端 npm 出口 | 7-21 | renderToReadableStream/decodeReply/registerServerReference 等 |
12.12.2 Fizz 引擎(react-server)
| 文件 | 函数 | 行号 | 作用 |
|---|---|---|---|
packages/react-server/src/ReactFizzServer.js |
RequestInstance |
544-599 | Request 构造 |
createRequest |
601-668 | 创建请求 + root task | |
createPrerenderRequest |
670-704 | 打开 trackedPostpones | |
resumeRequest |
706-800 | 从 PostponedState 恢复 | |
resumeAndPrerenderRequest |
802-831 | 恢复 + 继续 track | |
pingTask/pingRejectedTask |
845-882 | 唤醒任务入队 | |
createSuspenseBoundary |
884-929 | 边界对象构造 | |
createRenderTask/createReplayTask |
931-1047 | 任务构造(计数 +1) | |
createPendingSegment |
1049-1070 | 段构造 | |
renderSuspenseBoundary |
1474-1795 | 边界三命运 | |
replaySuspenseBoundary |
1797-1944 | 重放边界 | |
renderElement |
2337-2487 | 元素分派(服务器组件判定) | |
renderNode |
4312-4548 | 安全遍历外壳(回滚写指针) | |
renderNodeDestructive |
3475-4311 | 节点类型分派 | |
finishedTask |
5179-5325 | 进度结算 | |
completeShell/completeAll |
5082-5127 | shell/全部完成回调 | |
retryTask/retryRenderTask/retryReplayTask |
5327-5593 | 任务重试 | |
performWork |
5594-5650 | 工作循环 | |
flushSegment |
5850-6003 | 段的四种输出分支 | |
flushCompletedBoundary/flushPartialBoundary |
6051-6129 | 边界 flush | |
flushCompletedQueues |
6175-6368 | 输出总调度 | |
startWork/startFlowing/stopFlowing |
6370-6497 | 流控制 | |
abort/attachAbortSignal |
6540-6598 | 中止语义 | |
getPostponedState |
6651+ | 产出 PostponedState | |
DEFAULT_PROGRESSIVE_CHUNK_SIZE |
474 | 12800 | |
getBlockingRenderMaxSize |
476-509 | 512KB shell 红线 | |
isEligibleForOutlining |
511-527 | outlining 启发式 |
12.12.3 Fizz host config 与指令集
| 文件 | 函数 | 行号 | 作用 |
|---|---|---|---|
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js |
createRenderState |
385-673 | RenderState(前缀/bootstrap/importMap/headers) |
createResumableState |
688-731 | 跨 pass 资源状态 | |
writeStartPendingSuspenseBoundary |
4686-4703 | <!--$?--><template id="B:id"> |
|
writeStartCompletedSuspenseBoundary |
4680-4685 | <!--$--> |
|
writeStartClientRenderedSuspenseBoundary |
4704-4763 | <!--$!--> + 错误属性 |
|
writeStartSegment |
4819-4880 | <div hidden id="S:id"> 等 |
|
writePlaceholder |
4625-4635 | <template id="P:id"> |
|
writeCompletedSegmentInstruction |
4928-4972 | $RS / data 格式 |
|
writeCompletedBoundaryInstruction |
5003-5131 | $RC / data 格式 |
|
writeClientRenderBoundaryInstruction |
5154-5278 | $RX / data 格式 |
|
writeCompletedRoot |
4587-4616 | shell 收尾 + bootstrap | |
preinitStyle/preinitScript/preinitModuleScript |
6672-6871+ | 资源预初始化 | |
| dispatcher 替换 | 106-114 | D/C/L/m/X/S/M |
|
.../fizz-instruction-set/ReactDOMFizzInstructionSetShared.js |
clientRenderBoundary |
378-405 | $RX 运行时 |
completeBoundary |
408-461 | $RC 运行时(批处理 + 节流) |
|
completeSegment |
589+ | $RS 运行时 |
|
packages/react-server/src/ReactServerStreamConfigNode.js |
beginWriting/writeChunkAndReturn/completeWriting |
84/203/211 | 4096 字节聚合写 |
packages/react-server/src/ReactFizzHooks.js |
HooksDispatcher |
884-947 | SSR hooks |
use/unwrapThenable |
818-849 | use → thenable 跟踪 |
|
packages/react-server/src/ReactFizzThenable.js |
SuspenseException/trackUsedThenable |
29/45-150 | thenable 仪表化 |
12.12.4 Flight 引擎
| 文件 | 函数 | 行号 | 作用 |
|---|---|---|---|
packages/react-server/src/ReactFlightServer.js |
RequestInstance |
686-827 | Flight Request(去重表 + chunk 队列) |
createRequest/createPrerenderRequest |
829-893 | 创建 | |
renderFunctionComponent |
1797-1990 | 执行服务器组件 | |
renderClientElement |
2185-2246 | 客户端元素序列化(keyPath 拼接) | |
renderElement |
2337-2487 | 元素分派 | |
deferTask/outlineTask/outlineHaltedTask |
2268-2335 | 拆行原语 | |
renderModel |
3660-3772 | 带错误处理的模型渲染 | |
renderModelDestructive |
3774-4400 | 类型分派 + 序列化契约 | |
serializeClientReference |
3164-3216 | 客户端引用去重 + lazy 特判 | |
serializeServerReference |
3306-3367 | 服务端引用(id + bound) | |
serializeThenable |
1109-1284 | Promise → $@/$w |
|
serializeXxx(Map/Set/FormData/Blob/TypedArray/Error/Iterator/Stream/AsyncIterable) |
3376-3612 | 白名单类型 | |
emitChunk |
6151-6241 | 发射(resolveModel + stringify) | |
retryTask |
6268-6400 | 任务执行 + 重试 | |
performWork |
6422-6447 | 工作循环 | |
flushCompletedChunks |
6508-6737 | 五类 chunk 输出顺序 | |
emitModelChunk/emitImportChunk/emitTextChunk/emitTypedArrayChunk/emitHintChunk |
4776-5203 | 行发射器 | |
MAX_ROW_SIZE |
2256 | 3200 | |
resolveModel |
2948-3069 | 纯 JS 预解析 | |
packages/react-server/src/ReactFlightHooks.js |
HooksDispatcher |
66-104 | RSC hooks(unsupportedHook 组) |
use |
129-170 | use 语义 |
|
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js |
renderToPipeableStream/renderToReadableStream/prerender |
166/333/438-527 | Flight 出口 |
decodeReply/decodeReplyFromBusboy |
540-748 | Server Action 上行解码 | |
packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js |
transformModuleIfNeeded |
648-760 | use client/use server 识别 |
packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js |
registerClientReference/registerServerReference/createClientModuleProxy |
37/111/349 | 引用注册 |
12.12.5 Flight 客户端
| 文件 | 函数 | 行号 | 作用 |
|---|---|---|---|
packages/react-client/src/ReactFlightClient.js |
ResponseInstance/createResponse |
2995-3149 | 响应状态 |
createStreamState |
3161-3191 | 行解析状态 | |
processBinaryChunk |
5319-5482 | 字节级行解析 | |
processStringChunk |
5484+ | 字符串版 | |
processFullBinaryRow/processFullStringRow |
5074-5317 | tag 分派 | |
parseModelString/parseModelTuple |
2656-2983 | $ 前缀逆运算 |
|
createElement |
1484-1610 | 元素重建(阻塞 → lazy) | |
fulfillReference/rejectReference |
1754-1994 | 引用填充 | |
getChunk/getWeakChunk/getOutlinedModel |
1661/1686/2305 | chunk 寻址 | |
loadServerReference |
2066-2245 | 服务端引用加载/代理 | |
initializeModelChunk/initializeModuleChunk |
1136/1276 | chunk 初始化 | |
resolveModel/resolveText/resolveBuffer/resolveModule/resolveHint/resolveErrorModel |
3309-3962 | 各 tag 处理器 | |
reportGlobalError |
1292-1331 | 流错误传播 | |
packages/react-client/src/ReactFlightReplyClient.js |
createResponse(上行) |
1529 行文件 | 服务端解码请求体 |
12.12.6 Hydration 与选择性水合
| 文件 | 函数 | 行号 | 作用 |
|---|---|---|---|
packages/react-dom/src/client/ReactDOMRoot.js |
hydrateRoot |
276-361 | 入口 |
unstable_scheduleHydration |
268-274 | 手动触发水合 | |
packages/react-reconciler/src/ReactFiberHydrationContext.js |
tryToClaimNextHydratableInstance |
461-484 | 认领或 mismatch |
throwOnHydrationMismatch |
388-416 | 错误构造 | |
claimHydratableSingleton |
418-459 | html/head/body | |
prepareToHydrateHostSuspenseInstance |
676-698 | 边界水合 | |
enterHydrationState/popHydrationState |
162/759 | 水合开关 | |
upgradeHydrationErrorsToRecoverable |
907-920 | 错误降级 | |
packages/react-reconciler/src/ReactFiberThrow.js |
水合恢复分支 | 552-620 | ForceClientRender 逻辑 |
packages/react-reconciler/src/ReactFiberWorkLoop.js |
recoverFromConcurrentError |
1327-1406 | 根级降级 |
packages/react-reconciler/src/ReactFiberReconciler.js |
attemptContinuousHydration/attemptHydrationAtCurrentPriority |
534/550 | 选择性水合入口 |
packages/react-reconciler/src/ReactFiberLane.js |
SelectiveHydrationLane |
102 | 专用车道 |
packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js |
queueExplicitHydrationTarget/attemptExplicitHydrationTarget |
361/312 | 事件驱动水合 |
replayUnblockedEvents |
454 | 事件重放 | |
packages/react-dom/src/shared/ReactDOMFloat.js |
preload/preinit/preconnect 等 |
24-300 | 资源提示 API |
12.12A 深入:从元素到字节的 host config
前面反复出现「host config 的 pushXxx 把 chunk 推进 segment」。这一节把这些函数展开,看一个普通 <div className="x">hello</div> 究竟怎么变成字节。
pushStartInstance:标签分发与属性编码
pushStartInstance(ReactFizzConfigDOM.js:4232)是 host 元素的总入口。它先做一批 DEV 校验(ARIA 属性名、受控 input 警告、大小写警告------type.indexOf('-') === -1 && type.toLowerCase() !== type 时提示「Use PascalCase for React components」),然后按标签名分派:
js
switch (type) {
case 'div':
case 'span':
case 'svg':
case 'path':
// Fast track very common tags
break;
case 'a':
return pushStartAnchor(target, props, formatContext);
// ...
case 'select':
return pushStartSelect(target, props, formatContext);
case 'option':
return pushStartOption(target, props, formatContext);
case 'textarea':
return pushStartTextArea(target, props, formatContext);
case 'input':
return pushInput(target, props, ...);
// ...
「Fast track」意味着 div/span/svg/path/g/p/li 这些最高频标签走通用路径 pushStartGenericElement(:4012),而 a/select/option/textarea/input/button/form/img/title/meta/link/style/script 各有专用实现------因为它们有需要特殊处理的属性:<a> 的 href 要过 sanitizeURL(防 javascript: URL);<input>/<textarea>/<select> 要处理受控值与 defaultValue 的 SSR 语义(checkControlledValueProps、inputValueTracking);<form> 要处理 action 与 Server Function 的 formAction 编码(pushFormActionAttribute,:1480);<title>/<meta>/<link>/<style> 是 hoistable,要同时登记进 hoistableState 以便提升到 head。
属性编码的三件套(:1360 起):
js
function pushBooleanAttribute(target, name, value) { /* true → name="",false → 跳过 */ }
function pushStringAttribute(target, name, value, ...) { /* name="escaped" */ }
function pushStyleAttribute(...) { /* style 对象 → CSS 文本,处理 unitless 数字 */ }
escapeTextForBrowser(./escapeTextForBrowser)负责 HTML 转义(&、<、>、"、' 全部实体化);isAttributeNameSafe 防属性名注入;sanitizeURL 只允许 http/https/mailto/tel 等安全协议。所有用户可控字符串在进入 chunk 前都被转义 ------这是 SSR 防 XSS 的第一道闸,第二道是内联 script 内容的 escapeJSStringsForInstructionScripts/escapeJSObjectForInstructionScripts(:5281/:5303,把 < 转成 \u003c 防 </script> 逃逸)。
pushTextInstance 与文本分隔符
文本节点的编码比想象中讲究(:1180):
js
const textSeparator = stringToPrecomputedChunk('<!-- -->');
export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
renderState: RenderState,
textEmbedded: boolean,
): boolean {
if (text === '') {
// Empty text doesn't have a DOM node representation and the hydration is aware of this.
return textEmbedded;
}
if (textEmbedded) {
target.push(textSeparator);
}
target.push(stringToChunk(encodeHTMLTextNode(text)));
return true;
}
<!-- --> 分隔符解决的是一个微妙的 DOM 等价性问题:{'a'}{'b'} 两个相邻文本在 DOM 里是两个 text 节点,而 HTML 源码 ab 解析出来只有一个。React 用注释节点把它们隔开,保证 SSR 产物与水合时的 DOM 结构逐节点一致 。textEmbedded 标记表示「当前处于 table/mathml 等文本语义特殊的上下文」------在这些上下文里相邻文本的分割规则不同,需要分隔符。pushSegmentFinale(:1199)在段结束时补最后一个分隔符。这是 SSR 与 hydration「结构同构」承诺的一个缩影:字节流不是给人看的 HTML,是给 DOM 解析器的精确指令。
makeId:useId 的确定性
Fizz 侧 makeId(:1155):
js
export function makeId(
resumableState: ResumableState,
treeId: string,
localId: number,
): string {
const idPrefix = resumableState.idPrefix;
let id = '_' + idPrefix + 'R_' + treeId;
// Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId
// hooks for this fiber.
if (localId > 0) {
id += 'H' + localId.toString(32);
}
return id + '_';
}
treeId 来自 getTreeId(ReactFizzTreeContext.js)------按组件在树中的路径生成;localId 是同组件内第几个 useId。组合起来保证:服务端渲染生成的 id 与客户端水合时生成的 id 完全一致 (水合时 useId 走 reconciler 的实现,但树路径 + hook 序号相同,输出就相同)。identifierPrefix 选项给每个渲染请求加前缀,隔离同一进程里的多个请求。Flight 侧用 S 前缀(见 12.6.2),Fizz 用 R,客户端用 r/R 变体------三套命名空间互不干扰。
类组件的 SSR 路径
renderClassComponent(ReactFizzServer.js:2604)→ constructClassInstance + mountClassInstance(ReactFizzClassComponent.js)。SSR 里类实例只走 render,不走 componentDidMount(没有 DOM 挂载点);getDerivedStateFromProps 正常执行;getSnapshotBeforeUpdate 在 SSR 无意义(didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate 只在 DEV 警告)。legacy context(contextTypes/childContextTypes)由 ReactFizzLegacyContext.js 支持,可以通过 feature flag 关闭(disableLegacyContext)。
Context 在 SSR:快照而非订阅
ReactFizzNewContext.js 实现了一套与 reconciler 的 context 机制平行但更简单的版本。核心是 ContextSnapshot:
js
// Used to store the parent path of all context overrides in a shared linked list.
// Forming a reverse tree.
type ContextNode<T> = {
parent: null | ContextNode<any>,
depth: number, // Short hand to compute the depth of the tree at this node.
context: ReactContext<T>,
parentValue: T,
value: T,
};
export opaque type ContextSnapshot = null | ContextNode<any>;
export const rootContextSnapshot: ContextSnapshot = null;
switchContext(snapshot) 在遍历进出 Provider 时切换 context._currentValue(或非 primary 渲染器的 _currentValue2),popToNearestCommonAncestor 复用公共祖先避免无谓的 pop/push。与客户端的差别:没有订阅 。SSR 里 readContext 直接读 _currentValue;渲染结果被序列化/输出后,「更新」不存在------下次请求自然读到新值。这解释了为什么 context 可以跨越 RSC 边界的一部分:服务器组件不能读客户端 context(12.6.2),但可以把客户端 context 的 Provider 渲染出来(getReference 里 Provider 返回自身,ReactFlightWebpackReferences.js:184),让客户端组件在水合后读取。
12.12B 深入:SSR 错误处理三件套
encodeErrorForBoundary:错误如何变成字节
encodeErrorForBoundary(ReactFizzServer.js:1342)决定一个出错的边界在输出里携带什么信息:
js
function encodeErrorForBoundary(
boundary: SuspenseBoundary,
digest: ?string,
error: mixed,
thrownInfo: ThrownInfo,
wasAborted: boolean,
) {
boundary.errorDigest = digest;
if (__DEV__) {
if (isRecoverableError(error)) {
boundary.errorMessage = wasAborted
? 'Switched to client rendering because the server render was aborted ' +
'with a request to render on the client.'
: 'Switched to client rendering because a component requested it.';
boundary.errorComponentStack = thrownInfo.componentStack;
return;
}
let message, stack;
if (error instanceof Error) {
message = String(error.message);
stack = String(error.stack);
} else if (typeof error === 'object' && error !== null) {
message = describeObjectForErrorMessage(error);
stack = null;
} else {
message = String(error);
stack = null;
}
const prefix = wasAborted
? 'Switched to client rendering because the server rendering aborted due to:\n\n'
: 'Switched to client rendering because the server rendering errored:\n\n';
boundary.errorMessage = prefix + message;
boundary.errorStack = stack !== null ? prefix + stack : null;
boundary.errorComponentStack = thrownInfo.componentStack;
}
}
生产模式只发 errorDigest(由 onError 回调返回的字符串,通常是错误哈希)------DEV 才把 message/stack/componentStack 写进 data-msg/data-stck/data-cstck 属性(writeStartClientRenderedSuspenseBoundary,:4704)。这是「错误信息不泄漏内部细节」的经典分层。isRecoverableError 是 React 19 的新内部品牌(REACT_RECOVERABLE_TYPE,shared/ReactRecoverable.js):组件可以抛「可恢复错误」主动要求切换到客户端渲染(例如检测到 typeof document === 'undefined' 不满足),此时文案是「a component requested it」而非真实错误。
logRecoverableError 与 onError 契约
logRecoverableError(:1383)的契约很严:
js
const onError = request.onError;
const errorDigest =
__DEV__ && debugTask
? debugTask.run(onError.bind(null, error, errorInfo))
: onError(error, errorInfo);
if (errorDigest != null && typeof errorDigest !== 'string') {
// We used to throw here but since this gets called from a variety of unprotected places it
// seems better to just warn and discard the returned value.
if (__DEV__) {
console.error(
'onError returned something with a type other than "string". ...',
);
}
return;
}
// An empty digest is reserved for React's internal client-render signal.
return errorDigest === '' ? undefined : errorDigest;
onError 必须返回 string 或 null/undefined;返回空串是 React 内部「客户端渲染信号」的保留值(会归一化成 undefined)。注释解释了「为什么只警告不抛」:调用点太多太深,抛错会把可恢复错误升级成致命错误。没有提供 onError 时用 defaultErrorHandler(:529)------它会检查 error.environmentName(RSC 环境名徽标机制),决定用 bindToConsole 还是裸 console.error。
fatalError:不可恢复之路
fatalError(:1434)是终态处理:onShellError(仅当 shell 未完成)+ onFatalError(总是),然后 endRenderLifetime(触发 renderLifetimeController 清理),有 destination 就 closeWithError,否则标记 CLOSING 并把 fatalError 存进 request(abort 的 task 会读到它)。典型触发场景:根组件在 Suspense 之外抛错、根 fallback 自身抛错、React 自身或 host config 的 bug。
12.12C 深入:Server Action 的上行协议
decodeReply 系列(ReactFlightDOMServerNode.js:540-748)把客户端 POST 回来的 FormData/异步迭代还原成参数,靠的是 ReactFlightReplyServer.js------它是 Flight 客户端在服务器上的复用:
js
// packages/react-server/src/ReactFlightReplyServer.js
// The server acts as a Client of itself when resolving Server References.
// That's why we import the Client configuration from the Server.
// Everything is aliased as their Server equivalence for clarity.
import type {
ServerReferenceId,
ServerManifest,
ClientReference as ServerReference,
} from 'react-client/src/ReactFlightClientConfig';
这个「服务器作为自己的客户端」的设计让上行与下行共用同一套 chunk 解析器:createResponse(:1891)建一个上行 Response,resolveField(response, name, value)(:1914)/resolveFile(:1948)逐字段喂入,getRoot(response)(:225)返回还原后的参数树。decodeReplyFromBusboy 还处理了「文件流与文本字段交错到达」的顺序问题------用一个 PendingFile 链表(ReactFlightDOMServerNode.js:529)保证 backing FormData 的插入顺序与 payload 一致,注释解释了这个链表存在的理由。
decodeAction(ReactFlightActionServer.js)在参数还原后调用真正的 Server Action 函数,返回值再交给 Flight 序列化流回客户端------完成「调用 → 序列化 → 传输 → 反序列化 → 执行 → 再序列化返回」的闭环。React 19 的 <form action>、useFormStatus、useActionState、渐进增强(无 JS 也能提交)全部建立在这条通道上;ReactFizzConfigDOM.js 的 pushFormStateMarkerIsMatching/IsNotMatching(:2357)负责把 useActionState 的状态匹配信息编进表单字段,让服务端能判断「这次提交对应哪个状态」。
manifest:引用如何变成 chunk URL
serializeClientReference 调用的 resolveClientReferenceMetadata(ReactFlightServerConfigWebpackBundler.js:47)查 ClientManifest:
js
export function resolveClientReferenceMetadata<T>(
config: ClientManifest,
clientReference: ClientReference<T>,
): ClientReferenceMetadata {
const modulePath = clientReference.$$id;
let name = '';
let resolvedModuleData = config[modulePath];
if (resolvedModuleData) {
// The potentially aliased name.
name = resolvedModuleData.name;
} else {
// We didn't find this specific export name but we might have the * export
// which contains this name as well.
const idx = modulePath.lastIndexOf('#');
// ...查 config[modulePath.slice(0, idx) + '#*']...
}
if (!resolvedModuleData) {
throw new Error(
'Could not find the module "' + modulePath + '" in the React Client Manifest. ' +
'This is probably a bug in the React Server Components bundler plugin.',
);
}
return {id: resolvedModuleData.id, name, async: !!resolvedModuleData.async};
}
ClientManifest 是 bundler 插件(ReactFlightWebpackPlugin.js)在构建时生成的映射:moduleId#exportName → {id: chunkId, name, async}。I 行发出的就是这份元数据。getClientReferenceKey(:38)用 $$async ? $$id + '#async' : $$id 做去重键------同一个模块的同步与异步引用被视为不同键,因为 async 影响客户端的加载语义。
12.12D 深入:headers 预算与 onHeaders
createRenderState 里的 headers 机制(ReactFizzConfigDOM.js:484)常被忽略但很有工程价值:
js
const headers = onHeaders
? {
preconnects: '',
fontPreloads: '',
highImagePreloads: '',
remainingCapacity:
// We seed the remainingCapacity with 2 extra bytes because when we decrement the capacity
// we always assume that we are inserting an interstitial ", " however the first header does not actually
// consume these two extra bytes.
2 +
(typeof maxHeadersLength === 'number'
? maxHeadersLength
: DEFAULT_HEADERS_CAPACITY_IN_UTF16_CODE_UNITS),
}
: null;
思路:HTTP 响应头有大小上限(CDN/代理通常 8KB~16KB),而 preconnect/字体 preload/首屏大图 preload 走 <Link> 头比走 HTML <link> 标签更早 (头先于 body 到达)。Fizz 把这类「高优先级资源提示」收集进 headers 字符串,并维护 remainingCapacity 预算:超预算就停止往头部塞(宁可退化成 HTML 里的 <link>)。maxHeadersLength 选项让用户按自己的服务器调整这个预算;onHeaders(headersDescriptor) 在合适的时机(shell 完成时)把 {Link: "..."} 交给调用方设置 HTTP 头。DEV 下 maxHeadersLength <= 0 会明确报错(:475)。
12.12E 深入:completeDehydratedSuspenseBoundary 与 Activity
completeDehydratedSuspenseBoundary(ReactFiberCompleteWork.js:999)是水合与 Suspense 的交汇点,三个分支:
- 首次进入 dehydrated 边界且未水合 (
current === null && !wasHydrated会抛内部错误;正常路径wasHydrated === true):prepareToHydrateHostSuspenseInstance认领边界的 comment 节点,bubbleProperties,返回 false------「不要落进普通 Suspense 路径」。 - 重入边界继续水合 (
current !== null):resetHydrationState();如果本次没挂起(DidCapture为 NoFlags),把memoizedState清成 null------「该边界已完整水合、不再 suspended」;workInProgress.flags |= Update调度一个 effect 把边界标记为 hydrated,这个 effect 同时是「事件可以重放」与「 suspense 回调触发」的信号(注释原文:「If nothing suspended, we need to schedule an effect to mark this boundary as having hydrated so events know that they're free to be invoked. It's also a signal to replay events and the suspense callback.」)。 - 水合成功落穿 (
nextState.dehydrated === null):upgradeHydrationErrorsToRecoverable()把第一次尝试的水合错误升级为可恢复错误存进prevState.hydrationErrors,返回 true 走普通 Suspense 路径。
Activity(<Activity>,React 19)有平行的 completeDehydratedActivityBoundary(:916)。SSR 侧 Activity 边界编码为 <!--&-->/<!--/&--> 注释(pushStartActivityBoundary,ReactFizzConfigDOM.js:4641);水合侧 tryHydrateActivity(ReactFiberHydrationContext.js:313)、claimNextHydratableActivityInstance(:506)、reenterHydrationStateFromDehydratedActivityInstance(:181)与 Suspense 版一一对应。选择性水合同样支持 Activity 边界(attemptExplicitHydrationTarget 里的 tag === ActivityComponent 分支,ReactDOMEventReplaying.js:335)。这就是 React 19 「Activity 是一等边界」的源码依据:从 SSR 编码、水合认领、事件重放到 Effect 暂停(useEffect 在 Activity 隐藏时延迟执行),整条管线都有专门分支。
ViewTransition 的 SSR 支持(enableViewTransition)走类似的「编码 + 指令」路线:getViewTransitionFormatContext(ReactFizzConfigDOM.js:1023)与 getViewTransitionClassName/getViewTransitionName(ReactFizzViewTransitionComponent.js)把 view-transition-name/class 编进 HTML,upgradeToViewTransitionsInstruction 作为 $RC 指令的可选升级参数下发(writeCompletedBoundaryInstruction 的 requiresViewTransitions 分支,:5011)。
12.12F 深入:useId、identifierPrefix 与多请求隔离
一个 Node 进程同时服务多个渲染请求时,有三样东西必须隔离:
useId的输出 :靠identifierPrefix(createResumableState的第一个参数)------每个请求的前缀不同,同树同 hook 生成的 id 就不同。Fizz 的makeId与 Flight 的useId(_S_)都带此前缀。resolveRequest()的解析 :Node 上靠AsyncLocalStorage(requestStorage,ReactServerStreamConfigNode.js的supportsRequestStorage)。preinit之类在渲染外调用的 API 靠它找回 request;AsyncLocalStorage 不可用时(旧 Node)退化为「调用栈上唯一 request」,多请求并发渲染时不能 在渲染外调这些 API------这就是preinitStyle里「找不到 request 就静默走 previousDispatcher」的原因。- 模块级单例 :
ReactSharedInternals.A(AsyncDispatcher)在 FlightRequestInstance构造时检查「只能同时一个 RSC renderer」(ReactFlightServer.js:701:「Currently React only supports one RSC renderer at a time.」)------并发 RSC 渲染需要每个请求独立的 dispatcher 上下文,19.3.1 的实现在这一点上是显式保守的。
StrictMode 在 SSR 的行为:Fizz 与 Flight 都不做双调用(双调用是客户端 reconciler 的开发期检查),但组件仍可能因 task 重试被多次调用(12.10.3 的幂等性要求)。resetOwnerStackLimit(shared/ReactOwnerStackReset)在 createRequest/createPrerenderRequest 开头被调用,防止 owner stack(DEV 的错误归属栈)跨请求无限增长。
12.13 自测 FAQ
Q1:renderToString 和 renderToPipeableStream 内部是同一套引擎吗?为什么前者遇到 Suspense 就废?
是同一套。renderToStringImpl(ReactDOMLegacyServerImpl.js:35)同样调 createRequest + startWork,但它紧接着 abort(request, abortReason)(:84)------任何未完成的边界在 abort 时被转成 client-rendered。这是产品决策:legacy API 语义是「同步拿到完整字符串」,等不了流。
Q2:Fizz 为什么不直接用 reconciler 渲染出 Fiber 树再序列化?
三个原因:Fiber 是「整树建完才提交」,与流式输出根本冲突;SSR 无 update/effect,不需要双缓冲;边遍历边输出能更早发现「这个边界可以内联」。见 12.4.1。
Q3:Suspense 边界在 SSR 输出里到底长什么样?
同步完成的小边界:<!--$-->内容<!--/$-->(零成本,renderSuspenseBoundary 提前 return);未完成/outlined:<!--$?--><template id="B:x"></template>fallback<!--/$-->,内容就绪后追加 <div hidden id="S:x">内容</div><script>$RC("B:x","S:x")</script>;出错/放弃:<!--$!--> + $RX 指令让客户端接管。
Q4:$RC、$RS、$RX 三条指令的分工?
$RC(boundaryID, contentID):把隐藏内容段搬进边界替换 fallback(completeBoundary);$RS(containerID, placeholderID):把隐藏段搬进 <template> 占位符(completeSegment);$RX(boundaryID, digest...):把边界标记为「客户端渲染」并附错误信息(clientRenderBoundary)。三者运行时都在 fizz-instruction-set/ReactDOMFizzInstructionSetShared.js。
Q5:hydration mismatch 时页面会白屏吗?
不会(React 18+ 起)。mismatch 冒泡到最近 Suspense 边界,打 ForceClientRender 后重渲该边界;没有边界兜底才整树同步客户端重渲(recoverFromConcurrentError)。用户可见结果是「局部重绘」而非「白屏」,同时 console 收到可恢复错误。
Q6:选择性水合为什么能「点击处优先」?
事件在未水合边界内触发时,attemptExplicitHydrationTarget 找到该边界对应的 Suspense fiber,用事件优先级(或 SelectiveHydrationLane)调度一次针对该边界的并发渲染;markRetryLaneIfNotHydrated 同步提高边界内未 resolve promise 的重试车道。事件本身先入队,水合完后 replayUnblockedEvents 重放。
Q7:为什么服务器组件不能用 useState?用了会怎样?
三层防御:react-server 条件导出的 react 里根本没有 useState(import 即失败);即便塞进来,Flight dispatcher 的 useState: unsupportedHook 抛「This Hook is not supported in Server Components.」;根因是服务器组件只执行一次并被序列化,没有第二次渲染同步状态,也没有 DOM 挂 effect。
Q8:use client 的传递性是怎么回事?我改了一个 import 怎么整页都变客户端了?
bundler 把 'use client' 模块及其所有传递依赖打成一个客户端 chunk。你 import 的链条上任何一环最终连到带指令的文件,整链都进客户端。export * 不传递 'use server'(loader 注释明示),需要目标文件自己带指令。治理办法:让边界文件只 import 无副事的共享代码。
Q9:Flight 协议里同一个客户端组件出现 100 次,会传 100 次吗?
不会。第一次 serializeClientReference 发一个 I 行(模块元数据)并登记 writtenClientReferences,之后全部是 $id 或(元素 type 槽位的)$Lid。对象级还有 parentId:propName 间接去重。行超 3200 字符会 deferTask 拆行。
Q10:什么类型的 props 不能从服务器组件传给客户端组件?
函数(除 'use server' action 与 client reference)、非全局 symbol、类实例/带方法对象/原型非 Object.prototype 的对象、ref、以及 server-only 数据。Date、bigint、Map、Set、FormData、Blob、TypedArray、Promise、可迭代、ReadableStream、AsyncIterable 都支持。判定逻辑全部在 renderModelDestructive。
Q11:prerender 和 renderToPipeableStream 的产物差在哪?
prerender 返回 {postponed, prelude}:prelude 是已渲染部分的流,postponed 是「空洞」的结构指纹(replay nodes/slots + resumableState + nextSegmentId)。后续请求可以用 resume(children, postponed) 只渲染空洞部分,且 replay 时会核对树结构是否与预渲染一致,不一致就客户端重渲。
Q12:React 19.3.1 里有 resumeModel 吗?
没有这个名字。等价能力是 Fizz 的 resume/resumeToPipeableStream(ReactDOMFizzStaticNode.js)与 Flight 的 prerender/prerenderToNodeStream(ReactFlightDOMServerNode.js:438)。写代码时以这两个 API 为准。
Q13:SSR 里 useEffect 为什么不报错,RSC 里报错?
Fizz 的 dispatcher 把 effect 设为 noop(组件仍会被水合,effect 在水合后第一次 commit 触发);Flight 的 dispatcher 直接 unsupportedHook 抛错(服务器组件永不在客户端执行,effect 无处可挂)。
Q14:preload 在服务器组件里调用,资源真的会被预加载吗?
会,但要经过三层:RSC dispatcher 把它编成 :H 行(ReactDOMFlightServerHostDispatcher.js)随 Flight 流下发 → 客户端 resolveHint 交给 ReactFlightClientConfigDOM → SSR 场景由 preinitModuleForSSR/preinitScriptForSSR 在 Fizz 渲染期间写入 HTML <head>。没有任何一层时静默跳过(「optimistic and not critical」)。
Q15:大页面没有 Suspense 边界会发生什么?
shell 超过 getBlockingRenderMaxSize(默认 512KB)时,React 打一条可恢复错误(「>500 kB without any Suspense boundaries」)并启用 skipBlockingShell:放弃「等 shell 完整」的策略,允许不完整 HTML 先画。功能不坏,但 FCP/LCP 指标会退化。
Q16:Flight 流可以嵌进 HTML 一起发吗?二进制行怎么办?
可以,这就是 RSC+SSR 的标准架构(Flight 流内嵌为 self.__next_f.push(...) 之类)。但嵌 HTML 的通道通常是字符串,而 T/TypedArray 行是二进制长度前缀的------processStringChunk 明确要求「字符串 chunk 必须原样传递」。框架需要把二进制数据单独走 base64 通道或独立请求,这也是 createFromFetch/createFromReadableStream 分离存在的原因。
12.14 本章要点小结
- 四种渲染形态的演进动力是 FCP/SEO 与「慢数据不阻塞首屏」:CSR → SSR → 流式 SSR → RSC,每一级都在把「等待」从关键路径上挪走。
- 入口分流 :
react-dom/server= 流式 API(PipeableStream/ReadableStream/prerender/resume);react-dom/server-legacy=renderToString(内部仍是 Fizz + 立即 abort)。 - Fizz 抛弃 Fiber ,改用 Task/Segment/SuspenseBoundary 模型边遍历边输出;
performWork跑任务、flushCompletedQueues按「shell → hoistables → RX→RC → 部分段 → 大边界」五级优先级输出,背压通过 destination=null + drain 重连处理。 - Suspense 边界的输出策略 由
isEligibleForOutlining+progressiveChunkSize决定:小边界内联(零成本),大边界先 fallback 后$RC补齐;<!--$-->/<!--$?-->/<!--$!-->三套注释 +<template>+<div hidden>+ 内联 script 指令构成完整的「可寻址 HTML」协议。 - hydration 是 commit 的水合模式 :认领而非创建;mismatch 时
ForceClientRender就近降级、根级降级走recoverFromConcurrentError;选择性水合用SelectiveHydrationLane与事件优先级让「用户交互处」优先水合,事件重放保证不丢交互。 - RSC 边界由三层强制 :包导出白名单(
react-server条件的ReactServer.js)、dispatcher 抛错(ReactFlightHooks.js)、序列化报错(renderModelDestructive)。use client/use server由 bundler 插件识别(transformModuleIfNeeded用 AST directive 判定),落地为registerClientReference/registerServerReference的引用对象。 - Flight 协议 = 行格式流 :
id:tag+JSON,$前缀表达引用($id/$Lid/$@id/$w/$h/$S/$T/$D/$n...);去重靠writtenObjects/writtenClientReferences/writtenServerReferences三张表 + 父引用间接寻址 +MAX_ROW_SIZE=3200拆行;客户端用字节级状态机(processBinaryChunk)解析,parseModelString还原引用,createElement把阻塞元素转 lazy 实现渐进初始化。 - RSC + SSR 的合流 :Flight 流既是组件树也是资源清单(
:Hhint 行),preinitModuleForSSR/preinitScriptForSSR把 chunk 提前写进 HTML;React 19 的prerender/resume通过PostponedState+ ReplayTask 实现「构建期渲染 + 结构核对 + 请求期补齐」。 - 资源预加载 统一走
ReactDOMSharedInternals.ddispatcher,三个环境(客户端 DOM/Fizz/Flight)各自实现;Fizz 侧用resumableState(跨 pass)+renderState(单次渲染)两级去重,precedence保证样式顺序一致。 - 最大的工程风险 依次是:根级缺 Suspense(流式退化)、hydration mismatch 治理不当、props 序列化越界、
'use client'传递性失控、二进制/字符串 chunk 通道混用。理解本章的源码机制,绝大多数「RSC 玄学问题」都能落到具体的抛错行上。