Next.js: Server Components 思维模型
最后更新:2026-08-26
RSC 思维模型是 Next.js 16 最重要的心智转换——理解它,你才能真正掌握 App Router 的设计哲学。
1. 你将学到
- RSC 的定义与"零客户端 JS"的运行机制
- Server Component 与 Client Component 的渲染边界
'use client'指令与客户端边界穿透规则- 组件互嵌规则:Server → Client 可,Client → Server 不可
- Serializable Props 限制与 React Flight Payload 格式
2. 一个全栈开发者的真实故事
(1) 痛点:Bundle Size 失控了
Alice 是 TaskFlow 团队的全栈开发者。她构建的 Dashboard 页面包含一个数据表格组件,引用了 date-fns、recharts 和 lodash 三个库——仅仅为了格式化日期和画几个柱状图。页面初始 JS Bundle 飙到 480 KB,Lighthouse Performance 评分跌到 52 分。更糟糕的是,这个表格只是服务端渲染的纯展示组件,用户根本不需要与它交互,但这些库的代码仍然被下载到了客户端。
(2) RSC 的解法
RSC 让服务端组件只运行在服务端,输出纯 HTML + 序列化数据,JS Bundle 完全排除。
// 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 中,因此可以安全地使用大型库、直接访问数据库、读取文件系统。
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 的核心承诺是:如果一个组件没有交互逻辑,它的代码就不应该发送到浏览器。这意味着:
date-fns、lodash、bcrypt等纯服务端库在 RSC 中使用时,不会增加 Bundle Size- 数据库查询在组件中直接执行,不需要经过 API Route 中转
- RSC 最终输出的是纯 HTML 字符串,浏览器立即渲染
(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 差异(难度⭐⭐)
// 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>
)
}
输出:
构建后检查 .next/static/chunks — 不包含 date-fns 的代码
4. 'use client' 指令与客户端边界
'use client' 是一个模块级指令,标记文件中的组件为 Client Component。当 RSC 组件树中需要交互功能(useState、onClick、useEffect)时,必须在文件顶部添加此指令。
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 组件树中有两条铁律:
- ✅ Server Component 可以导入并渲染 Client Component
- ❌ Client Component 不能直接导入 Server Component(因为 Server Component 只存在于服务端)
// ✅ 正确: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>
}
// ❌ 错误: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 的渲染结果。
// 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(难度⭐⭐)
// 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>
)
}
// 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 限制(难度⭐⭐⭐)
// 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 的结构
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 响应:
# 打开 Network 面板,刷新页面,过滤 Fetch/XHR
# 找到对当前页面的请求,查看 Response
# Content-Type: text/x-component 即为 RSC Payload
输出:
# 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(难度⭐⭐⭐)
// 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>
}
// 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>
)
}
// app/flight-demo/page.tsx
import ClientShell from './ClientShell'
import ServerData from './ServerData'
export default function FlightDemoPage() {
return (
<ClientShell dataSlot={<ServerData />}>
)
}
6. 完整示例:RSC 组件树架构
// 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>
}
❓ 常见问题
📖 小节
- RSC(React Server Component)只运行在服务端,零客户端 JS 开销
'use client'指令标记 Client Component 边界,交互逻辑必须在客户端- Server Component 可导入 Client Component,反之不行(通过 children props 绕过)
- Serializable Props 限制:函数、Date、undefined 不可作为 RSC props 传递
- React Flight Payload 是 RSC 的序列化协议,包含 HTML 片段 + 组件引用 + 数据
- 最佳实践:尽可能使用 Server Component,将交互逻辑隔离到小的 Client 包装器中
📝 作业
-
基础题(⭐):在
app/page.tsx中创建一个 Server Component,直接调用fetch('https://api.github.com/repos/vercel/next.js')获取数据并渲染星标数。验证客户端不下载node-fetch等库。 -
进阶题(⭐⭐):创建一个包含 Client Component(计数器按钮)和 Server Component(用户列表)的页面,通过 children props 传递 Server 数据到客户端包装器中。在浏览器 DevTools Network 中验证 RSC Payload 响应。
-
挑战题(⭐⭐⭐):构建一个三层组件树:Layout(Server)→ ClientTabs(Client, 含
useState管理当前 tab)→ ServerTabContent(通过 children 传递的 Server Component),每个 Tab 内容是不同的异步数据查询。确保所有 Server Component 的数据获取不增加客户端 Bundle。