React 19.3.1 源码深度剖析-第 12 章(下)RSC 与 Flight 协议

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 也就成了「客户端模块」。有两个推论经常踩坑:

  1. 服务器组件 import 的任何东西,如果传递依赖了 'use client',整条链都进客户端。想拆细就得把边界文件保持「干净」(只 import 真正无副事的共享模块)。
  2. 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)

去重的三个层次:

  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 串二义)。

  1. 客户端引用级 :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,而不是让父级阻塞。这直接改善了「客户端组件代码未下载完」时的呈现策略。

  1. 行大小限制与 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 协议格式全景图

flowchart LR subgraph ServerWrite[&#34;服务端写入(ReactFlightServer)&#34;] direction TB RC[&#34;服务器组件 render()&#34;] --> RD[&#34;renderModelDestructive&#34;] RD -->|&#34;元素数组&#34;| EL[&#34;[$, type, key, props]&#34;] RD -->|&#34;client ref&#34;| CR[&#34;serializeClientReference<br/>$L1(首次)+ I 行&#34;] RD -->|&#34;server action&#34;| SR[&#34;serializeServerReference<br/>$h5&#34;] RD -->|&#34;promise&#34;| PR[&#34;$@7 / $w7&#34;] RD -->|&#34;大对象/挂起&#34;| OT[&#34;outlineTask / deferTask<br/>新 id + $L&#34;] EL --> EM[&#34;emitModelChunk<br/>id:JSON&#34;] CR --> EI[&#34;emitImportChunk<br/>id:I{...}&#34;] OT --> EM end subgraph Wire[&#34;线路格式(按行)&#34;] direction TB L1[&#34;1:I{&#34;chunk&#34;:&#34;main&#34;,&#34;name&#34;:&#34;Button&#34;}&#34;] L2[&#34;2:[&#34;$&#34;,&#34;$L1&#34;,null,{&#34;children&#34;:&#34;hi&#34;}]&#34;] L3[&#34;:HP{&#34;href&#34;:&#34;/x.js&#34;,&#34;as&#34;:&#34;script&#34;}&#34;] L4[&#34;3:&#34;plain string&#34;&#34;] L5[&#34;7:T12,hello world&#34;] end subgraph ClientRead[&#34;客户端读取(ReactFlightClient)&#34;] direction TB PB[&#34;processBinaryChunk<br/>ROW_ID/TAG/LENGTH&#34;] PFR[&#34;processFullBinaryRow<br/>→ processFullStringRow&#34;] RM[&#34;resolveModel<br/>parseModelString / parseModelTuple&#34;] CE[&#34;createElement<br/>阻塞 → lazy&#34;] FC[&#34;真正的 React 元素树<br/>(客户端组件 = 真实函数)&#34;] PB --> PFR --> RM --> CE --> FC end ServerWrite --> Wire --> ClientRead

一次最小往返的字节级示例(生产模式,假设 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):

  1. 函数(: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。

  1. Symbol (:4359):只接受 Symbol.for(name)(全局符号注册表里的),否则抛「Only global symbols received from Symbol.for(...) can be passed」。因为 symbol 身份无法跨环境传输。
  2. 类实例 / 非简单对象(: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)。方法不能序列化------因为方法意味着行为,行为属于代码,代码不过界。

  1. ref :renderElement 开头(:2346)直接抛「Refs cannot be used in Server Components, nor passed to Client Components.」------React 19 把 ref 变成普通 prop 后,这个限制依然存在于 RSC 边界(服务器没有实例可 ref)。
  2. 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 的完整形态。真实架构里两者串起来:

sequenceDiagram participant FS as Flight Server<br/>react-server-dom-webpack participant SSR as Fizz<br/>react-dom/server participant Browser as 浏览器 participant FC as Flight Client<br/>react-server-dom-webpack/client Browser->>FS: GET /page(带 RSC 协商头) FS->>FS: renderToPipeableStream(, clientManifest) FS-->>SSR: 同进程:createFromNodeStream(flightStream)<br/>得到 React 元素树(含 client refs) Note over SSR: 框架层把 RSC 流同时喂给两处 SSR->>SSR: renderToPipeableStream(rscTree) SSR->>Browser: HTML 流(shell + Suspense fallback + $RC/$RS 指令) FS->>Browser: Flight 流(内嵌于 HTML,如 self.__next_f.push) Browser->>FC: createFromNodeStream(flightStream) FC->>FC: processBinaryChunk → 元素树 + 组件 chunk 加载 Browser->>Browser: hydrateRoot(container, rscRoot) Note over Browser: 同一棵元素树:SSR 用它出 HTML,<br/>Flight 客户端重建它供水合/导航

需要澄清一个常见误解: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 语义分两层:

  1. 服务器组件里的 <Suspense> :在 Flight 序列化时没有特殊处理 (renderElement 只把它当普通元素类型序列化,因为 Suspense 的 $$typeof 是 REACT_SUSPENSE_TYPE 而不是函数)。它的 fallback/内容都会被完整序列化。真正「分段」发生在这些元素到达 Fizz 之后------即 SSR 阶段。
  2. 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。三个环境各填各的:

  1. 客户端 DOM :ReactFiberConfigDOM.js 的 preloadInstance/preloadResource(:6575/:6588)等,直接往 document head 插 <link>。
  2. 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,
};
  1. 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);
    }
  }
}

机制拆解:

  1. resolveRequest() 找当前渲染 :依赖 AsyncLocalStorage(supportsRequestStorage 时)或调用栈。找不到就静默走 previousDispatcher(客户端)或啥也不做------这就是注释说的「optimistic and not critical,favor silent bailout」。
  2. 跨 pass 去重靠 resumableState :styleResources[key] = EXISTS 是「本 request(含 prerender→resume 的整个生命周期)已创建过」。所以 prerender 里 preload 过的样式,resume 时不会重复发。这是 resumableState 与 renderState 的分工:前者跨 pass 持久(序列化进 postponed state),后者单次渲染内有效(styles Map、preloads Map)。
  3. preload 与 preinit 的协同 :如果先 preload 后 preinit,且 preload 的 <link> 还没 flush,就清零它(避免重复标签);如果已经 flush 过(或来自上一次 prerender),resource 直接标 PRELOADED,preinit 只发样式内容不发 preload 链接。
  4. precedence(React 19 的样式优先级) :同 precedence 的样式进同一个 StyleQueue,按 data-precedence 控制层叠与去重顺序;这解决了一个经典 SSR 难题------「样式的插入顺序必须与客户端一致,否则 FOUC」。
  5. 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)就是一份检查清单。按出现频率排:

  1. typeof window !== 'undefined' 分支 :服务端走一支、客户端走另一支,结构必然不同。正确做法是把「只关心一端」的部分各自包进 'use client' / 服务器组件,或用 useEffect 推迟到水合后再改。
  2. 非确定性值 :Date.now()、Math.random()、crypto.randomUUID()。服务端渲染一次、客户端水合一次,值不同。解决:值从服务端传下来(props),或放进 useEffect。
  3. locale/时区格式 :toLocaleDateString() 在服务器(UTC)与浏览器(本地时区)输出不同。React 的 DEV 警告专门点了这一条。
  4. 外部数据无快照:首屏 HTML 用了 A 数据,水合时 store 已是 B 数据。
  5. 非法 HTML 嵌套 :<div> 里嵌 <p> 再嵌 <div>,浏览器解析器会「纠正」DOM,React 拿到的是修正后的结构。注意这与 React 无关------浏览器在脚本执行前就重排了。validateDOMNesting(react-dom-bindings/src/client/validateDOMNesting.js)在 DEV 下提前警告。
  6. 浏览器扩展 :扩展往 <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 的经典陷阱

  1. 根级没有 Suspense :flushCompletedQueues 在 pendingRootTasks > 0 时什么都不发(ReactFizzServer.js:6186)。整个页面等最慢的数据 → 流式退化成同步 SSR。最佳实践:<Suspense> 包住「header 以下」的所有内容,根 fallback 保持轻量。
  2. shell 超过 512KB :getBlockingRenderMaxSize 触发 skipBlockingShell,React 会放弃「shell 完整才发」的策略,允许不完整也先画(ReactFizzServer.js:6207)。这通常意味着页面缺边界。
  3. fallback 里做重工作:fallback 是低优先级 task,但如果 fallback 自身同步渲染很重,照样拖慢 shell(它在 shell 之前被同步渲染------prerender/defer 分支)或占满工作循环(正常分支)。
  4. 在渲染中写全局可变状态 :Fizz 的 task 可能重试(retryTask),组件会被调用多次。组件必须幂等------这也是 trackUsedThenable 复用旧 thenable 的前提假设。
  5. onError 返回值的双刃剑 :onError 返回字符串会作为 digest 发给客户端(生产环境用它避免泄漏堆栈);返回 null/undefined 用默认消息。忘记提供 onError 时用 defaultErrorHandler(ReactFizzServer.js:529),它会 console.error 并尝试绑定环境名徽标。
  6. 流中断 :客户端断开 → createCancelHandler(Node)或 cancel(Web)→ stopFlowing + abort。已渲染未发出的内容被丢弃;onBrowserBailout(ReactFizzServer.js:1418)专门上报「浏览器 bailout」事件。
  7. HTTP 压缩中间件与 flush :Node 的 flushBuffered 会调 destination.flush()------zlib 流有这个方法,但某些自定义 Writable 没有,不致命(有 typeof 保护)。

12.10.4 RSC 的坑

  1. 把服务器组件当普通组件 import 进客户端 :'use client' 文件里 import 一个没带指令的模块------如果那个模块是「纯 UI」,bundler 会把它打进客户端 bundle(它变成了客户端模块);如果它 import 了 fs,直接构建失败。边界的传递性(12.6.5)意味着「干净」很重要。
  2. props 传函数 :除了 'use server' 的 action,函数一律抛错(12.7.6)。需要交互就把那一块拆成客户端组件。
  3. 'use client' 放在文件中间 :指令序言必须在模块最顶部(loader 用 node.directive 判定,ReactFlightWebpackNodeLoader.js:704),放在函数体里无效。
  4. 同一个文件两个指令:loader 直接抛「Cannot have both...」(:719)。
  5. 序列化 Symbol / class 实例 / Map 的键 :Map 的 key 也会被序列化(serializeMap 把 entries 转数组再 outline),非可序列化 key 同样炸。
  6. 大小行T/二进制行的混用 :二进制 chunk 与字符串 chunk 不能互换通道(12.7.4 的 processStringChunk 校验)。框架把 Flight 流嵌进 HTML 时通常转成字符串 push(如 Next 的 self.__next_f.push([...])),此时所有 chunk 必须是字符串;二进制数据(TypedArray 行)在嵌 HTML 的场景要用框架提供的 base64 通道或独立 fetch。
  7. 客户端组件里 import server-only :server-only 包(packages/server-only 之类)在客户端构建时报错------这是防「服务器专用代码泄漏进 bundle」的编译期闸门,与 RSC 边界互补。
  8. 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)。拆行带来引用开销(每个 $L 4 字节 + 客户端 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 的交汇点,三个分支:

  1. 首次进入 dehydrated 边界且未水合 (current === null && !wasHydrated 会抛内部错误;正常路径 wasHydrated === true):prepareToHydrateHostSuspenseInstance 认领边界的 comment 节点,bubbleProperties,返回 false------「不要落进普通 Suspense 路径」。
  2. 重入边界继续水合 (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.」)。
  3. 水合成功落穿 (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 进程同时服务多个渲染请求时,有三样东西必须隔离:

  1. useId 的输出 :靠 identifierPrefix(createResumableState 的第一个参数)------每个请求的前缀不同,同树同 hook 生成的 id 就不同。Fizz 的 makeId 与 Flight 的 useId(_S_)都带此前缀。
  2. resolveRequest() 的解析 :Node 上靠 AsyncLocalStorage(requestStorage,ReactServerStreamConfigNode.js 的 supportsRequestStorage)。preinit 之类在渲染外调用的 API 靠它找回 request;AsyncLocalStorage 不可用时(旧 Node)退化为「调用栈上唯一 request」,多请求并发渲染时不能 在渲染外调这些 API------这就是 preinitStyle 里「找不到 request 就静默走 previousDispatcher」的原因。
  3. 模块级单例 :ReactSharedInternals.A(AsyncDispatcher)在 Flight RequestInstance 构造时检查「只能同时一个 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 本章要点小结

  1. 四种渲染形态的演进动力是 FCP/SEO 与「慢数据不阻塞首屏」:CSR → SSR → 流式 SSR → RSC,每一级都在把「等待」从关键路径上挪走。
  2. 入口分流 :react-dom/server = 流式 API(PipeableStream/ReadableStream/prerender/resume);react-dom/server-legacy = renderToString(内部仍是 Fizz + 立即 abort)。
  3. Fizz 抛弃 Fiber ,改用 Task/Segment/SuspenseBoundary 模型边遍历边输出;performWork 跑任务、flushCompletedQueues 按「shell → hoistables → RX→RX → RX→RC → 部分段 → 大边界」五级优先级输出,背压通过 destination=null + drain 重连处理。
  4. Suspense 边界的输出策略 由 isEligibleForOutlining + progressiveChunkSize 决定:小边界内联(零成本),大边界先 fallback 后 $RC 补齐;<!--$-->/<!--$?-->/<!--$!--> 三套注释 + <template> + <div hidden> + 内联 script 指令构成完整的「可寻址 HTML」协议。
  5. hydration 是 commit 的水合模式 :认领而非创建;mismatch 时 ForceClientRender 就近降级、根级降级走 recoverFromConcurrentError;选择性水合用 SelectiveHydrationLane 与事件优先级让「用户交互处」优先水合,事件重放保证不丢交互。
  6. RSC 边界由三层强制 :包导出白名单(react-server 条件的 ReactServer.js)、dispatcher 抛错(ReactFlightHooks.js)、序列化报错(renderModelDestructive)。use client/use server 由 bundler 插件识别(transformModuleIfNeeded 用 AST directive 判定),落地为 registerClientReference/registerServerReference 的引用对象。
  7. Flight 协议 = 行格式流 :id:tag+JSON,$ 前缀表达引用($id/$Lid/$@id/$w/$h/$S/$T/$D/$n...);去重靠 writtenObjects/writtenClientReferences/writtenServerReferences 三张表 + 父引用间接寻址 + MAX_ROW_SIZE=3200 拆行;客户端用字节级状态机(processBinaryChunk)解析,parseModelString 还原引用,createElement 把阻塞元素转 lazy 实现渐进初始化。
  8. RSC + SSR 的合流 :Flight 流既是组件树也是资源清单(:H hint 行),preinitModuleForSSR/preinitScriptForSSR 把 chunk 提前写进 HTML;React 19 的 prerender/resume 通过 PostponedState + ReplayTask 实现「构建期渲染 + 结构核对 + 请求期补齐」。
  9. 资源预加载 统一走 ReactDOMSharedInternals.d dispatcher,三个环境(客户端 DOM/Fizz/Flight)各自实现;Fizz 侧用 resumableState(跨 pass)+ renderState(单次渲染)两级去重,precedence 保证样式顺序一致。
  10. 最大的工程风险 依次是:根级缺 Suspense(流式退化)、hydration mismatch 治理不当、props 序列化越界、'use client' 传递性失控、二进制/字符串 chunk 通道混用。理解本章的源码机制,绝大多数「RSC 玄学问题」都能落到具体的抛错行上。
相关推荐
deli0071 小时前
TCP 三次握手到底发几个包?丢包重传动画实测一遍
前端
盟接之桥1 小时前
盟接之桥谈线束数字化-按单入按单出:线束企业库存管理的破局之道
大数据·前端·网络·数据库·人工智能·制造
巴勒个啦1 小时前
Prompt Engineering 实战:5 个让 AI 生成生产级业务代码的工程模式
java·前端
去伪存真1 小时前
AI时代,古法编程的码农路在何方?
前端
计算机魔术师1 小时前
从7月绕过沙盒到9月再曝违规,OpenAI的AI安全防线在漏什么
前端
Ikalus19881 小时前
gogs 公告说修好了,7 天后有人说还在
开源
我的div丢了肿么办1 小时前
element-plus表格使用:filter-method过滤导致出现无数据
前端·javascript·vue.js
F_Quant2 小时前
高股息+破净+PEG三因子:年化20%的背后,是一个“敢于证伪自己”的捡烟蒂策略
前端
badhope2 小时前
我用MonkeyCode搭了一个照片筛选器,然后发现本地IDE真的不香了
开源