学完这篇,你能建立 React 服务端组件(RSC)的正确心智模型,并在 Next.js 13+(App Router)里把「服务端取数」和「客户端交互」分得明明白白,不再为一个 `useState` 报错卡半天。
第一步:先搞清 RSC 和 SSR 不是一回事
这是最容易混的一点:
- SSR 说的是「什么时候渲染」——在服务端把组件渲染成 HTML 再发给浏览器。
- RSC 说的是「组件在哪运行」——服务端组件只在服务器上执行,代码不进浏览器 JS 包。
传统 SSR 页面,组件代码最终还是会打包进 bundle 到浏览器「注水」;RSC 则是直接不发货。所以在服务端组件里 `import fs from 'fs'`、直连数据库都是安全的,这些代码永远不会流到用户机器上。
第二步:建一个 App Router 项目
npx create-next-app@latest my-app --typescript --app --tailwind
cd my-app
npm run dev
关键入口:
- `app/layout.tsx`:根布局,默认是服务端组件
- `app/page.tsx`:首页,同样是服务端组件
- 只要文件放在 `app/` 下、且没有特殊标记,它就是服务端组件——服务端是默认值,客户端才需要显式声明。
第三步:写服务端组件,直接 await 拿数据
`app/posts/page.tsx`:
export default async function Page() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return (
<ul>
{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}
</ul>
)
}
组件函数可以直接是 `async`,可以直接 `await`,不需要 `useEffect` + `useState` + loading 状态三件套。
注意:服务端组件里不能用 `useState`、`useEffect`、`onClick` 这类东西。不是"不推荐",是根本不支持——它们依赖浏览器运行时。
第四步:用 'use client' 划出交互边界
`components/LikeButton.tsx`:
'use client'
import { useState } from 'react'
export default function LikeButton({ initial }: { initial: number }) {
const [n, setN] = useState(initial)
return <button onClick={() => setN(n + 1)}>赞 {n}</button>
}
然后在服务端组件里直接引用它:
import LikeButton from '@/components/LikeButton'
export default async function Page() {
const post = await getPost()
return <LikeButton initial={post.likes} />
}
服务端组件渲染成 HTML,客户端组件在需要交互的地方接管。
注意:`'use client'` 标的是模块边界,不是单个组件。一旦某个文件加了它,这个文件里 import 的所有组件都会被拖进客户端包。所以务必把 `'use client'` 下沉到叶子节点(按钮、表单、下拉框),别写在页面顶层。
注意:从服务端组件传给客户端组件的 props 必须可序列化。函数、`Date`(部分场景)、类实例、Symbol 都传不过去,会直接报错。
第五步:用 Server Actions 写数据
在服务端组件或单独文件里定义:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.insert({ title })
revalidatePath('/posts')
}
表单直接绑定:
<form action={createPost}>
<input name="title" />
<button type="submit">发布</button>
</form>
不用写 API Route,不用手写 `fetch('/api/xxx')`,也不用 `e.preventDefault()`。
注意:Server Actions 是公开的 HTTP 端点别名,必须在函数内部自己校验权限和登录态,不能靠"前端不显示按钮"来当安全措施。
第六步:搞懂缓存与重新验证
这是 Next.js 版本差异最大、最容易踩坑的地方:
- Next.js 13/14:`fetch` 默认缓存,需要 `{ cache: 'no-store' }` 才实时。
- Next.js 15 起:`fetch` 默认不缓存,需要主动 `{ cache: 'force-cache' }` 或 `next: { revalidate: 60 }` 才缓存。
常用手段:
fetch(url, { next: { revalidate: 60, tags: ['posts'] } })
改完数据后调用 `revalidateTag('posts')` 或 `revalidatePath('/posts')` 让页面刷新。
注意:升级 Next.js 大版本时,一定要检查取数处的缓存行为有没有翻转,这类 bug 表现为"数据一直不更新"或"数据不刷新",排查很费时间。
第七步:常见报错对照表
| 报错 | 原因 | 处理 |
|---|
| `useState only works in Client Components` | 在服务端组件里用了 Hook | 抽到单独文件加 `'use client'` |
| `Event handlers cannot be passed to Client Component props` | 把 `onClick` 传给了服务端组件 | 交互逻辑留在客户端组件内部 |
| `Only plain objects can be passed` | props 不可序列化 | 只传字符串/数字/数组/普通对象 |
| 构建时提示模块找不到 | 在客户端组件里 import 了 `fs`、数据库 | 加 `server-only` 包,或把逻辑挪回服务端组件 |
小结
- 服务端组件是 `app/` 下的默认形态,可以直接 `async/await` 取数,代码不进客户端包。
- 客户端组件靠文件顶部的 `'use client'` 声明,只用来放交互,尽量下沉到叶子。
- Server Actions 用 `'use server'` 声明,写数据不用再建 API 路由,但权限校验必须自己做。
- 缓存行为在 Next.js 13/14 与 15 之间是反的,升级务必复查取数代码。
- props 必须可序列化,这是服务端与客户端之间唯一的通道。