陌上人如玉
公子世无双

4.12 Server Components 深入

4.12 Server Components 深入

一句话: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)

九、面试延伸

  1. 「Server Components 和 SSR 有什么区别?」
    它们回答不同问题:① RSC 回答「组件在哪里执行」——服务端组件在服务端执行,代码不进客户端包,可以直接访问数据库;客户端组件用 "use client" 标记,在浏览器(以及水合时的服务端)执行。② SSR 回答「HTML 什么时候生成」——每次请求时(SSR)/ 构建时(SSG)/ 静态 + 再生(ISR)。两者可以叠加:App Router 的一次请求先产出 Flight payload(RSC 阶段),再用这份 payload 在服务端渲染出 HTML(SSR 阶段)。

  2. 「Flight 协议是什么?」
    它是 RSC 的传输格式:把「服务端渲染 React 树的结果」序列化成一串行(形如 id:value,用换行分隔),支持流式传输与引用去重。其中:① 普通行是「值」(React 元素用 ["$", type, key, props] 表示);② I 前缀是模块表(注册客户端组件引用,这样 payload 里只需传「模块 id + 导出名」而不传代码);③ T 是服务端动作表(Server Action 的引用);④ H/E 是提示与错误。理解它的动机(引用、流式、去重)比记住字节格式重要。

  3. 「客户端引用(client reference)是什么?为什么重要?」
    当服务端组件 import 一个 'use client' 模块时,拿到的不是函数,而是一个引用对象({ $$typeof: Symbol(react.client.reference), id, name })。服务端把它序列化成 payload 里的模块表条目,客户端用自己的打包产物按 id 找到本地的实现。重要性:组件的代码只存在于客户端包,不会通过 payload 传输——这正是「服务端组件可以引入大依赖而不增大客户端包」的原因(那些依赖在服务端执行完就丢掉了)。

  4. 「哪些 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 对象)。

  5. 「为什么服务端组件能减小客户端包体积?」
    因为服务端组件的代码不会进入客户端 bundle:它在服务端执行完,只把「输出(元素描述)」通过 payload 传下去。所以:① 它 import 的服务端依赖(数据库 SDK、Markdown 解析器、大体积工具库)都不进客户端包;② 传给客户端的只有「它的渲染结果 + 数据」。注意边界:'use client' 模块的代码依然在客户端包里(因为它要在浏览器执行、要水合),所以「减小体积」指的是「服务端那部分」。

  6. 「客户端导航时为什么不请求 HTML?」
    因为 RSC 允许「只传 payload」:客户端 React 拿到新的 payload 后,在客户端重建 DOM(复用已有的 DOM 结构、保留布局与滚动状态)。这样:① 省掉一次服务端渲染(不用再生成 HTML);② 传输量更小(payload 通常比完整 HTML 小);③ 交互更快(没有整页 HTML 解析与替换)。这也是 App Router 客户端导航感觉很快的原因。

  7. 「"use client" 的作用范围是什么?」
    它标记的是「模块图的入口」——从该文件开始,它导入的所有模块都会变成客户端代码(都会进 bundle)。所以:① 不要在高层组件里写 'use client'(那会让整个子树都变成客户端代码,失去 RSC 减少包体积的收益);② 要把边界下沉到「叶子交互组件」。这与「useContext 的 Provider 会影响整棵子树」不同——它是编译期的模块依赖,而不是运行时的树结构。

  8. 「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' 会把它导入的一切变成客户端代码)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.12 Server Components 深入

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫