RSC 心智模型

为什么默认 Server:RSC 载荷是序列化的 UI 描述;每条 'use client' 边界都是一次 bundle 切点与序列化约束;Client 嵌 Server 走 children 槽

为什么默认是 Server Component

客户端 JS 不是默认配置,而是显式 opt-in

  • App Router 里所有组件默认在服务端渲染并可直接预渲染成静态产物; 只有写了"use client"的模块才会进入客户端 bundle。交互能力(state/effect/浏览器 API)是用客户端体积换来的,所以要按叶子粒度申请
  • 服务端渲染的产出物是 RSC 载荷:一种序列化的UI 描述(组件树 + props 的流式格式)。 它不是 HTML(HTML 由它驱动生成),也不是 JSON 数据(它描述的是界面而不是接口返回值); 客户端拿到它后与已有 DOM reconcile,无需重新下载整棵组件树的代码
  • 推论:Server Component 的代码本身永远不会被发送到浏览器—— 数据查询、密钥、重型依赖留在服务端,是它超越「SSR 模板」的根本原因

边界成本:每条 'use client' 都要付两次钱

边界不是免费的分组标记,而是真实的工程开销

  • 一份客户端 bundle 切点:该模块及其 import 链全部进入客户端产物,需要被下载、解析、水合。边界画得越高,切进来的代码越多
  • 一次序列化约束:跨越边界的 props 必须可序列化 (数据可以,函数/类实例/JSX 以外的运行时对象不行); 想传回调,要么改用 Server Actions,要么把交互整体下移
  • 因此边界的正确位置是尽可能靠下的叶子: 页面外壳、数据、静态内容留在 Server,只有真正交互的控件下沉为 Client (实操对照见下方 props-boundary 专题)

children 槽:Client 组件嵌 Server 内容的唯一正确姿势

Client 不能 import Server,但可以接收 Server 渲染好的 children

// ❌ ClientCounter.tsx
"use client";
// Server 组件被 import 进 Client 边界,会被当作 Client 组件处理,
// 整棵子树都被拖进客户端 bundle,RSC 优势清零
import ServerList from "./ServerList";

export function ClientCounter() {
    return (
        <div>
            <ServerList />
        </div>
    );
}
// ✅ page.tsx(Server Component)
import { ClientCounter } from "./ClientCounter";
import ServerList from "./ServerList";

export default function Page() {
    return (
        <ClientCounter>
            {/* 在服务端渲染完毕,以 RSC 载荷形式嵌入 Client 组件的槽位 */}
            <ServerList />
        </ClientCounter>
    );
}

// ✅ ClientCounter.tsx
"use client";
import { useState, type ReactNode } from "react";

// 只声明「我有一个槽」,不关心槽里是什么 —— 槽的内容不进入客户端渲染管线
export function ClientCounter({ children }: { children: ReactNode }) {
    const [n, setN] = useState(0);
    return (
        <div>
            <button onClick={() => setN(n + 1)}>点击 {n} 次</button>
            {children}
        </div>
    );
}

原理:children 对 Client 组件而言是一个「已经渲染好的 RSC 载荷占位」, 它在服务端求值,Client 组件只负责把它放进自己的 DOM 槽位; 而 import 会把模块拉进自己的编译单元,边界随之吞掉整棵子树。

什么时候别把边界往上推

决策要点:先问「谁真的需要交互」,再问边界画在哪

  • 整页标"use client"图省事 = 放弃预渲染 + 全量客户端 bundle + 失去服务端直取数据的能力,三种成本一起付
  • 表单变更不要本能地写 onSubmit + fetch 自己的 API: 站点内部的提交优先 Server Actions,少一次边界序列化、自带渐进增强
  • 判断顺序:这段 UI 需要 state/effect/浏览器 API 吗?不需要就保持 Server; 需要就把那一片叶子单独抽成 Client 组件,而不是提升整页