Next.js: Server Components 思维模型

最后更新:2026-08-26

RSC 思维模型是 Next.js 16 最重要的心智转换——理解它,你才能真正掌握 App Router 的设计哲学。

1. 你将学到


2. 一个全栈开发者的真实故事

(1) 痛点:Bundle Size 失控了

Alice 是 TaskFlow 团队的全栈开发者。她构建的 Dashboard 页面包含一个数据表格组件,引用了 date-fnsrechartslodash 三个库——仅仅为了格式化日期和画几个柱状图。页面初始 JS Bundle 飙到 480 KB,Lighthouse Performance 评分跌到 52 分。更糟糕的是,这个表格只是服务端渲染的纯展示组件,用户根本不需要与它交互,但这些库的代码仍然被下载到了客户端。

(2) RSC 的解法

RSC 让服务端组件只运行在服务端,输出纯 HTML + 序列化数据,JS Bundle 完全排除。

TSX
// app/dashboard/page.tsx — Server Component(零客户端 JS)
import { getSalesData } from '@/lib/db'
import { formatDistanceToNow } from 'date-fns'

export default async function DashboardPage() {
  const sales = await getSalesData()  // 直接访问数据库
  return (
    <div>
      <h1>Dashboard — {sales.length} records</h1>
      <SalesTable data={sales} />
    </div>
  )
}

async function SalesTable({ data }: { data: Sale[] }) {
  return (
    <table>
      {data.map(row => (
        <tr key={row.id}>
          <td>{formatDistanceToNow(row.createdAt)}</td>
          <td>{row.amount}</td>
        </tr>
      ))}
    </table>
  )
}

(3) 收益

维度 纯 Client Component RSC
Bundle Size 480 KB(含 date-fns + recharts + lodash) 0 KB(服务端库不下载)
数据库访问 需 API Route 中转 直接访问(零延迟)
首屏渲染 需 JS 下载 + 执行 即时 HTML
SEO 依赖 SSR / 客户端渲染 原生支持
Lighthouse 评分 52 96

3. RSC 的基本定义

RSC(React Server Component)是 React 19 引入的新组件类型,它只运行在服务端,永远不会发送到客户端浏览器。RSC 的代码(包括依赖的库)不会出现在 JS Bundle 中,因此可以安全地使用大型库、直接访问数据库、读取文件系统。

100%
graph TB
    subgraph "服务端 (Server)"
        A[RSC 组件] --> B[数据库/文件系统/API]
        A --> C[序列化为 RSC Payload<br/>React Flight 协议]
    end
    subgraph "客户端 (Browser)"
        D[RSC Payload] --> E[Client Component<br/>保留交互逻辑]
        D --> F[纯 HTML 渲染<br/>零 JS 开销]
    end
    C --> D
    style A fill:#d4edda
    style D fill:#cce5ff
特性 Server Component Client Component
运行位置 服务端(Node.js) 浏览器
JS Bundle ❌ 不包含 ✅ 包含
数据库/文件系统 ✅ 直接访问 ❌ 不可(需 API)
React Hooks(useState/useEffect) ❌ 不可用 ✅ 可用
事件处理(onClick/onSubmit) ❌ 不可用 ✅ 可用
Async/Await ✅ 原生支持 ❌ 需额外处理

(1) "零客户端 JS" 的含义

RSC 的核心承诺是:如果一个组件没有交互逻辑,它的代码就不应该发送到浏览器。这意味着:

(2) RSC vs 传统 SSR

传统 SSR(Pages Router)也会在服务端渲染 HTML,但仍然会把组件的 JS 代码发送到客户端用于 Hydration。RSC 则完全不同——服务端组件的代码永远不会到达客户端

维度 传统 SSR(Pages Router) RSC(App Router)
服务端渲染 ✅ HTML ✅ HTML
客户端 Hydration ✅ 全量 ❌ 无需
组件代码发送到客户端 ✅ 全部 ❌ 仅 Client Component
状态保持 需要小心处理 天然无状态
数据获取时机 getServerSideProps 组件内直接 await

▶ 示例:验证 Bundle Size 差异(难度⭐⭐)

TSX
// app/bundle-demo/page.tsx — Server Component(纯服务端)
import { format, addDays } from 'date-fns'

export default function BundleDemoPage() {
  const today = new Date()
  const dates = Array.from({ length: 7 }, (_, i) => {
    const d = addDays(today, i)
    return { label: format(d, 'EEEE'), date: format(d, 'yyyy-MM-dd') }
  })

  return (
    <div>
      <h1>This Week</h1>
      <ul>{dates.map(d => <li key={d.date}>{d.label}: {d.date}</li>)}</ul>
    </div>
  )
}

输出:

TEXT 📖 仅展示
构建后检查 .next/static/chunks — 不包含 date-fns 的代码

4. 'use client' 指令与客户端边界

'use client' 是一个模块级指令,标记文件中的组件为 Client Component。当 RSC 组件树中需要交互功能(useStateonClickuseEffect)时,必须在文件顶部添加此指令。

100%
graph TB
    A[Root Layout<br/>Server Component] --> B[NavBar<br/>Server Component]
    A --> C[DashboardPage<br/>Server Component]
    C --> D[SalesChart<br/>'use client']
    C --> E[DataTable<br/>Server Component]
    D --> F[交互逻辑<br/>useState / useEffect]

    style A fill:#d4edda
    style B fill:#d4edda
    style C fill:#d4edda
    style D fill:#cce5ff
    style E fill:#d4edda
指令 作用 示例
'use client' 标记模块为 Client Component 'use client'; export default function Btn() { ... }
'use server' 标记函数为 Server Action 'use server'; export async function create() { ... }

(1) 边界穿透规则

RSC 组件树中有两条铁律:

TSX
// ✅ 正确:Server Component 导入 Client Component
// app/page.tsx (Server)
import ClientCounter from './ClientCounter'
export default function Page() {
  return <ClientCounter />
}

// app/ClientCounter.tsx (Client)
'use client'
import { useState } from 'react'
export default function ClientCounter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
TSX
// ❌ 错误:Client Component 无法直接导入 Server Component
// app/ClientList.tsx
'use client'
import ServerItem from './ServerItem'  // ❌ 编译错误:Server Component 不可在客户端导入

export default function ClientList() {
  return <ServerItem />  // 这行会报错
}

(2) 如何将 Server Component 嵌入 Client Component

技巧:通过 children props 传递——Client Component 的 children 槽可以接收 Server Component 的渲染结果。

TSX
// app/layout.tsx (Server)
import ClientShell from './ClientShell'
import ServerSidebar from './ServerSidebar'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <ClientShell sidebar={<ServerSidebar />}>
      {children}
    </ClientShell>
  )
}

▶ 示例:边界穿透的正确模式 — Children Props(难度⭐⭐)

TSX
// app/interleaving/ClientWrapper.tsx
'use client'
import { useState } from 'react'

export default function ClientWrapper({ sidebar, children }: {
  sidebar: React.ReactNode
  children: React.ReactNode
}) {
  const [isOpen, setIsOpen] = useState(true)
  return (
    <div style={{ display: 'flex' }}>
      {isOpen && <aside>{sidebar}</aside>}
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      <main>{children}</main>
    </div>
  )
}
TSX
// app/interleaving/page.tsx (Server)
import ClientWrapper from './ClientWrapper'
import { getSidebarData } from '@/lib/db'

export default function InterleavingPage() {
  const items = getSidebarData() // 服务端获取数据
  return (
    <ClientWrapper sidebar={<ServerItemList items={items} />}>
      <h1>Main Content</h1>
    </ClientWrapper>
  )
}

async function ServerItemList({ items }: { items: string[] }) {
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>
}

▶ 示例:Serializable Props 限制(难度⭐⭐⭐)

TSX
// app/serializable/page.tsx (Server)
function greet() { return 'hello' }  // ❌ 函数不可序列化
const date = new Date()              // ⚠️ Date 不可作为 RSC Props

// app/serializable/ClientComponent.tsx
'use client'
export default function ClientComponent(props: {
  fn: () => string       // ❌ 函数作为 props → 运行时错误
  date: Date             // ⚠️ Date → 被转为 string 可能丢失时区
  data: { name: string } // ✅ 普通对象可以
}) {
  return <div>{props.data.name}</div>
}

5. React Flight Payload 与组件树合并

RSC 服务端渲染完成后,输出一种称为 RSC Payload(React Flight 协议)的特殊数据格式,包含序列化后的 HTML 树 + 组件引用 + Props 数据。客户端接收到 Payload 后,与本地 Client Component 合并,形成最终的组件树。

(1) RSC Payload 的结构

100%
sequenceDiagram
    participant Server as Next.js Server
    participant Client as Browser

    Server->>Server: 执行 RSC 组件树
    Server->>Server: 序列化为 React Flight Payload
    Server->>Client: 发送 RSC Payload + HTML
    Client->>Client: 解析 Flight Payload
    Client->>Client: 合并 Client Component(Hydrate)
    Client->>Client: 渲染最终界面
组成部分 说明 示例
Server Component 输出 序列化的 HTML 片段
<h1>Dashboard</h1>
Client Component 引用 模块 ID + Props {id: "./chart.js", props: {data: [...]}}
数据引用 数据库查询结果 {sales: [{id:1, amount: 100}]}
Stream 流 Suspense 边界拆分 多个 chunk 逐步发送

▶ 示例:查看 RSC Payload(难度⭐⭐)

在浏览器开发者工具的 Network 面板中查看 RSC 响应:

BASH
# 打开 Network 面板,刷新页面,过滤 Fetch/XHR
# 找到对当前页面的请求,查看 Response
# Content-Type: text/x-component 即为 RSC Payload

输出:

TEXT 📖 仅展示
# RSC Payload 片段(简化):
M1:{"id":"./app/page.tsx","chunks":["app/page-abc123.js"]}
J0:["$","div",null,{"children":["$","h1",null,{"children":"Dashboard"}]}]
S1:"react.suspense"

▶ 示例:Client Component 如何引用 Server Component(难度⭐⭐⭐)

TSX
// app/flight-demo/ServerData.tsx — 纯服务端数据组件
export default async function ServerData() {
  const data = await fetch('https://api.example.com/data').then(r => r.json())
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}
TSX
// app/flight-demo/ClientShell.tsx
'use client'
export default function ClientShell({ dataSlot }: { dataSlot: React.ReactNode }) {
  return (
    <div className="card">
      <h2>Client Shell</h2>
      <div className="server-data">{dataSlot}</div>
    </div>
  )
}
TSX
// app/flight-demo/page.tsx
import ClientShell from './ClientShell'
import ServerData from './ServerData'

export default function FlightDemoPage() {
  return (
    <ClientShell dataSlot={<ServerData />}>
  )
}

6. 完整示例:RSC 组件树架构

TSX
// app/rsc-architecture/layout.tsx — RSC 布局
import ClientShell from './ClientShell'
import { getUser } from '@/lib/auth'

export default async function RscLayout({ children }: { children: React.ReactNode }) {
  const user = await getUser()                    // ✅ 直接数据库查询
  return (
    <ClientShell username={user?.name ?? 'Guest'}>
      <nav>
        <a href="/">Home</a>
        <a href="/dashboard">Dashboard</a>
        <a href="/settings">Settings</a>
      </nav>
      {children}
    </ClientShell>
  )
}

// app/rsc-architecture/ClientShell.tsx
'use client'
import { useState } from 'react'
import type { ReactNode } from 'react'

export default function ClientShell({ username, children }: {
  username: string
  children: ReactNode
}) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <div data-theme={theme}>
      <header>
        <span>Welcome, {username}</span>
        <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
          Toggle {theme}
        </button>
      </header>
      {children}
    </div>
  )
}

// app/rsc-architecture/page.tsx
import { getProjects } from '@/lib/db'

export default async function RscArchitecturePage() {
  const projects = await getProjects()
  return (
    <div>
      <h1>Projects ({projects.length})</h1>
      <table>
        <thead><tr><th>Name</th><th>Status</th></tr></thead>
        <tbody>
          {projects.map(p => (
            <tr key={p.id}>
              <td>{p.name}</td>
              <td><StatusBadge status={p.status} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    active: '#4caf50', archived: '#9e9e9e', draft: '#ff9800'
  }
  return <span style={{ background: colors[status] ?? '#ccc', padding: '2px 8px', borderRadius: 4 }}>{status}</span>
}

❓ 常见问题

Q RSC 和 SSR 是一回事吗?
A 不是。传统 SSR 在服务端渲染 HTML 后仍会把 JS 代码发送到客户端(Hydration)。RSC 的 Server Component 代码完全不会发送到客户端——零 JS 开销。SSR + RSC 可以共存(App Router 的默认模式是 RSC + SSR 结合)。
Q 'use client' 文件中的所有组件都是客户端组件吗?
A 是的。'use client' 是模块级别的指令——一个文件中所有导出的组件都是 Client Component。建议将交互组件拆分到单独的文件,减少客户端代码量。
Q 为什么 Client Component 不能直接导入 Server Component?
A 因为 Server Component 只存在于服务端运行时。Client Component 在浏览器中执行时,Server Component 的代码根本不存在。正确的模式是通过 children props 或 Server Actions 来桥接。
Q 函数作为 props 传递给 Client Component 会怎样?
A 会报错。RSC Payload 基于 JSON 序列化(React Flight 协议),函数无法被序列化。如果需要传递回调,应该使用 Server Actions 或事件处理模式。
Q 如何判断一个组件应该是 Server 还是 Client?
A 最简原则:如果组件需要交互(useState、useEffect、onClick、浏览器 API),就是 Client;否则优先用 Server。一个常见的优化策略是:把交互部分提取到小的 Client 包装器中,主体保持为 Server Component。
Q RSC Payload 与 HTML 是什么关系?
A Next.js 16 会同时发送 HTML(用于 Instant Loading)和 RSC Payload(用于组件树重建)。HTML 让首屏立即展示,RSC Payload 在客户端解析后接管交互。两者协作实现"即时首屏 + 完整交互"。

📖 小节


📝 作业

  1. 基础题(⭐):在 app/page.tsx 中创建一个 Server Component,直接调用 fetch('https://api.github.com/repos/vercel/next.js') 获取数据并渲染星标数。验证客户端不下载 node-fetch 等库。

  2. 进阶题(⭐⭐):创建一个包含 Client Component(计数器按钮)和 Server Component(用户列表)的页面,通过 children props 传递 Server 数据到客户端包装器中。在浏览器 DevTools Network 中验证 RSC Payload 响应。

  3. 挑战题(⭐⭐⭐):构建一个三层组件树:Layout(Server)→ ClientTabs(Client, 含 useState 管理当前 tab)→ ServerTabContent(通过 children 传递的 Server Component),每个 Tab 内容是不同的异步数据查询。确保所有 Server Component 的数据获取不增加客户端 Bundle。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏