React Server Components:服务端渲染的革命
第6章:React Server Components:服务端渲染的革命
RSC 不是对现有 SSR 的修补,而是对“组件”这一概念本身的重新定义——组件可以运行在服务器上、永不水合、直接访问后端资源。
本章核心问题:RSC 与传统 SSR 的本质区别是什么?RSC Payload 是什么格式?服务器组件的能力边界在哪里?
读完本章你将理解:
- CSR、SSR、RSC 三种渲染模型的根本差异
- 异步组件(async component)直接在组件体内 await 的机制
- RSC 与客户端组件的组合——甜甜圈模式(Donut Pattern)
Level 1 · 你需要知道的(1-3年经验)
三种渲染模型的本质区别
在 RSC 之前,前端渲染有两种主流范式:
客户端渲染(CSR):服务器只返回一个空的 HTML 壳,浏览器下载 JavaScript bundle,执行 React,生成 DOM,再发起 API 请求获取数据。用户面对的是白屏等待。
传统服务端渲染(SSR):服务器在响应时执行 React,生成完整 HTML 发送给浏览器,用户看到内容的速度更快。但随后浏览器还需要下载同样的 JavaScript,执行"水合"(hydration)过程——React 重新遍历整颗组件树,将事件处理器绑定到已有 DOM 上。这意味着:每个组件的代码都会发送到客户端,无论它是否需要交互。
React Server Components:组件在服务器上执行,但永远不会被水合。RSC 的输出不是 HTML,也不是 JavaScript——而是一种特殊的序列化格式(RSC Payload),描述了渲染结果的虚拟 DOM 树结构,供客户端 React 运行时消费。服务器组件本身的代码永远不会出现在客户端的 JavaScript bundle 中。
这个区别至关重要。一个使用了 lodash、marked、date-fns 的服务器组件,这些库的代码不会出现在浏览器的 network 请求里。
异步组件:直接在组件体内 await
RSC 允许组件函数是 async 的,可以在组件体内直接 await 任何 Promise。这彻底消除了对 useEffect + useState 获取数据的需求。
// app/products/page.tsx
import { db } from '@/lib/db'
// 这个组件在服务器上运行,永远不会发到客户端
export default async function ProductsPage() {
// 直接访问数据库,无需 API 层
const products = await db.query(
'SELECT id, name, price, stock FROM products WHERE active = true ORDER BY created_at DESC'
)
return (
<main>
<h1>产品列表</h1>
<ul>
{products.map(product => (
<li key={product.id}>
<span>{product.name}</span>
<span>¥{product.price}</span>
<span>库存:{product.stock}</span>
</li>
))}
</ul>
</main>
)
}
注意这里没有任何 fetch('/api/products')。数据库连接字符串留在服务器,SQL 查询在服务器执行,结果直接传入 JSX。浏览器的 Network 面板里只会看到页面的 HTML 请求,不会有任何 /api/products 请求。
Level 2 · 它是怎么运行的(3-5年经验)
直接访问文件系统与私密资源
RSC 的服务器特权不止于数据库。你可以直接读取文件系统:
// app/docs/[slug]/page.tsx
import fs from 'fs/promises'
import path from 'path'
import { marked } from 'marked' // 这个库不会进入客户端 bundle
interface Props {
params: Promise<{ slug: string }>
}
export default async function DocPage({ params }: Props) {
const { slug } = await params
const filePath = path.join(process.cwd(), 'docs', `${slug}.md`)
let content: string
try {
const raw = await fs.readFile(filePath, 'utf-8')
content = marked(raw) as string
} catch {
return <div>文档不存在</div>
}
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: content }}
/>
)
}
marked 是一个数十 KB 的 Markdown 解析库。在 CSR 中,它会被打包进客户端 bundle。在 RSC 中,它只在服务器上运行,客户端得到的是已经解析好的 HTML 字符串。
RSC Payload:不是 HTML,不是 JS
RSC 的网络传输格式是 React 团队设计的一种特殊流式格式。打开浏览器开发者工具,查看返回的响应体,你会看到类似这样的内容:
0:["$","main",null,{"children":[["$","h1",null,{"children":"产品列表"}],["$","ul",null,{"children":[["$","li","prod-1",{"children":[...]}]]}]]}]
这是 RSC Payload 的文本表示。它不是 HTML(没有标签闭合,是 JSON 式的树结构),也不是可执行的 JavaScript(它是数据,不是代码)。这种格式有以下特点:
- 可流式传输:服务器可以边渲染边发送,不需要等待整棵树完成
- 可与 HTML 合并:Next.js 会同时发送初始 HTML(用于 SEO 和首屏展示)和 RSC Payload(用于客户端 React 接管)
- 携带客户端组件引用:当 RSC 树中出现客户端组件时,Payload 中包含的是对该组件模块的引用(
"$Lsome-module-id"),而不是组件的渲染结果
服务器组件的能力边界
RSC 不能使用以下 API,使用时会在构建期或运行时报错:
// ❌ 这些在 RSC 中全部不可用
'use client' // 不需要,但也不能用在 RSC 内部逻辑里
import { useState, useEffect, useCallback } from 'react'
// useState、useEffect 等所有 Hooks 依赖客户端运行时
export default function ServerComp() {
const [count, setCount] = useState(0) // ❌ 报错
useEffect(() => { // ❌ 报错
document.title = 'Hello'
}, [])
return <button onClick={() => setCount(c => c + 1)}>{count}</button> // ❌ 事件处理器报错
}
同样不可用的还有:window、document、localStorage、sessionStorage,以及任何仅在浏览器中存在的 API。这是有意为之的设计——RSC 运行在 Node.js 环境(或 Edge Runtime),这些浏览器 API 根本不存在。
Level 3 · 规范怎么定义的(资深)
RSC 与客户端组件的组合:甜甜圈模式
RSC 和客户端组件不是互斥的,它们是互补的。正确的架构是让 RSC 负责数据获取和静态渲染,让客户端组件负责交互。
// components/ProductCard.tsx —— 客户端组件,负责交互
'use client'
import { useState } from 'react'
interface Product {
id: string
name: string
price: number
}
export function ProductCard({ product }: { product: Product }) {
const [added, setAdded] = useState(false)
return (
<div className="card">
<h3>{product.name}</h3>
<p>¥{product.price}</p>
<button
onClick={() => {
// 调用购物车 API
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId: product.id }),
})
setAdded(true)
}}
>
{added ? '已加入购物车' : '加入购物车'}
</button>
</div>
)
}
// app/shop/page.tsx —— 服务器组件,负责数据
import { db } from '@/lib/db'
import { ProductCard } from '@/components/ProductCard'
export default async function ShopPage() {
// 直接查询数据库
const products = await db.product.findMany({
where: { active: true },
select: { id: true, name: true, price: true },
})
return (
<div className="grid">
{products.map(product => (
// 将数据作为 props 传给客户端组件
// product 对象会被序列化进 RSC Payload
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
这就是"甜甜圈模式"(Donut Pattern):外层是服务器组件(甜甜圈圈),中间的交互部分是客户端组件(甜甜圈洞)。服务器组件渲染客户端组件,把数据作为 props 传入。
重要约束:传给客户端组件的 props 必须是可序列化的。不能传函数、不能传类实例、不能传 Promise(特殊情况除外)。只能传基本类型、普通对象、数组。
更深层的组合:children 穿透
有一个更精妙的组合模式:把服务器组件的输出作为 children 传入客户端组件。
// components/ThemeProvider.tsx —— 客户端组件
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext<'light' | 'dark'>('light')
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
切换主题
</button>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// app/layout.tsx —— 服务器组件
import { ThemeProvider } from '@/components/ThemeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* ThemeProvider 是客户端组件,但 children 是服务器组件渲染的 */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}
children 在这里起到了"穿透"的作用。ThemeProvider(客户端组件)持有 children 这个插槽,但它不知道也不关心 children 的内容——那些内容由服务器在更外层渲染好,作为不透明的 RSC Payload 传入。这样,children 里的服务器组件不会因为被放入客户端组件而变成客户端组件。
Network 面板的真实差异
在传统 SSR(无 RSC)的应用中,打开 Network 面板:
- 初始 HTML 请求返回完整页面
- JavaScript bundle 包含所有组件代码(包括服务器端逻辑)
- 后续导航通过
fetch调用 API 获取数据
在 Next.js App Router(RSC)的应用中:
- 初始 HTML 请求返回页面 HTML + 内联的 RSC Payload
- JavaScript bundle 只包含客户端组件的代码
- 客户端导航通过请求新路由的 RSC Payload(而非完整 HTML)实现,URL 变化触发 RSC 数据流请求
你可以在 Next.js 应用的 Network 面板中看到,客户端导航时的请求 URL 形如 /_next/data/... 或带有 ?_rsc=... 参数的请求,返回的是 RSC Payload 而非 HTML。
Level 4 · 边界与陷阱(所有人)
陷阱1:RSC 中不能使用 useState、useEffect 等所有 Hooks——这些依赖客户端运行时,在服务器组件中使用会报错。
陷阱2:传给客户端组件的 props 必须是可序列化的——不能传函数、类实例、Date 对象(需先转字符串)。
陷阱3:RSC 的输出不是 HTML,是 RSC Payload(一种特殊的序列化格式)——不要将 RSC 与传统 SSR 的 renderToString 混淆。
小结
RSC 的本质是一次架构范式的转移:组件不再是纯粹的客户端概念,而是可以运行在服务器上、永不水合、直接访问后端资源的第一类实体。它解决了三个长期困扰 React 应用的问题:不必要的 bundle 体积、数据获取的瀑布流、以及客户端与服务器之间的 API 层样板代码。但它也引入了新的心智模型:序列化约束、组件边界管理、以及对"这段代码在哪里运行"的持续意识。掌握这个模型,是理解 App Router 全部设计决策的前提。