Next.js 教程 Part 2 — 数据获取、Server Actions 与状态

本册涵盖: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 拿。检查清单:

  1. fetch 是否带 cache: "no-store"revalidate?
  2. 数据更新点是否调了 revalidateTag / revalidatePath?
  3. 是不是 build 时静态化了?(看 build 输出的 (Static) 标志)
  4. 是不是 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

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.tsxapiJson 返回 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 请求时校验 Origin header 与配置一致。配置 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,组件卸载自动 abort
  • keepPreviousData 分页/筛选切换不闪空
  • 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 });
    },
  });
}

💡 staleTime vs gcTime

  • 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-500bg-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)

延伸阅读


相关推荐
用户125758524361 小时前
XYGo Admin ArtTable 表格组件:一行代码搞定加载、刷新与分页
前端
gogoing1 小时前
Prettier 配置说明
前端·javascript
十有八七1 小时前
Hermes Agent 自进化实现:从源码到架构的深度拆解
前端·人工智能
渐儿1 小时前
NestJS 生产级开发教程
前端
前端毕业班1 小时前
uni-app onShareAppMessage hook 原理分析
前端·javascript
gogoing1 小时前
React 分包加载优化
前端·react.js
gogoing1 小时前
Babel 配置与工具
前端·javascript
亲亲小宝宝鸭1 小时前
重新install,项目就跑不起来了?!
前端·npm
Mike117.1 小时前
GBase 8a 物化视图依赖和 DDL 风险排查记录
java·服务器·前端