上一章用客户端组件实现了博客的增删改查:"use client" 开头,useEffect 发请求,useState 管状态,等数据回来再渲染。这一章用服务端组件做同样的事,代码量更少,行为却更好。
示例代码:codes
运行方式:
cd codes
npm install
npm run dev打开 http://localhost:3000/blogs 查看博客列表。
- 服务端组件是什么
- async 组件与直接 await 数据
- await params:服务端获取路由参数
- Server Action:在服务端处理表单
- defaultValue:非受控表单的预填充
- loading.tsx:真正的 Suspense 边界
- error.tsx:错误边界
- notFound:404 处理
- 服务端 fetch 必须用绝对路径
- 与客户端组件的对比
React Server Components(RSC)是 React 18(2022年)引入的新概念,在 React 19 中进一步完善。它代表了 React 团队对"组件应该在哪里运行"这个问题的重新思考。
在 RSC 之前,React 的服务端渲染(SSR)是这样工作的:组件在服务器上执行一次、生成 HTML,发给浏览器;然后浏览器下载同一份组件代码,再执行一次、"接管"页面(这个过程叫 hydration)。本质上,所有组件代码还是要发给浏览器。
RSC 做了一个根本性的切割:有些组件只在服务器上运行,永远不发给浏览器。它们可以直接访问数据库、文件系统、环境变量,执行完就扔掉,客户端 bundle 里没有它们的任何代码。
这个想法很激进。React 团队在 2023 年把它稳定下来,并选择以 Next.js App Router 作为第一个正式落地的场景——Next.js 13(App Router)是第一个将服务端组件作为默认模式的框架。
其他框架的态度相对保守。Remix(现在叫 React Router v7)选择用 loader 函数做服务端数据加载,组件本身仍然是客户端概念;Nuxt(Vue 生态)和 SvelteKit 也有服务端渲染,但它们用的是各自的 server/ 目录或 load 函数,而不是把"组件默认在服务端"这件事推到语言层面。相比之下,React + Next.js 的路子更彻底:组件默认就是服务端的,想要客户端行为才需要显式标注。
这也是为什么第三章会让你觉得"反直觉":你写了 "use client",感觉像是在申请一个特殊权限,而不是在写普通的 React。
服务端组件在服务器上执行,生成结果后发给浏览器。和客户端组件有几个根本性的不同:
- 可以直接
await:组件函数可以是async,可以在函数体里等待数据 - 没有 hooks:
useState、useEffect、useParams在服务端组件里不能用 - 不发 JS 到浏览器:组件代码只在服务器上运行,不会打包进客户端 bundle
Next.js 里所有组件默认都是服务端组件,只有显式加上 "use client" 才变成客户端组件。
第三章的客户端组件有一个固定套路:先渲染一个空壳,useEffect 在挂载后才开始请求数据,数据回来了再触发重渲染。服务端组件完全不需要这套流程:组件函数本身就是 async 的,直接 await 数据,拿到结果才渲染,一步到位:
// app/blogs/page.tsx
import { BASE_URL } from "@/lib/config";
type Blog = { id: number; title: string; content: string; author: string; date: string };
export default async function BlogsPage() {
const res = await fetch(`${BASE_URL}/api/blogs`, { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load blogs");
const blogs: Blog[] = await res.json();
return (
<ul>
{blogs.map((blog) => (
<li key={blog.id}>{blog.title}</li>
))}
</ul>
);
}没有 useState,没有 useEffect,没有 loading 状态,没有 "use client"。组件函数加上 async,数据直接在函数体里 await,拿到数据后直接渲染。
cache: "no-store" 告诉 Next.js 每次请求都重新获取数据,相当于完全关闭缓存。Next.js 在 fetch 上内置了一套缓存机制,可以细粒度地控制哪些数据缓存、缓存多久、什么时候失效——这是服务端渲染性能优化的重要手段,我们会在后续章节专门介绍。这里为了简化教学,统一用 no-store 跳过缓存,确保每次看到的都是最新数据。
第三章客户端组件用 useParams() hook 读取动态路由参数。服务端组件没有 hook,参数通过组件 props 传入。在 Next.js 16 里,params 是一个 Promise,需要 await:
// app/blogs/[id]/page.tsx
export default async function BlogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await fetch(`${BASE_URL}/api/blogs/${id}`, { cache: "no-store" });
// ...
}对比第三章:
// 第三章:客户端组件
const { id } = useParams();
// 第四章:服务端组件
const { id } = await params;第三章的表单用 onSubmit + useState + fetch——这套流程发生在浏览器里。服务端组件里没有事件处理,表单提交用的是 Server Action。
Server Action 是一个带 "use server" 指令的 async 函数。把它传给 <form action={...}>,浏览器提交表单时,Next.js 会在服务器上执行这个函数:
// app/blogs/add/page.tsx
import { redirect } from "next/navigation";
import { BASE_URL } from "@/lib/config";
async function createAction(formData: FormData) {
"use server";
await fetch(`${BASE_URL}/api/blogs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: formData.get("title"),
content: formData.get("content"),
author: formData.get("author"),
date: formData.get("date"),
}),
});
redirect("/blogs");
}
export default function AddBlogPage() {
return (
<form action={createAction}>
<input name="title" required />
{/* ... */}
<button type="submit">Create Blog</button>
</form>
);
}几个要点:
"use server" 写在函数体第一行,不是文件顶部。这是函数级别的指令,告诉 Next.js 这个函数只在服务器上运行。
参数是 FormData,不是 JSON。formData.get("title") 读取表单字段,字段名对应 input 的 name 属性。
redirect() 来自 next/navigation,在 Server Action 里调用,提交成功后跳转页面。注意这里不是 router.push()——那是客户端的 hook,服务端没有。
删除操作同样用 Server Action,配合隐藏字段传递 id:
// app/blogs/[id]/page.tsx
async function deleteAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await fetch(`${BASE_URL}/api/blogs/${id}`, { method: "DELETE" });
redirect("/blogs");
}
// 在组件里:
<form action={deleteAction}>
<input type="hidden" name="id" value={blog.id} />
<button type="submit">Delete</button>
</form>;第三章的编辑表单是受控组件:value={form.title} + onChange 把 React state 和输入框绑定在一起。服务端组件没有 state,也没有事件处理,所以用 defaultValue 设置初始值:
// 第三章:受控组件
<input name="title" value={form.title} onChange={handleChange} />
// 第四章:非受控组件,只设初始值
// app/blogs/[id]/edit/page.tsx
<input name="title" defaultValue={blog.title} required />defaultValue 告诉浏览器这个 input 的初始内容,用户之后可以自由修改,React 不再介入。提交时,FormData 自动收集所有带 name 属性的字段值。
第三章的每个页面都要手动维护一个 loading state,在数据还没回来时返回 loading UI:
if (loading) return <p>Loading...</p>;服务端组件不需要这样,loading.tsx 会自动接管。
loading.tsx 的触发机制是 React Suspense。当一个组件在渲染过程中挂起(suspend)——也就是说它在等待某个异步操作完成、暂时无法渲染——React 就会显示最近的 Suspense fallback,也就是 loading.tsx 的内容。
服务端组件的 await fetch(...) 正是这种挂起:组件开始渲染,遇到 await,挂起,Next.js 把 loading.tsx 发给浏览器先显示,等数据回来后再把真正的页面内容推过去。
app/blogs/
loading.tsx ← 覆盖 /blogs 及其所有子路由(/blogs/[id]、/blogs/add 等)
error.tsx
page.tsx
[id]/
page.tsx ← 没有自己的 loading.tsx,向上继承 blogs/loading.tsx
loading.tsx 向下覆盖所有子路由,除非子路由自己定义了一个。本章只在 blogs/ 下放了一个,/blogs/[id] 和 /blogs/[id]/edit 都共用它。
一个容易踩的坑:loading.tsx 只管"导航到页面"这件事,不管页面上的后续操作。点击 Delete 触发 Server Action,执行过程中 loading.tsx 不会出现,因为Server Action 是表单提交,不是页面导航,Suspense 对它没有感知。
如果想在 Server Action 期间也自动调用 loading 状态,则需要用到 useActionState hook,下一章会介绍。
error.tsx 捕获它所在路由段(及子路由)里渲染期间抛出的错误。服务端组件里,throw new Error(...) 会被最近的 error.tsx 接住:
// 服务端组件里直接 throw
// app/blogs/[id]/page.tsx
if (!res.ok) throw new Error("Failed to load blogs");// blogs/error.tsx
"use client"; // error.tsx 必须是客户端组件
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}error.tsx 本身通常是客户端组件("use client")。原因是它需要响应用户交互(reset 按钮点击),以及在客户端重新渲染时也能捕获错误。
reset 函数由 Next.js 注入,调用后会尝试重新渲染出错的路由段。
如何测试 error.tsx:app/api/blogs/[id]/route.ts 的 GET 方法里预留了一行被注释掉的模拟错误,取消注释即可让获取单篇博客的请求直接抛出异常:
// app/api/blogs/[id]/route.ts
export async function GET(_req: Request, ctx: RouteContext<"/api/blogs/[id]">) {
// 取消下面这行注释,任何请求都会抛错
throw new Error("Simulated server error");
// ...
}打开任意博客详情页,error.tsx 就会显示出来。观察完效果后把这行重新注释掉即可。
资源不存在时,用 notFound() 而不是 throw new Error。它触发 Next.js 内置的 404 页面,比通用错误页面更语义化:
// app/blogs/[id]/page.tsx
if (res.status === 404) notFound(); // → 显示 404 页面
if (!res.ok) throw new Error("Failed to load blog"); // → 显示 error.tsx客户端组件里 fetch("/api/blogs") 可以用相对路径,因为浏览器知道当前域名。服务端组件运行在 Node.js 环境,没有"当前域名"的概念,必须用完整 URL:
// lib/config.ts
export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";// 服务端组件里
const res = await fetch(`${BASE_URL}/api/blogs`);本地开发默认 http://localhost:3000。部署到生产时,设置环境变量 BASE_URL=https://your-domain.com 即可。
做同一件事,两种写法:
| 第三章(客户端组件) | 第四章(服务端组件) | |
|---|---|---|
| 文件声明 | "use client" |
无(默认) |
| 组件函数 | function Page() |
async function Page() |
| 路由参数 | useParams() |
await params |
| 数据获取 | useEffect + useState |
函数体直接 await fetch |
| Loading 状态 | 手动 if (loading) return ... |
loading.tsx 自动接管 |
| 错误状态 | 手动 if (error) return ... |
error.tsx 自动接管,throw 触发 |
| 表单提交 | onSubmit + router.push() |
Server Action + redirect() |
| 表单输入 | value + onChange(受控) |
defaultValue(非受控) |
| 提交期间 loading | submitting state |
需要 useFormStatus(第五章) |