
一句话:Server Components 的本质是「组件在服务端执行,产出的不是 HTML,而是一份可流式传输的『UI 描述』(Flight payload)」——客户端拿到它之后,才把这份描述渲染成 DOM;它与 SSR 回答的是两个不同问题,因此可以叠加使用。
一、先分清三件事:RSC ≠ SSR ≠ SSG
| 问题 | 由谁回答 |
|---|---|
| 组件在哪里执行? | RSC(Server Components) —— 服务端 or 客户端 |
| HTML 什么时候生成? | SSR / SSG / ISR —— 每次请求 / 构建时 / 静态+再生 |
| 数据在哪里取? | 由「数据获取的位置」决定(服务端组件里 await,或客户端用 Query) |
⭐ 一个常见的混淆:
「用了 Next.js App Router 就是 SSR」→ ❌ 不准确。
实际上一次请求会经历「两段」:
① 服务端渲染【React 树 → Flight payload】
· Server Component 在这里执行(可以查数据库)
· 客户端组件不会被执行,只产出一个「引用占位」
→ 产物:一份「UI 描述」(供客户端 React 消费)
② 服务端渲染【Flight payload → HTML】
· 用同样的 payload 在服务端渲染出 HTML(为了首屏可见 + SEO)
→ 产物:HTML(可直接展示)
③ 客户端水合:
· 下载 JS + Flight payload → 用 payload 恢复组件树 → 绑定事件(见 9.4)
⭐ 一句话总结两者关系:
RSC 决定「哪些代码在服务端跑」;
SSR 决定「HTML 什么时候生成」。
→ App Router 里它们【一起工作】:先出 payload,再由 payload 出 HTML。
二、Flight 协议:RSC 的传输格式
⭐ 如果我们把「Server Component 的输出」打印出来,它长这样(示意):
1:I{"id":"./components/like-button.tsx","chunks":["chunk-abc"],"name":"","async":false}
2:["$","div",null,{"className":"post","children":[
["$","h1",null,{"children":"Hello"}],
["$","$L1",null,{"postId":"42","initialCount":7}]
]}]
3:"一段服务端时才有的数据"
⭐ 逐行解读:
· 每行形如「行号:值」,用 \n 分隔(所以叫「流式」——可以边生成边发)
· 第 1 行是【模块表】:把一个客户端组件模块注册成「引用 id=1」
(这样后面就能用 "$L1" 指向它,而不需要把它的代码序列化过去)
· 第 2 行是【UI 描述】:数组形式 ["$", type, key, props]
· "$" 是「React 元素」的标记
· type 可以是字符串('div')、null(Text)、或引用("$L1" = 客户端组件)
· 第 3 行是普通值(服务端组件可以直接把字符串/数字/对象放进 payload)
⭐ 为什么要「行号 + 引用」?
因为要支持:
① 【流式】——行可以「后到」(先发 shell,再补数据)
② 【去重】——同一个对象/模块被多处引用时只传一次
③ 【循环引用】——用引用而不是嵌套,才能表达循环结构
⭐⭐ 三类特殊前缀(Flight 的「控制行」):
| 前缀 | 含义 | 用途 |
| --- | --- | --- |
| `I` | **模块表**(module import) | 注册客户端组件的引用(client reference) |
| `T` | 服务端模块表 | 注册服务端动作的引用(server reference,供 Server Action 用) |
| `H` | **hint**(提示) | 传输「表单状态/错误」等非 UI 信息(如 Action 的返回值) |
| `E` | **error** | 传输错误(供客户端抛给 Error Boundary) |
⚠️ 这些是「实现细节」,会随版本变化。
理解它的【设计动机】(引用、流式、去重)比记住字节格式重要得多。
⭐ 可观察的验证方式(在真实项目里就能看到):
① Next.js 里打开 DevTools 的 Network,找一个形如
`?_rsc=xxxx` 的请求(或带 `RSC: 1` 请求头的请求)
② 看它的 Response —— 就是一串「行号:JSON」
③ 客户端导航时,React 消费的就是这份 payload(不是 HTML)
→ 这就是「客户端导航不需要整页 HTML」的原因
三、客户端引用(client reference)与 "use client"
// components/like-button.tsx
'use client'; // ⭐ 这一行让这个【模块】变成「客户端模块」
export function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// app/post.tsx(Server Component)
import { LikeButton } from '@/components/like-button'; // ⚠️ 导入的是「引用」,不是代码
export default async function Post({ id }) {
const post = await db.post.findUnique({ where: { id } });
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} initialCount={post.likes} />
{/* ⭐ 服务端只输出「引用 + props」,LikeButton 的代码【不进 payload】 */}
</article>
);
}
⭐⭐ 客户端引用的本质(这是 RSC 能「减小包体积」的关键):
在服务端组件里 import 一个 'use client' 模块时,得到的不是「函数」,
而是一个【引用对象】(client reference):
{
$$typeof: Symbol(react.client.reference),
id: "./components/like-button.tsx", // 模块标识
name: "LikeButton", // 导出名
}
· 服务端渲染时,它被序列化成 payload 里的一行「模块表」条目(如 1:I{...})
· 客户端在【自己的打包产物】里,按这个 id 找到「本地的实现」
· 于是:**组件的代码只存在于客户端包里,不会通过 payload 传输**
→ 这就是「服务端组件可以引入大依赖而不增大客户端包」的原因:
因为服务端组件的代码在服务端执行完就丢掉了,只有「它的输出」被传下去。
⭐ 三条由此推出的规则:
① 【客户端的代码不会变少】
'use client' 模块的代码最终还是要在浏览器里执行(要水合)→ 依然在客户端包里
RSC 减少的是「服务端组件(含它 import 的服务端依赖)的体积」
② 【props 必须可序列化】
引用只传「id + props」,props 要能过 Flight 序列化 → 函数/类实例/Map 等不行
③ 【模块 id 要能被客户端解析】
打包器要「把服务端模块 id 映射到客户端的 chunk」
→ 这就是打包器与 RSC 必须深度集成(Flight 与 webpack/turbopack 的 plugin)的原因
// ⭐ 一个「容易踩」的实例:服务端组件传入「服务端专有对象」
export default async function Page() {
const dbClient = getDbClient(); // 有方法的类实例
return <ClientThing client={dbClient} />; // ❌ 无法序列化(类实例)
}
// ✅ 只传「纯数据」
const data = await dbClient.findMany(); // 纯对象数组
return <ClientThing data={data.map(({ id, name }) => ({ id, name }))} />;
四、可序列化边界:什么能传、什么不能
✅ 可以从 Server Component 传给 Client Component:
· string / number / boolean / null / undefined
· 数组、纯对象(只含可序列化值)
· Date(会被特判)、Map/Set(部分支持,视版本)
· Promise(客户端可以用 use() 消费 —— React 19)
· Server Reference(服务端函数,即 Server Action)
· Client Reference('use client' 组件的元素)
· React 元素(可以「把服务端渲染的元素作为 props 传给客户端组件」)
❌ 不能传:
· 普通函数(不是 Server Action 的那种)
· 类实例(含方法、getter 以外的私有状态)
· Symbol(作为值)
· DOM 节点
· 循环引用(引用机制能表达,但普通对象序列化要注意)
· 「服务端专属对象」(DB 连接、请求对象等)
// ⭐⭐ 一个「非常有用的技巧」:把「服务端渲染的元素」作为 props 传给客户端组件
// ClientTabs.tsx
'use client';
export function ClientTabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [active, setActive] = useState(0);
return (
<>
<div role="tablist">
{tabs.map((t, i) => (
<button key={t.label} role="tab" aria-selected={i === active} onClick={() => setActive(i)}>
{t.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[active]?.content}</div> {/* ⭐ content 是服务端渲染的元素 */}
</>
);
}
// page.tsx(Server Component)
import { ClientTabs } from './client-tabs';
export default async function Page() {
const [profile, orders] = await Promise.all([getProfile(), getOrders()]);
return (
<ClientTabs
tabs={[
{ label: '资料', content: <ProfileCard data={profile} /> }, // ⭐ 服务端组件(不进客户端包)
{ label: '订单', content: <OrderTable rows={orders} /> },
]}
/>
);
}
// 效果:切换 Tab 的状态在客户端(交互),但「内容」是服务端渲染好的
五、完整实现:一个 mini Flight(200 行)
下面的实现不是 React 的正式格式,而是「用最少的代码复现 Flight 的核心思想」:行号协议、模块表、引用、流式追加。它可以直接跑起来,帮助理解「payload 是怎么被消费的」。
// mini-flight.ts —— 教学版的 Flight 序列化/反序列化实现
// ⚠️ 说明:这是【简化演示】,不是 React 官方格式;目的是演示「引用 + 行号 + 流式」三个机制
type ClientReference = { $$typeof: 'client.reference'; id: string; name: string };
type ServerReference = { $$typeof: 'server.reference'; id: string; name: string };
/** ============ 1. 引用注册:让「服务端/客户端模块」能被引用而不是被内联 ============ */
export function createClientReference(id: string, name: string): ClientReference {
return { $$typeof: 'client.reference', id, name };
}
export function createServerReference(id: string, name: string): ServerReference {
return { $$typeof: 'server.reference', id, name };
}
/** ============ 2. 编码器:把值序列化成「行」 ============ */
export class FlightEncoder {
private chunks: string[] = [];
/** 已写过的「引用 id → 行号」,用于去重 */
private written = new Map<string, number>();
/** 模块表:行号 → 模块引用 */
private moduleTable = new Map<string, string>();
private nextId = 0;
/** 分配一个新的行号(用字符串,支持流式追加时用 "1a" 这种) */
private allocId(): string {
return String(this.nextId++);
}
private write(id: string, payload: object): void {
this.chunks.push(`${id}:${JSON.stringify(payload)}`);
}
/** 写一个客户端模块引用(第一次写会产出 I 行 = 模块表) */
private writeClientReference(ref: ClientReference): string {
const key = `client:${ref.id}#${ref.name}`;
const found = this.written.get(key);
if (found !== undefined) return `$L${found}`; // ⭐ 复用引用
const id = this.allocId();
// ⭐ 模块表行:用 I 前缀(与 React 的思路一致)
this.chunks.push(`I${id}:${JSON.stringify({ id: ref.id, name: ref.name })}`);
this.written.set(key, Number(id));
return `$L${id}`;
}
/** 序列化一个 React 元素 */
private encodeElement(el: MiniElement): unknown {
const type =
typeof el.type === 'string'
? el.type
: el.type && (el.type as ClientReference).$$typeof === 'client.reference'
? this.writeClientReference(el.type as ClientReference) // ⭐ 输出引用
: (() => { throw new Error('Unsupported element type'); })();
return ['$', type, el.key, this.encodeProps(el.props)];
}
private encodeProps(props: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
if (k === 'children') {
out.children = this.encodeValue(v);
} else {
out[k] = this.encodeValue(v);
}
}
return out;
}
private encodeValue(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
if (typeof value === 'function') {
throw new Error('普通函数无法序列化:请改用 Server Action(server reference)');
}
return value; // string/number/boolean/undefined
}
if (Array.isArray(value)) return value.map((v) => this.encodeValue(v));
const asAny = value as { $$typeof?: string };
if (asAny.$$typeof === 'client.reference') return this.writeClientReference(value as ClientReference);
if (asAny.$$typeof === 'server.reference') {
const ref = value as ServerReference;
const id = this.allocId();
this.chunks.push(`T${id}:${JSON.stringify({ id: ref.id, name: ref.name })}`); // 服务端动作表
return `$F${id}`;
}
if (asAny.$$typeof === 'element' || (asAny as MiniElement).type !== undefined) {
return this.encodeElement(value as MiniElement); // 嵌套元素
}
if (value instanceof Date) return `$D${value.toISOString()}`; // 日期特判
const plain: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
plain[k] = this.encodeValue(v);
}
return plain;
}
/** 写根节点(返回「根行号」) */
encodeRoot(root: MiniElement): { payload: string; rootId: string } {
const data = this.encodeElement(root);
const rootId = this.allocId();
this.write(rootId, data as unknown as object);
return { payload: this.chunks.join('\n') + '\n', rootId };
}
/** ⭐ 流式追加:在原有 payload 之后继续写(用于「慢的部分后到」) */
append(id: string, value: unknown): string {
this.write(id, this.encodeValue(value) as object);
return this.chunks[this.chunks.length - 1]! + '\n';
}
}
/** ============ 3. 解码器:把「行」还原成可消费的树 ============ */
export interface MiniElement {
$$typeof: 'element';
type: string | ClientReference;
key: string | null;
props: Record<string, unknown>;
}
export class FlightDecoder {
private rows = new Map<string, unknown>();
private modules = new Map<string, ClientReference>();
private serverRefs = new Map<string, ServerReference>();
private rootId: string | null = null;
/** 逐行解析(支持「多次 push」= 流式) */
push(chunk: string): void {
for (const line of chunk.split('\n')) {
if (!line) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const rawId = line.slice(0, colon);
const body = line.slice(colon + 1);
// ⭐ 识别「控制行」:I = 模块表 / T = 服务端动作表 / H = hint / E = error
if (rawId.startsWith('I')) {
this.modules.set(rawId.slice(1), JSON.parse(body) as ClientReference);
continue;
}
if (rawId.startsWith('T')) {
this.serverRefs.set(rawId.slice(1), JSON.parse(body) as ServerReference);
continue;
}
if (rawId.startsWith('E')) {
throw new Error(`服务端错误:${body}`);
}
this.rows.set(rawId, JSON.parse(body));
if (this.rootId === null) this.rootId = rawId;
}
}
/** 把「引用字符串」或「普通值」还原 */
private revive(value: unknown): unknown {
if (typeof value === 'string') {
if (value.startsWith('$L')) {
// ⭐ 客户端组件引用 → 在客户端用自己的实现替换(这里用「占位函数」演示)
const ref = this.modules.get(value.slice(2))!;
return makePlaceholder(ref);
}
if (value.startsWith('$F')) {
const ref = this.serverRefs.get(value.slice(2))!;
return (...args: unknown[]) => callServerAction(ref, args); // ⭐ 变成「调用服务端」的函数
}
if (value.startsWith('$D')) return new Date(value.slice(2));
return value;
}
if (Array.isArray(value)) {
if (value[0] === '$') {
// ⭐ React 元素:["$", type, key, props]
const [, type, key, props] = value as [string, unknown, string | null, Record<string, unknown>];
return {
$$typeof: 'element',
type: this.revive(type) as string | ClientReference,
key,
props: this.mapProps(props),
} satisfies MiniElement;
}
return value.map((v) => this.revive(v));
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = this.revive(v);
return out;
}
return value;
}
private mapProps(props: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) out[k] = this.revive(v);
return out;
}
/** 取根节点 */
getRoot(): MiniElement {
if (this.rootId === null) throw new Error('还没有收到根节点(payload 未就绪)');
return this.revive(this.rows.get(this.rootId)) as MiniElement;
}
}
/** 客户端侧:把「客户端引用」变成真实组件(真实实现里是查本地模块表) */
function makePlaceholder(ref: ClientReference) {
const Comp = (props: Record<string, unknown>) => ({ $$typeof: 'element', type: 'div', key: null, props });
(Comp as unknown as { displayName: string }).displayName = `${ref.id}#${ref.name}`;
return Comp;
}
/** 调用服务端动作(真实实现里发一个 POST) */
async function callServerAction(ref: ServerReference, args: unknown[]): Promise<unknown> {
const res = await fetch(`/_action?id=${encodeURIComponent(ref.id)}&name=${encodeURIComponent(ref.name)}`, {
method: 'POST',
body: JSON.stringify(args),
});
return res.json();
}
// ✅ 用它跑一遍:服务端
import { FlightEncoder, createClientReference } from './mini-flight';
const LikeButton = createClientReference('./components/like-button.tsx', 'LikeButton');
const tree = {
$$typeof: 'element' as const,
type: 'div',
key: null,
props: {
children: [
{ $$typeof: 'element' as const, type: 'h1', key: null, props: { children: 'Hello' } },
{ $$typeof: 'element' as const, type: LikeButton, key: null, props: { postId: '42', count: 7 } },
],
},
};
const encoder = new FlightEncoder();
const { payload } = encoder.encodeRoot(tree);
console.log('=== 服务端产出的 payload ===');
console.log(payload);
// 输出(示意):
// I0:{"id":"./components/like-button.tsx","name":"LikeButton"}
// 1:["$","div",null,{"children":[["$","h1",null,{"children":"Hello"}],["$","$L0",null,{"postId":"42","count":7}]]}]
// ✅ 客户端:消费 payload
import { FlightDecoder } from './mini-flight';
const decoder = new FlightDecoder();
decoder.push(payload); // 可以「分多次 push」(流式)
const root = decoder.getRoot();
console.log(root);
// → { $$typeof: 'element', type: 'div', props: { children: [ ... ] } }
// type 为 LikeButton 的那个元素,type 变成「客户端本地的组件」
⭐⭐ 这个 mini 实现复现的三个核心机制:
① 【引用而不是内联】
LikeButton 的「代码」没有被序列化,只传了「模块 id + 导出名」
→ 客户端用自己的实现替换它(这就是「客户端包不含服务端代码」的原理)
② 【行号协议】
每行形如 `id:value`,因此可以「按需/流式」传输(先发 1,再发 3、5…)
③ 【控制行】
`I`(模块表)、`T`(服务端动作表)、`E`(错误)走不同的前缀
→ 让「数据」与「元信息」在同一条流里传输
六、两种渲染产物:payload 与 HTML
⭐ 一次 App Router 请求会产出【两份不同的东西】(分两个阶段):
阶段 ①:React 树 → Flight payload
· Server Component 执行(查库、读 cookie…)
· 客户端组件只产出「引用 + props」
· 产物:一串「行号:JSON」
阶段 ②:Flight payload → HTML
· React 用同一份 payload 在服务端渲染出 HTML(含 Suspense 边界)
· 产物:HTML(首屏可见 + 可被爬虫读取)
阶段 ③:客户端水合
· 下载 JS(含客户端组件代码)
· 下载/复用 Flight payload(导航时是「只拿 payload」,不拿 HTML)
· 用 payload 恢复组件树 → 绑定事件
| 场景 | 传输内容 | 为什么要这样 |
|---|---|---|
| 首次进入(硬导航) | HTML + Flight payload + JS | HTML 负责「立刻可见 + SEO」,payload 负责「水合时恢复组件树」 |
| 客户端导航(软导航) | 只有 Flight payload + 需要的 JS chunk | 不需要重新生成 HTML(DOM 由 React 在客户端重建),省一次服务端渲染与一次 HTML 传输 |
⭐⭐ 这个表格解释了两件常被问到的事:
① 「为什么 App Router 的客户端导航很快?」
→ 因为不重新请求 HTML,只请求 payload(数据量小得多),
且 DOM 由客户端 React 更新(保留布局与滚动状态)
② 「为什么 RSC 能让首屏更快?」
→ 因为服务端组件的代码不在客户端包里(下载更少),
而且数据在服务端就取好了(没有客户端请求瀑布,见 9.5)
七、边界划分:怎么写才对
⭐⭐ 四条划分原则:
① 【边界尽量下沉】
'use client' 是「模块图入口」——它导入的所有模块都会变客户端代码(见 9.3)
→ 只在「真正需要交互的叶子组件」上标它
② 【数据获取留在服务端】
能在服务端 await 的,就不要扔给客户端(避免瀑布 + 减少 JS)
③ 【服务端组件优先于「服务端 + 客户端 props 传数据」】
如果一个组件只是「展示数据」,它就该是服务端组件
❌ 服务端取数据 → 传给客户端组件 → 客户端组件只负责渲染
✅ 服务端组件自己取数据、自己渲染
④ 【把「交互」与「数据」拆开】
典型结构:服务端组件负责取数据 + 渲染骨架,客户端组件只包住「需要交互的那一小块」
// ✅ 一个「正确的边界划分」示例
// app/posts/[id]/page.tsx(Server)
import { LikeButton } from './like-button'; // Client(只有它进客户端包)
import { CommentSection } from './comment-section'; // Client(有表单)
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id); // ⭐ 服务端取数据
const comments = await getComments(id);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} /> {/* 正文(服务端渲染) */}
<LikeButton postId={id} initialCount={post.likes} /> {/* 交互 */}
<CommentSection postId={id} initialComments={comments} /> {/* 交互 + 初始数据 */}
{/* ⭐ 传给客户端组件的 initialComments 只是为了「首屏就绪」,
之后的新评论由客户端自己请求 */}
</article>
);
}
⭐ 关于「初始数据要不要传给客户端组件」的取舍:
· 传:首屏就有数据(不闪骨架),但数据会「序列化进 payload」(体积增加)
· 不传:客户端自己请求(多一次往返、会闪骨架),但 payload 更小
✅ 经验法则:
· 「首屏必须展示」的数据 → 传(或更好:让它成为服务端组件的一部分)
· 「交互后才需要」的数据(下一页评论、筛选结果)→ 客户端自己请求
八、本篇特有的坑
// ① 把「普通函数」当 props 传给客户端组件
<ClientThing onSubmit={save} /> // ❌ 若 save 不是 Server Action 则无法序列化
// ✅ 用 Server Action('use server')或把回调逻辑放进客户端组件
// ② 传「类实例 / 带方法的对象」
<ClientThing api={apiClient} /> // ❌
// ✅ 只传纯数据;客户端需要能力时,用 Server Action 或客户端自己 import
// ③ 在服务端组件里「用客户端 API」
export default function Page() {
const [x, setX] = useState(0); // ❌ 服务端组件不能有状态
const w = window.innerWidth; // ❌ 服务端没有 window
}
// ④ 'use client' 标在「太高的位置」→ 整个子树变客户端代码
'use client'; // ⚠️ 放在 page.tsx / layout.tsx 顶部
// ⑤ 在客户端组件里「import 服务端组件」
'use client';
import { ServerThing } from './server-thing'; // ❌ 它会被打进客户端包(并且可能报错)
// ⑥ 把「数据库查询」写在客户端组件里
'use client';
const data = await db.query(); // ❌ 客户端没有 db
// ⑦ 用「服务端组件」做「需要频繁交互」的 UI
// ❌ 每次交互都要「往返服务端」(因为服务端组件只能在服务端执行)
// ✅ 交互部分抽成客户端组件
// ⑧ 忘记「payload 也要传输」→ 传了巨大对象给客户端组件
<ClientThing data={hugeObject} /> // ⚠️ 序列化进 payload → 传输与解析成本
// ⑨ 在服务端组件里「读 request 对象并把它传给客户端」
req.headers // ❌ 不可序列化(且不该泄漏)
// ⑩ 「Server Action 不做鉴权」
export async function deletePost(fd: FormData) {
await db.post.delete({ where: { id: fd.get('id') } }); // ❌ 公开端点,必须鉴权
}
// ⑪ 把 Server Action 当作「万能后端」——在里面读 cookie 之外的「客户端可信数据」
const userId = fd.get('userId'); // ❌ 客户端可伪造
// ✅ 从 session/cookie 读当前用户
// ⑫ 认为「RSC 可以减少客户端代码总量」
// ⚠️ 只减少「服务端组件与其服务端依赖」的量;
// 'use client' 模块的代码依然在客户端包里(要水合)
// ⑬ 用「服务端组件 + 客户端组件」的边界「跨越」Context
// ⚠️ Context 只在「客户端树」里有效;服务端组件不能读客户端 Context
// ✅ 把需要 Context 的部分放进同一个客户端子树
// ⑭ 在服务端组件里「依赖请求级状态」却没意识到「可能被静态化」
// ⚠️ 读 cookies()/headers() 会让路由变成动态渲染(见 9.2 的「让整页变动态」的坑)
// ⑮ 用「生成随机值/时间」渲染服务端组件 → 水合不匹配(见 9.4)
九、面试延伸
- 「Server Components 和 SSR 有什么区别?」
它们回答不同问题:① RSC 回答「组件在哪里执行」——服务端组件在服务端执行,代码不进客户端包,可以直接访问数据库;客户端组件用"use client"标记,在浏览器(以及水合时的服务端)执行。② SSR 回答「HTML 什么时候生成」——每次请求时(SSR)/ 构建时(SSG)/ 静态 + 再生(ISR)。两者可以叠加:App Router 的一次请求先产出 Flight payload(RSC 阶段),再用这份 payload 在服务端渲染出 HTML(SSR 阶段)。 -
「Flight 协议是什么?」
它是 RSC 的传输格式:把「服务端渲染 React 树的结果」序列化成一串行(形如id:value,用换行分隔),支持流式传输与引用去重。其中:① 普通行是「值」(React 元素用["$", type, key, props]表示);②I前缀是模块表(注册客户端组件引用,这样 payload 里只需传「模块 id + 导出名」而不传代码);③T是服务端动作表(Server Action 的引用);④H/E是提示与错误。理解它的动机(引用、流式、去重)比记住字节格式重要。 -
「客户端引用(client reference)是什么?为什么重要?」
当服务端组件import一个'use client'模块时,拿到的不是函数,而是一个引用对象({ $$typeof: Symbol(react.client.reference), id, name })。服务端把它序列化成 payload 里的模块表条目,客户端用自己的打包产物按 id 找到本地的实现。重要性:组件的代码只存在于客户端包,不会通过 payload 传输——这正是「服务端组件可以引入大依赖而不增大客户端包」的原因(那些依赖在服务端执行完就丢掉了)。 -
「哪些 props 能从服务端组件传给客户端组件?」
可序列化的值:string/number/boolean/null/undefined、数组、纯对象、Date、Promise(React 19,用use()消费)、Server Reference(Server Action)、Client Reference('use client'组件的元素)、以及React 元素(可以把服务端渲染好的元素作为 props 传下去,这是一个很有用的技巧)。不能传:普通函数、类实例、Symbol、DOM 节点、服务端专有对象(DB 连接、request 对象)。 -
「为什么服务端组件能减小客户端包体积?」
因为服务端组件的代码不会进入客户端 bundle:它在服务端执行完,只把「输出(元素描述)」通过 payload 传下去。所以:① 它 import 的服务端依赖(数据库 SDK、Markdown 解析器、大体积工具库)都不进客户端包;② 传给客户端的只有「它的渲染结果 + 数据」。注意边界:'use client'模块的代码依然在客户端包里(因为它要在浏览器执行、要水合),所以「减小体积」指的是「服务端那部分」。 -
「客户端导航时为什么不请求 HTML?」
因为 RSC 允许「只传 payload」:客户端 React 拿到新的 payload 后,在客户端重建 DOM(复用已有的 DOM 结构、保留布局与滚动状态)。这样:① 省掉一次服务端渲染(不用再生成 HTML);② 传输量更小(payload 通常比完整 HTML 小);③ 交互更快(没有整页 HTML 解析与替换)。这也是 App Router 客户端导航感觉很快的原因。 -
「
"use client"的作用范围是什么?」
它标记的是「模块图的入口」——从该文件开始,它导入的所有模块都会变成客户端代码(都会进 bundle)。所以:① 不要在高层组件里写'use client'(那会让整个子树都变成客户端代码,失去 RSC 减少包体积的收益);② 要把边界下沉到「叶子交互组件」。这与「useContext的 Provider 会影响整棵子树」不同——它是编译期的模块依赖,而不是运行时的树结构。 -
「RSC 有什么代价?」
四点:① 心智模型复杂——要理解「两种组件、边界在哪、什么能传什么不能传」;② 模块 id 与打包器深度耦合——需要 webpack/Turbopack 的插件支持(这也是 RSC 目前主要依赖 Next.js 这类框架的原因);③ 调试更复杂——两层运行时、错误可能跨边界、堆栈需要 source map 与「server/client」标记才能定位;④ 生态适配成本——很多库是「客户端假设」的(依赖useState/useEffect/window),需要在边界上包装或改造成本。
一句话速记
RSC 决定「组件在哪执行」,SSR 决定「HTML 何时生成」(两者可叠加);一次 App Router 请求分两段:① React 树 → Flight payload(服务端组件执行、客户端组件只产出「引用 + props」),② payload → HTML(首屏可见 + SEO);客户端导航只传 payload(省一次服务端渲染,DOM 由客户端重建);Flight 是一串「行号:JSON」(
["$", type, key, props]表示元素),用I模块表注册客户端引用、T注册 Server Action、H/E传提示与错误,从而支持流式 + 引用去重;客户端引用让组件代码只存在于客户端包(所以服务端组件引入大依赖不增大客户端包,但'use client'模块的代码依然在包里);props 必须可序列化(可传纯数据、Promise、Server Action、Client Reference、React 元素;不可传函数、类实例、服务端对象);边界要下沉('use client'会把它导入的一切变成客户端代码)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!