本册涵盖:03 数据获取与四层缓存 · 04 Server Actions 与表单 · 05 状态管理与 UI 体系
03 - 数据获取与四层缓存
目标:把"数据从哪里来、什么时候新鲜、怎么失效"这件事讲透。Next 14 的缓存语义是上手最容易翻车的地方,本章给你一套可直接抄的策略。
1. 数据获取三大流派
1.1 Server Component 直接 async
typescript
async function getUser(id: string) {
const res = await fetch(`${process.env.API_URL}/users/${id}`, {
headers: { authorization: `Bearer ${getServerToken()}` },
next: { revalidate: 60, tags: [`user:${id}`] },
});
if (!res.ok) throw new Error("API error");
return res.json();
}
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id);
return <div>{user.name}</div>;
}
1.2 并行获取
❌ 串行:
typescript
const user = await getUser(); // 等
const posts = await getPosts(); // 再等
✅ 并行:
typescript
const [user, posts] = await Promise.all([getUser(), getPosts()]);
✅ 更优雅 ------ Suspense 流式渲染:
typescript
export default function Page() {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserBlock />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsBlock />
</Suspense>
</>
);
}
async function UserBlock() {
const user = await getUser();
return <h1>{user.name}</h1>;
}
async function PostsBlock() {
const posts = await getPosts();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
💡 流式 SSR 原理 Next 14+ 在 server 端 render 时遇到 Suspense 边界就先输出 fallback,实际数据拿到后再"补丁式"流到浏览器。用户能立刻看到页面骨架,慢的部分慢慢补上,体验质变。
1.3 Client 端获取(TanStack Query)
互动多、需要乐观更新、轮询的场景仍然在 client。
typescript
"use client";
import { useQuery } from "@tanstack/react-query";
export function NotificationsList() {
const { data } = useQuery({
queryKey: ["notifications"],
queryFn: () => api.get("/notifications").then(r => r.data),
staleTime: 30_000,
refetchInterval: 60_000,
});
return <ul>{data?.map(...)}</ul>;
}
💡 何时 server-fetch 何时 client-fetch?
- 首屏可见、对 SEO 有价值、不依赖用户交互 → Server
- 大量交互、缓存复杂、需要轮询/订阅、个性化高 → Client + TanStack Query
- 混合:Server 初始数据 + Client 增量,通过
initialData或 HydrationBoundary 把 server 数据"种"到 query cache
1.4 Server prefetch → Client hydrate
typescript
// app/users/page.tsx
import { dehydrate, QueryClient, HydrationBoundary } from "@tanstack/react-query";
import { UsersListClient } from "./users-list-client";
export default async function Page() {
const qc = new QueryClient();
await qc.prefetchQuery({
queryKey: ["users"],
queryFn: getUsers,
});
return (
<HydrationBoundary state={dehydrate(qc)}>
<UsersListClient />
</HydrationBoundary>
);
}
客户端组件用 useQuery({ queryKey: ["users"], ... }) 直接拿到 server 已 prefetch 的数据,无需二次请求;后续刷新/失效仍走 TanStack Query。
2. Next.js 的四层缓存
理解缓存语义是用好 App Router 的关键 。Next 14 的缓存有四层,分开记:
2.1 Request Memoization
- 范围:一次请求内
- 机制:同一次 RSC 渲染期间,相同 fetch URL + options 自动去重
- 失效:请求结束自然失效
- 控制:无(自动)
typescript
// 在同一个 RSC 树里被调两次,只会真发一次
async function getUser(id: string) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
2.2 Data Cache(fetch 缓存)
- 范围:跨请求,服务器持久(部署期间)
- 机制 :
fetch(url, { next: { revalidate, tags } }) - 失效 :
revalidate时间到 / 显式revalidateTag()/revalidatePath() - 关闭 :
fetch(url, { cache: "no-store" })或next: { revalidate: 0 }
2.3 Full Route Cache
- 范围:静态路由
- 机制:build 时(或第一次访问时)render 好 HTML/RSC payload
- 失效 :重新构建 / 调用
revalidatePath/ 路由被标 dynamic - 关闭 :
export const dynamic = "force-dynamic"
2.4 Router Cache(客户端)
- 范围:浏览器,会话内
- 机制:Next 路由器缓存"上次访问过的段"的 RSC payload,前进/后退秒级
- 失效 :
router.refresh()/ Action 后自动 / 30s-5min 过期 - 关闭:不能完全关,但可缩短 TTL
3. 决策矩阵(直接抄)
| 场景 | 怎么做 |
|---|---|
| 公开博客文章列表,每小时更新 | fetch(url, { next: { revalidate: 3600, tags: ["posts"] } }) |
| 用户私人数据,绝不缓存 | fetch(url, { cache: "no-store" }) |
| CMS 推送后立即刷新 | webhook 内调 revalidateTag("posts") |
| 用户改了头像,需要立即看到 | Server Action 内 revalidatePath("/profile") |
| 强烈互动 / 实时(评论、通知) | TanStack Query polling / SSE / WebSocket |
| 跨页保持(全站徽章计数) | TanStack Query + 共享 key |
| 静态营销页 | 默认即静态,无需配置 |
| 调试缓存行为 | next.config.mjs logging.fetches: { fullUrl: true } |
4. 缓存最常见的坑
4.1 数据看起来"卡住"
你以为是最新数据,实际从 Data Cache 拿。检查清单:
- fetch 是否带
cache: "no-store"或revalidate? - 数据更新点是否调了
revalidateTag/revalidatePath? - 是不是 build 时静态化了?(看 build 输出的
(Static)标志) - 是不是 Router Cache 还没过期?(
router.refresh()强制刷)
4.2 静态化失败的常见原因
typescript
// 这一个 cookies() 就让整页变 dynamic
const c = cookies();
// 类似的:headers() / draftMode() / dynamic params / fetch with no-store
💡 诊断 :
pnpm build看输出,每个路由后面有○ (Static)/λ (Dynamic)/● (SSG)标记。意外的 dynamic 标记 = 静态化失败。
4.3 revalidateTag 没起效
- tag 写错(
"user"vs"users") - 你 fetch 时没带 tag(
next: { tags: [...] }) - 部署后才生效,本地 dev 行为不同(dev 几乎不缓存)
5. 标签化缓存(Tag-based Invalidation)的实战
typescript
// lib/api/users.ts
import "server-only";
export function listUsers() {
return fetch(`${API}/users`, {
next: { revalidate: 60, tags: ["users"] },
}).then(r => r.json());
}
export function getUser(id: string) {
return fetch(`${API}/users/${id}`, {
next: { revalidate: 60, tags: ["users", `user:${id}`] },
}).then(r => r.json());
}
更新用户后:
typescript
"use server";
import { revalidateTag } from "next/cache";
export async function updateUserAction(id: string, data: UpdateUserInput) {
await apiFetch(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
revalidateTag(`user:${id}`); // 该用户详情失效
revalidateTag("users"); // 列表失效
}
💡 标签设计原则
- 资源级 tag:
user:${id}------ 单条更新就失效一条- 集合级 tag:
users------ 列表更新或资源变化时一起失效- 不要把 tenant_id 编进 tag,会爆炸 → 资源粒度即可
6. 与 NestJS API 协作:apiFetch 封装
typescript
// lib/api.ts ------ server-only
import "server-only";
import { cookies, headers } from "next/headers";
const API = process.env.API_URL!;
export interface ApiFetchInit extends RequestInit {
next?: NextFetchRequestConfig;
}
export async function apiFetch(path: string, init: ApiFetchInit = {}) {
const cookieStore = cookies();
const token = cookieStore.get("access_token")?.value;
const requestId = headers().get("x-request-id") ?? crypto.randomUUID();
return fetch(`${API}${path}`, {
...init,
headers: {
"content-type": "application/json",
"x-request-id": requestId,
...(token && { authorization: `Bearer ${token}` }),
...init.headers,
},
// 默认不缓存,具体接口需要时显式开
cache: init.cache ?? "no-store",
});
}
💡
import "server-only"是好朋友 任何文件第一行import "server-only";后,如果被某个 client 组件 import → 编译报错。防止意外把 server-only 工具(含 secret)带到 client bundle。
6.1 安全版本:返回 typed result
typescript
export type ApiResult<T> = { ok: true; data: T } | { ok: false; problem: ProblemDetails };
export async function apiJson<T>(path: string, init?: ApiFetchInit): Promise<ApiResult<T>> {
const res = await apiFetch(path, init);
if (res.ok) return { ok: true, data: (await res.json()) as T };
const problem = await res.json().catch(() => ({ title: "Network error", status: res.status, type: "about:blank" }));
return { ok: false, problem };
}
⚠️ 不要让 server 抛 raw error 给客户端 任何后端错误经过
apiJson转成结构化结果,组件层用if (!result.ok)渲染错误 UI。绝不让 stack trace 飘到浏览器。
7. cookies / headers 的边界
| 工具 | 在哪里能用 | 注意 |
|---|---|---|
cookies()(read) |
Server Component / Server Action / Route Handler | 让路由变 dynamic |
cookies().set() |
Server Action / Route Handler | 不能在 Server Component 设 |
headers() |
Server Component / Server Action / Route Handler | 让路由变 dynamic |
draftMode() |
Server Component | 让路由变 dynamic |
💡 为什么 Server Component 不能 set cookie? RSC 渲染时,响应 header 已经发出。响应已开始流,改不了 header。要 set cookie 必须在 Action / Route Handler / Middleware 里(它们生成的是响应本身)。
8. 错误处理与重试
8.1 Server fetch 加超时
typescript
async function apiFetchWithTimeout(path: string, init: ApiFetchInit = {}, timeoutMs = 5000) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await apiFetch(path, { ...init, signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
⚠️ 没有超时 = server 端可能卡几十秒 ,Next 渲染被阻塞,该容器整体 throughput 下降。所有出站 fetch 都该有超时。
8.2 重试谨慎用
服务端不要无脑重试:
- GET / HEAD:幂等,可重试一次
- POST / PUT / PATCH / DELETE :不要自动重试 ,除非你确信幂等(配合
Idempotency-Key)
8.3 错误兜底渲染
typescript
// 错误归错误,显示有意义的占位
async function UserBlock({ id }: { id: string }) {
const result = await apiJson<User>(`/users/${id}`);
if (!result.ok) {
return <p className="text-red-600">无法加载用户信息(错误号 {result.problem.requestId ?? "?"})</p>;
}
return <div>{result.data.name}</div>;
}
也可以让 error.tsx 兜底 (直接 throw),但段级 error.tsx 会取代整段渲染 ,导致页面其他部分也炸。推荐:预期失败显式兜底,真正 5xx 才让 error.tsx 接管。
9. Streaming 与 loading.tsx
app/users/[id]/loading.tsx:
typescript
export default function Loading() {
return <UserSkeleton />;
}
等价于在 app/users/[id]/page.tsx 外面包了 <Suspense fallback={<Loading />}>。整段被包。
💡 细粒度 Suspense 比 loading.tsx 更优
loading.tsx整段 fallback;手写<Suspense>可以做"先显示已就绪部分"。复杂页面用手写 Suspense;简单列表/详情用 loading.tsx 够。
10. 数据获取常见反模式
10.1 Server Component 里调本站 Route Handler
typescript
// ❌
async function getUsers() {
return fetch("https://my-site.com/api/users").then(r => r.json());
}
绕一圈回到自己。直接调内部函数或直调后端 API。
10.2 Server fetch 不带 cookie 鉴权
typescript
// ❌ 这条 fetch 不会自动带浏览器 cookie
const res = await fetch(`${API}/me`);
// ✅
const res = await fetch(`${API}/me`, {
headers: { cookie: cookies().toString() },
});
⚠️ Server fetch 不自动带浏览器 cookie 。Next server 是个独立的 HTTP 客户端,只发你显式给它的 header 。鉴权信息必须自己注入(参考第 6 节的
apiFetch)。
10.3 在 client component 里 fetch server-only 数据
typescript
"use client";
useEffect(() => {
fetch(process.env.API_URL!); // ❌ API_URL 在 client 是 undefined
}, []);
非 NEXT_PUBLIC_ 变量在客户端是 undefined。client 端要么调 Next 自己的 Route Handler 转发,要么用 server-prefetch + hydrate(1.4 节)。
11. 速记卡
| 想做 | 怎么做 |
|---|---|
| 服务端取数据 | RSC async function + fetch |
| 客户端取/订阅数据 | TanStack Query |
| 服务端预热客户端缓存 | prefetchQuery + HydrationBoundary |
| 缓存 N 秒 | next: { revalidate: N } |
| 不缓存 | cache: "no-store" |
| 按 tag 失效 | next: { tags: [...] } + revalidateTag |
| 按路径失效 | revalidatePath("/...") |
| 强制每次新鲜 | 顶部 export const dynamic = "force-dynamic" |
| 加载占位 | loading.tsx 或 <Suspense> |
| 错误兜底 | error.tsx 或 apiJson 返回 result |
延伸阅读
04 - Server Actions 与表单
目标:把表单这件最常被做差的事做对。前后端类型共享、即时校验 + 服务端再校验、乐观更新、错误归位、上传、安全。
1. Server Actions 是什么(本质)
typescript
"use server";
export async function createUser(formData: FormData) {
// 跑在 Node,可以读 cookies / 调 API / 操作 DB
}
本质:Next 把这个函数编译成一个 POST 端点,客户端调用它时其实是 fetch 一个隐藏 URL。前端语法看似"直接调函数",底下是 RPC。
关键认知:
- ✅ 可读 cookies / headers / env / 调内网 API
- ✅ 与
<form action={...}>原生集成,JS 关闭也能提交(渐进增强) - ✅ 自动
revalidatePath/revalidateTag后页面新鲜 - ⚠️ 是公网端点!鉴权、授权、输入校验全部要做
- ⚠️ 是 POST 请求,不能用 GET 触发(URL 不能直接调用 Action)
- ⚠️ 调用方可被构造,别假设"只有你自家页面会调"
2. 最简表单
typescript
// app/users/actions.ts
"use server";
import { CreateUserSchema } from "@my-app/shared/schemas/user";
import { apiFetch } from "@/lib/api";
import { revalidatePath } from "next/cache";
export async function createUserAction(_prev: unknown, formData: FormData) {
const parsed = CreateUserSchema.safeParse({
email: formData.get("email"),
name: formData.get("name"),
password: formData.get("password"),
});
if (!parsed.success) {
return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
}
const res = await apiFetch("/users", {
method: "POST",
body: JSON.stringify(parsed.data),
});
if (!res.ok) {
return { ok: false as const, error: await res.text() };
}
revalidatePath("/users");
return { ok: true as const };
}
页面:
typescript
// app/users/new/page.tsx
"use client";
import { useActionState } from "react";
import { createUserAction } from "../actions";
const initial = { ok: false as const, errors: {} as Record<string, string[]> };
export default function NewUserPage() {
const [state, formAction, pending] = useActionState(createUserAction, initial);
return (
<form action={formAction}>
<label>Email <input name="email" required /></label>
{state.errors?.email && <p className="text-red-600">{state.errors.email[0]}</p>}
<label>Name <input name="name" required /></label>
{state.errors?.name && <p className="text-red-600">{state.errors.name[0]}</p>}
<label>Password <input name="password" type="password" required minLength={12} /></label>
{state.errors?.password && <p className="text-red-600">{state.errors.password[0]}</p>}
<button disabled={pending}>{pending ? "..." : "Create"}</button>
</form>
);
}
💡
useActionState(原useFormState) React 18.3 / Next 14.2+ 的标准 hook。返回[state, action, pending]:state 是 action 上一次返回值;action 包装后传给<form action>;pending 是 inflight 状态。
3. 与 React Hook Form + Zod 组合(推荐)
简单表单 useActionState 够。复杂表单(多字段、联动、动态字段)推荐 RHF。
typescript
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { CreateUserSchema, type CreateUserInput } from "@my-app/shared/schemas/user";
import { createUserAction } from "../actions";
export default function NewUserForm() {
const [pending, startTransition] = useTransition();
const form = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
defaultValues: { email: "", name: "", password: "" },
mode: "onBlur",
});
const onSubmit = form.handleSubmit((values) => {
startTransition(async () => {
const result = await createUserAction(values);
if (!result.ok) {
// 把 server 返回的字段错误映射回 RHF
for (const [field, msgs] of Object.entries(result.errors ?? {})) {
form.setError(field as keyof CreateUserInput, { message: msgs?.[0] });
}
} else {
form.reset();
}
});
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("email")} />
{form.formState.errors.email && <p>{form.formState.errors.email.message}</p>}
{/* ... */}
<button disabled={pending}>{pending ? "..." : "Create"}</button>
</form>
);
}
action 接受对象版本:
typescript
// actions.ts
"use server";
export async function createUserAction(input: CreateUserInput) {
const parsed = CreateUserSchema.safeParse(input);
// ...
}
💡 Server Action 接受任何可序列化对象 Next 自动序列化/反序列化。函数、Date、Map 受限,普通对象/数组完全 OK。
💡useTransition替代useActionState当你已经用 RHF 管理状态时,useTransition只需要"知道有没有 pending"。功能更轻量,与 RHF 互不重叠。
4. 服务端再校验是 必须 的
typescript
"use server";
import { CreateUserSchema } from "@my-app/shared/schemas/user";
export async function createUserAction(input: unknown) {
// 1. 鉴权
const session = await getSession();
if (!session) return { ok: false, code: "UNAUTHORIZED" } as const;
// 2. 授权
if (!can(session.user, "create", "User")) {
return { ok: false, code: "FORBIDDEN" } as const;
}
// 3. 输入校验(永远!)
const parsed = CreateUserSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors } as const;
}
// 4. 业务调用
// ...
}
⚠️ 不要假设"只有你自家页面会调 Action" Action 在 Network panel 是一个普通 POST,攻击者可以构造任意请求(包括
useActionState也用不到的字段)。输入校验 + 鉴权 + 授权全部要重做一次。客户端 RHF 校验只是 UX,不是安全。
5. revalidate / redirect 后置
成功后通常做两件事之一:留在页面 (revalidate + 弹 toast)或 跳走(redirect)。
typescript
// 留在页面
revalidatePath("/users");
return { ok: true, message: "创建成功" };
// 跳走(注意 redirect 是 throw,放最后)
revalidatePath("/users");
redirect(`/users/${newUser.id}`);
⚠️
redirect在 try/catch 内会被吞掉 如果你不得不包 try/catch,在 catch 里if (isRedirectError(e)) throw e;重抛。或者把 redirect 放在 try 块外。
6. 乐观更新
提升体验的关键。两种实现:
6.1 useOptimistic(轻量)
typescript
"use client";
import { useOptimistic } from "react";
export function Likes({ count, postId }: { count: number; postId: string }) {
const [optimistic, addOptimistic] = useOptimistic(count, (state, by: number) => state + by);
return (
<form action={async () => {
addOptimistic(1);
await likePostAction(postId);
}}>
<button>👍 {optimistic}</button>
</form>
);
}
6.2 TanStack Query mutation(复杂场景)
typescript
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: UpdateUserInput) => updateUserAction(input),
onMutate: async (input) => {
await qc.cancelQueries({ queryKey: ["user", input.id] });
const prev = qc.getQueryData(["user", input.id]);
qc.setQueryData(["user", input.id], (old: any) => ({ ...old, ...input }));
return { prev };
},
onError: (_e, input, ctx) => qc.setQueryData(["user", input.id], ctx?.prev),
onSettled: (_d, _e, input) => qc.invalidateQueries({ queryKey: ["user", input.id] }),
});
}
💡
useOptimistic适合简单场景(计数、列表加一条),复杂的状态依然走 TanStack。两者不冲突。
7. 文件上传
7.1 小文件(<2MB):直接走 Action
typescript
"use server";
export async function uploadAvatarAction(formData: FormData) {
const file = formData.get("file") as File;
if (!file || file.size > 2 * 1024 * 1024) return { ok: false, error: "too big" };
const buf = Buffer.from(await file.arrayBuffer());
// 上传到 S3 或调后端 API
}
next.config.mjs:
javascript
experimental: { serverActions: { bodySizeLimit: "2mb" } }
7.2 大文件:预签名 URL(必走)
typescript
// Server Action 申请上传 URL
"use server";
export async function getUploadUrl(filename: string, contentType: string) {
return apiJson<{ url: string; key: string }>("/upload-url", {
method: "POST",
body: JSON.stringify({ filename, contentType }),
});
}
// Client 直接 PUT 到 S3
"use client";
async function upload(file: File) {
const { url, key } = (await getUploadUrl(file.name, file.type)).data;
await fetch(url, { method: "PUT", body: file, headers: { "content-type": file.type } });
await registerUploadAction(key); // 通知后端"已上传"
}
💡 为什么大文件不走 Action? Action 是 Next server 处理的 POST。文件经过 Next → 后端 → S3,三跳带宽 ;Next 内存压力大;Action 还有 bodySizeLimit。预签名 URL 让浏览器直传 S3,Next 不碰文件流。
8. 安全清单(Action 专属)
- 鉴权:每个 Action 起手检查 session
- 授权:CASL / RBAC 判断当前用户能否做这个 action
- 输入校验 :Zod
.safeParse,失败返回结构化错误 - 输出脱敏:返回值不要带后端 stack / 内部 ID
- CSRF :Next 14+ 默认对 Server Actions 做 origin 校验(检查
Origin === Host),不要在next.config关掉 - 限流:对登录、注册、付费类 Action 单独限流(参考第 9 节)
- 幂等 :涉及钱/外部副作用的 Action,客户端传
Idempotency-Key,Action 内查 Redis 去重 - 审计日志:谁、什么时候、做了什么,关键 Action 必记
💡 Next 内置 Origin 校验 Next 14+ 收到 Server Action 请求时校验
Originheader 与配置一致。配置experimental.serverActions.allowedOrigins: ["app.example.com"],默认拒绝外站调用。但这不是 CSRF 的全部,你仍要做鉴权 + 输入校验。
9. Action 级速率限制
typescript
"use server";
import { getRedisRateLimiter } from "@/lib/rate-limit";
const limiter = getRedisRateLimiter({ points: 5, duration: 60 });
export async function loginAction(input: LoginInput) {
const session = headers().get("x-forwarded-for") ?? "unknown";
const r = await limiter.consume(`login:${session}`);
if (!r.ok) return { ok: false, code: "RATE_LIMITED", retryAfter: r.retryAfter } as const;
// ...
}
⚠️ 不要直接
process.memory存计数 多副本 Next 各自计数 = 形同虚设。用 Redis(rate-limiter-flexible或自己写)。
10. 错误归位:把后端字段错误映射回 UI
后端返回 RFC 7807 + errors:
json
{
"type": "...", "title": "Validation failed", "status": 400,
"errors": { "email": ["already in use"] }
}
Action 转手给客户端:
typescript
"use server";
export async function createUserAction(input: CreateUserInput) {
const res = await apiJson<User>("/users", { method: "POST", body: JSON.stringify(input) });
if (!res.ok) {
return {
ok: false as const,
errors: (res.problem.errors ?? {}) as Record<keyof CreateUserInput, string[]>,
message: res.problem.title,
};
}
return { ok: true as const, user: res.data };
}
客户端用 RHF setError 映射回字段(第 3 节)。用户看到红框 + 红字,不需要 alert。
11. 常见反模式
11.1 把整个业务塞 Action
typescript
"use server";
export async function placeOrderAction(...) {
// 200 行,事务、风控、扣库存、生成发票、发邮件...
}
❌ 维护噩梦 + 移动端复用难。Action 只做转发到 NestJS API,业务在 API 内部。
11.2 在 Action 里 throw 给客户端看
typescript
"use server";
export async function createUserAction(input) {
if (!input.email) throw new Error("email required"); // ❌
}
throw 出来变 500,客户端只看到红屏。预期失败返回 result 对象,只有真崩才让它 throw。
11.3 Action 用 GET-like 风格
Action 不能用 URL 直接触发(它是 POST + serialized form payload)。别用 Action 做"导出 CSV 链接" ------ 用 Route Handler。
12. 速记卡
| 想做 | 用 |
|---|---|
| 表单提交 | Server Action + useActionState |
| 复杂表单 | RHF + Zod + Action + useTransition |
| 即时反馈 | useOptimistic 或 TanStack mutation |
| 大文件上传 | 预签名 URL |
| 重定向后跳页 | revalidatePath + redirect |
| 字段级错误 | Action 返回 errors,RHF setError 映射 |
| 防滥用 | 鉴权 + 授权 + Zod + 限流 + 幂等 |
| 审计 | 关键 Action 记 log |
延伸阅读
05 - 状态管理与 UI 体系
目标:把"前端工程"做成可维护的体系。状态分层、URL 状态、共享 client 状态、UI 体系(Tailwind + shadcn/ui)、i18n。
1. 状态分四类
把状态分类后,用工具就清晰了:
| 类别 | 例子 | 工具 |
|---|---|---|
| 服务器状态 | 用户列表、订单详情 | TanStack Query / RSC fetch |
| URL 状态 | 当前 tab、筛选、分页 | useSearchParams / nuqs |
| 本地 UI 状态 | 弹窗开关、表单当前值 | useState / useReducer |
| 共享 client 状态 | 主题、登录信息、购物车 | Zustand / Context |
💡 黄金规则:能从 URL 推导的状态,放 URL 优点:可分享、可后退、可深链接、SSR 友好。
?tab=invoices&page=2永远优于 client-only state。
2. TanStack Query 用法精要(client 端)
2.1 key 工厂(标准做法)
typescript
// lib/query/keys.ts
export const userKeys = {
all: ["users"] as const,
list: (filters: Record<string, unknown>) => [...userKeys.all, "list", filters] as const,
detail: (id: string) => [...userKeys.all, "detail", id] as const,
};
key 集中管理后,改字段名、加筛选条件不会拼写漂移 ,失效时也能批量(invalidateQueries({ queryKey: userKeys.all }))。
2.2 query
typescript
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
export function useUsers(filters: Filters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: ({ signal }) => api.get("/users", { params: filters, signal }).then(r => r.data),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
要点:
signal传给 fetch / axios,组件卸载自动 abortkeepPreviousData分页/筛选切换不闪空staleTime给 30s 到 5min,避免无意义重 fetch
2.3 mutation + 乐观更新
typescript
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: UpdateUserInput) =>
api.patch(`/users/${input.id}`, input).then(r => r.data),
onMutate: async (input) => {
await qc.cancelQueries({ queryKey: userKeys.detail(input.id) });
const prev = qc.getQueryData(userKeys.detail(input.id));
qc.setQueryData(userKeys.detail(input.id), (old: any) => ({ ...old, ...input }));
return { prev };
},
onError: (_err, input, ctx) => {
qc.setQueryData(userKeys.detail(input.id), ctx?.prev);
},
onSettled: (_data, _err, input) => {
qc.invalidateQueries({ queryKey: userKeys.detail(input.id) });
qc.invalidateQueries({ queryKey: userKeys.all });
},
});
}
💡
staleTimevsgcTime
staleTime:数据多久后被视作"过时",过时才会重新 fetch(focus / mount 时)。默认 0。gcTime:无观察者后多久从缓存清除。默认 5min。
2.4 Provider 注入
typescript
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [qc] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
refetchOnWindowFocus: false,
},
},
}));
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
⚠️
new QueryClient()必须在 state init ,不能放 render body 顶部。否则每次重渲染都新建 client,所有缓存丢失。
3. URL 状态:nuqs
bash
pnpm add nuqs
typescript
"use client";
import { useQueryState, parseAsString, parseAsInteger } from "nuqs";
export function UserFilters() {
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""));
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<button onClick={() => setPage(page + 1)}>Next</button>
</>
);
}
nuqs 自动处理 URL 同步、SSR、debounce、shallow。比手动 useSearchParams + router.replace 干净得多。
4. 共享 client 状态:Zustand
typescript
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartState {
items: CartItem[];
add: (item: CartItem) => void;
clear: () => void;
}
export const useCart = create<CartState>()(
persist(
(set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}),
{ name: "cart" },
),
);
用:
typescript
const items = useCart((s) => s.items); // 选择器,只关心 items 变
const add = useCart((s) => s.add);
要点:
- 选择器只挑必要字段,避免重渲染
persist让状态自动 localStorage 化(注意不要存 token)
⚠️ 不要把 server data 放 Zustand。重复造轮子,丢了 TanStack Query 的所有 cache/refetch/优化。
💡 Context 何时还有用? 不需要"中间组件读不到,只让叶子读到的"场景(主题、locale、currentUser)。Context 的痛点是任何 value 变化让所有消费者重渲染。状态多/变化频繁 → Zustand。
5. 表单(深度见 04 章,这里收尾)
简单表单:useActionState + 原生 <form>。 复杂表单:React Hook Form + Zod + Server Action。
5.1 复杂场景:useFieldArray
typescript
const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" });
return (
<>
{fields.map((f, i) => (
<div key={f.id}>
<input {...form.register(`items.${i}.name` as const)} />
<button onClick={() => remove(i)}>X</button>
</div>
))}
<button onClick={() => append({ name: "" })}>+</button>
</>
);
5.2 字段联动
typescript
const country = form.watch("country");
useEffect(() => { form.setValue("state", ""); }, [country, form]);
⚠️ 避免 over-watching :
form.watch()不传参订阅所有字段 → 整组件每次输入都重渲染。指定字段或用useWatch({ name: "country" })。
6. UI 体系:Tailwind + shadcn/ui + Radix
6.1 为什么不是 MUI / Chakra / Antd?
| 维度 | 组件库(MUI / Antd) | shadcn + Radix |
|---|---|---|
| 样式控制 | 主题 + override,改一点都难 | 复制到自己 repo,自由改 |
| 包体积 | 大,无法 tree-shake 干净 | 只装你用的 |
| a11y | 看实现,参差不齐 | Radix 是 a11y 优等生 |
| 与 Tailwind 配合 | 别扭 | 原生 |
前提:你愿意维护组件代码。如果团队不会写 UI,选 MUI 等。
6.2 shadcn 安装
bash
pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button input form dialog dropdown-menu toast
组件会被复制到 src/components/ui/,你可以自由改。
6.3 推荐结构
bash
src/components/
├── ui/ # shadcn 复制过来的原子组件(button, input, dialog...)
├── primitives/ # 你自己写的"原子"(更上层封装)
├── layouts/ # 页面布局组件
└── features/ # 业务组件(按 feature 分组)
└── users/
├── UserList.tsx
├── UserForm.tsx
└── ...
6.4 暗色模式
bash
pnpm add next-themes
typescript
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
export function ThemeWrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider attribute="class" defaultTheme="system">{children}</ThemeProvider>;
}
typescript
// app/layout.tsx
<html lang="zh" suppressHydrationWarning>
<body>
<ThemeWrapper>{children}</ThemeWrapper>
</body>
</html>
⚠️ 避免主题闪烁(FOUC)
next-themes默认在<head>注入一段 script,在 React 挂载前就设class="dark"。别用useEffect自己加 ,会闪。 注意<html suppressHydrationWarning>:服务端不知道用户系统主题,会与客户端 class 不一致,加这个抑制 hydration 警告。
6.5 className 合并:cn 工具
typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// 用法
<button className={cn("bg-blue-500 hover:bg-blue-600", isPrimary && "ring-2", className)} />
twMerge 解决"覆盖冲突 class":bg-blue-500 和 bg-red-500 同时出现时取最后一个。
7. 国际化(i18n)
next-intl 与 App Router 配合最好。
bash
pnpm add next-intl
typescript
// src/i18n/messages/zh.json
{ "user": { "welcome": "欢迎,{name}" } }
// 组件
import { useTranslations } from "next-intl";
const t = useTranslations("user");
return <p>{t("welcome", { name: user.name })}</p>;
要点:
- Locale 走 URL 前缀 :
/zh/users/en/users,SEO 友好 - 不要把翻译写在代码里 (
text || "默认")→ 维护噩梦 - 服务端组件可以直接用 i18n(不需要 client provider)
7.1 URL 前缀模式 + middleware
typescript
// src/i18n.ts
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./i18n/messages/${locale}.json`)).default,
}));
// middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["zh", "en"],
defaultLocale: "zh",
localePrefix: "always",
});
export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"] };
💡 复数与变量插值 用 ICU MessageFormat:
{count, plural, =0 {无} one {# 项} other {# 项}}。next-intl原生支持。
8. 全局 Provider 装配模板
typescript
// app/providers.tsx ------ 所有 client provider 集中
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [qc] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, refetchOnWindowFocus: false },
},
}));
return (
<ThemeProvider attribute="class" defaultTheme="system">
<QueryClientProvider client={qc}>
{children}
{process.env.NODE_ENV !== "production" && <ReactQueryDevtools />}
</QueryClientProvider>
</ThemeProvider>
);
}
typescript
// app/layout.tsx ------ Server Component
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
💡
Providers是 client,RootLayout是 server 把 client 边界拉到最浅(Providers 内部),其他 layout / page 仍是 server。
9. 速记卡
| 想做 | 用 |
|---|---|
| 服务端数据 | RSC fetch / TanStack prefetchQuery |
| 客户端数据 | TanStack Query |
| URL 状态 | nuqs |
| 弹窗开关等本地 | useState |
| 跨组件 client 状态 | Zustand(主题、购物车等) |
| 表单(简单) | useActionState |
| 表单(复杂) | RHF + Zod + Action |
| UI 组件 | shadcn + Radix |
| 暗色 | next-themes |
| i18n | next-intl + URL 前缀 |
| className 合并 | cn (clsx + tailwind-merge) |