起因
第一次看到 React Server Components(RSC)的介绍很懵:
"在服务端渲染的组件"——这不就是 SSR 吗?为什么搞个新东西?
直到我真的写了一个 RSC 项目(Next.js 14 app router)才明白它解的
是另一个问题:让组件能直接访问后端资源(DB / 文件 / API),
零 JS bundle 开销。
传统 SSR 的局限
传统 SSR(getServerSideProps / loader):
// pages/posts/[id].js (传统 Next.js pages router)
export async function getServerSideProps({ params }) {
const post = await db.posts.findById(params.id)
const author = await db.users.findById(post.author_id)
const comments = await db.comments.findByPost(params.id)
return { props: { post, author, comments } }
}
export default function Page({ post, author, comments }) {
return (
<article>
<h1>{post.title}</h1>
<AuthorBlock user={author} />
<CommentList items={comments} />
</article>
)
}
问题:
- 数据获取集中在一个函数里,组件需要数据时要 prop drill 传
- 整个组件树都 hydrate(即使大部分不需要交互)
- 所有数据 JSON serialize 进 HTML,包大
<AuthorBlock>和<CommentList>各自需要的数据要 page 这层先取
好再传
RSC 的做法
// app/posts/[id]/page.tsx (Next.js 14 app router)
// 默认就是 Server Component!
import { db } from '@/db'
import AuthorBlock from './AuthorBlock'
import CommentList from './CommentList'
export default async function Page({ params }: { params: { id: string }}) {
const post = await db.posts.findById(params.id)
return (
<article>
<h1>{post.title}</h1>
<AuthorBlock userId={post.author_id} />
<CommentList postId={params.id} />
</article>
)
}
// app/posts/[id]/AuthorBlock.tsx
// 也是 Server Component,自己取数据
import { db } from '@/db'
export default async function AuthorBlock({ userId }: { userId: string }) {
const user = await db.users.findById(userId)
return (
<div>
<img src={user.avatar} alt="" />
<strong>{user.name}</strong>
</div>
)
}
// app/posts/[id]/CommentList.tsx
import { db } from '@/db'
export default async function CommentList({ postId }: { postId: string }) {
const comments = await db.comments.findByPost(postId)
return (
<ul>
{comments.map(c => <li key={c.id}>{c.body}</li>)}
</ul>
)
}
变化:
- 组件就是 async 函数,直接 await DB / API
- 每个组件管自己的数据获取
- 没有 prop drilling
- 这些组件的 JS 完全不进客户端 bundle(因为它们只在服务端跑)
客户端组件交互("use client")
需要 useState / 事件 / 浏览器 API 的组件加 "use client":
// app/posts/[id]/LikeButton.tsx
"use client"
import { useState } from 'react'
export default function LikeButton({ postId, initialCount }: ...) {
const [count, setCount] = useState(initialCount)
return (
<button onClick={() => {
setCount(c => c + 1)
fetch(`/api/posts/${postId}/like`, { method: 'POST' })
}}>
❤️ {count}
</button>
)
}
// app/posts/[id]/page.tsx (server component)
import LikeButton from './LikeButton'
export default async function Page({ params }: ...) {
const post = await db.posts.findById(params.id)
return (
<>
<h1>{post.title}</h1>
<LikeButton postId={post.id} initialCount={post.likes} />
</>
)
}
LikeButton 是 client component,JS 进 bundle、可以 useState。
其它 server component 完全不进 bundle。
实际收益
我们的项目(中等规模博客):
| 传统 SSR | RSC | |
|---|---|---|
| 首页 JS bundle | 280 KB | 60 KB |
| First Contentful Paint | 1.8s | 0.9s |
| LCP | 2.4s | 1.4s |
| 代码可读性 | 数据 / UI 分离明显 | 数据在用它的组件里 |
bundle 显著小(因为大部分组件不进客户端),首屏快得多。
什么时候用 client component
"use client" 的场景:
useState/useEffect等 React hooks- onClick / onChange 等事件
- 浏览器 API(localStorage / window / etc)
- 用了 client-only 库(如 framer-motion / chart.js)
- Context provider(虽然 server component 也能 consume)
其它一律 server component。原则:"静态展示 → server;交互 → client"。
数据获取最佳实践
1. 直接 DB / ORM
import { prisma } from '@/lib/db'
export default async function Page() {
const posts = await prisma.post.findMany({ take: 20 })
return <PostList posts={posts} />
}
不再需要 REST / GraphQL 层。component → ORM → DB。
2. 并行获取
export default async function Page({ params }: ...) {
const [post, author] = await Promise.all([
db.posts.findById(params.id),
db.users.findById(params.userId),
])
return ...
}
避免 await 串行让请求慢。
3. Streaming + Suspense
import { Suspense } from 'react'
export default function Page({ params }: ...) {
return (
<>
<h1>Article</h1>
<Suspense fallback={<div>loading post...</div>}>
<PostBody id={params.id} />
</Suspense>
<Suspense fallback={<div>loading comments...</div>}>
<CommentList postId={params.id} />
</Suspense>
</>
)
}
server 先 stream 出 <h1> + fallback,post / comments 各自异步加载完
就 stream 出真实 DOM 替换 fallback。用户看到 "渐进显示" 而不是
"全部等好再显示"。
Server Actions:从 client 调 server 函数
// app/posts/[id]/page.tsx
async function deletePost(postId: string) {
'use server'
await db.posts.delete(postId)
revalidatePath('/posts')
}
export default function Page({ params }: ...) {
return (
<form action={async () => { 'use server'; deletePost(params.id) }}>
<button>删除</button>
</form>
)
}
不需要写 /api/posts/:id/delete endpoint。直接调函数,Next.js 自动
hook 成 RPC。
与传统 SSR / SPA 共存
不是非黑即白。常见混合:
- 营销页 / 文章 / 列表 → server component
- 仪表盘 / 复杂表单 / 实时聊天 → client component
- Next.js app router 默认 server,按需
"use client"
效果
- 首屏体验大幅改善(bundle 减少)
- 数据 / UI 不再分离,组件代码更聚合
- 不需要专门维护 REST API 给前端用(直接调 ORM)
- 但要熟悉"哪些代码跑服务端 / 哪些跑客户端" 心智模型
踩过的坑
-
在 server component 里用 useState → 编译报错。新人最常见的
错。要么加 "use client",要么把状态下移到 client 子组件。 -
import 服务端库进 client component:bundle 暴涨 + 可能泄漏
secret。import { db } from '@/db'在 client component 里就是
重大失误。lint 规则强制检查。 -
server / client 边界传 props 必须 serializable:函数 / Map /
class 实例传不过去。只能传 plain object / 数组 / 基础类型。 -
revalidate / cache 复杂:Next.js 默认激进 cache,dev / prod
行为差异大。明确用cache: 'no-store'/revalidate: 60/cookies()。 -
Vercel / 自托管差异:Server Actions 等功能在自托管 Next.js
还要配 standalone build。Vercel 上很丝滑,自部署要折腾。
登录后参与评论。