
一句话:Server Actions 的本质是「一个自动生成的 POST 端点」——所以它最容易被误解的地方是「它没有任何隐式安全」:必须自己鉴权 + 自己校验参数 + 自己防 CSRF;而它的真正价值是「用「函数调用」的写法拿到『渐进增强的表单提交 + 自动重新验证 + 乐观更新』」。
一、本质:它就是一个「公开的 POST 端点」
// ✅ 定义(⭐ `'use server'` 让「这个函数在服务端执行」)
'use server';
export async function createPost(formData: FormData) {
// ⭐ 这里的代码「在服务端执行」—— 但「它是由「客户端的一个 POST 请求」触发的」
}
⭐⭐⭐⭐ 理解这一点,后面全都顺:
你在客户端写:
<form action={createPost}>…</form>
Next.js 会:
① 把 `createPost` 编译成一个「服务端入口」
② 在客户端生成「一个指向它的引用」
③ 提交时「发一个 POST 请求」(body 里是「序列化后的参数」)
⭐⭐⭐ 于是:
· ⚠️ **它是「公开可访问」的**——任何人只要「知道这个名字」就能 POST 它
(⭐ 「客户端不显示按钮」**完全不构成保护**)
· ⚠️ **参数完全不可信**(可以「用 curl 发任意 payload」)
· ⚠️ **它天然是「有副作用的 POST」**→ 「重复提交」要有幂等/防重考虑(见 7.12)
· ⭐ 它**不是「RPC 框架」**(没有 tRPC 那套「类型安全 + 中间件 + 输入校验」的基建,见 7.11)
→ ⭐ **所有安全与校验都要自己写**(或者「在 action 里调一个「有防护的服务层」」)
⭐⭐ 与 API Route 的对比(⭐ 什么时候用哪个):
| 维度 | Server Action | API Route(`route.ts`) |
| --- | --- | --- |
| 调用方式 | ⭐ 「像调函数」(`<form action>` / `onClick`) | 「fetch 一个 URL」 |
| 渐进增强 | ⭐ **表单在「JS 未加载」时也能提交** | ❌ 需要 JS |
| 与「重新验证」的集成 | ⭐ 内建(`revalidatePath` 会「同时清 Router Cache」) | ⚠️ 要手动处理 |
| 类型安全 | ⚠️ 参数类型「不会自动跨端校验」(要自己 zod) | ⚠️ 同样要自己校验 |
| 适合 | ⭐ **表单提交 / 数据变更(Mutation)** | ⭐ **给「外部/第三方」用的接口、Webhook、纯 GET 数据接口** |
| 缓存 | ⚠️ 「POST → 天然不缓存」 | ✅ 「GET 可缓存」(见 9.9) |
⭐ 一句话:「**内部用(表单/变更)→ Server Action;对外用(第三方/Webhook)→ API Route**」
二、安全:四道必须自己上的锁
⭐⭐⭐⭐ Server Actions 的四条安全纪律(⭐ 「缺一条就能被攻击」):
① 【鉴权:每个 action 都要「自己验」】⭐ 最重要
❌ 「因为客户端只给管理员显示这个按钮,所以 action 里不用检查」
✅ **每个 action 的第一行就该是「取当前用户 + 校验权限」**
⭐ 与 6.12 的铁律一致:「前端权限只是体验,服务端才是边界」——
而 Server Action **就是服务端**,所以「它就是那个边界」。
② 【参数校验:用 zod(或等价)】⭐
⚠️ 「TypeScript 类型在运行时不存在」→ 恶意请求可传任意值
✅ **每个 action 都要「解析 + 校验 + 收窄」**(⭐ 并在不合格时「返回明确错误」而不是抛 500)
③ 【CSRF:Next 有内建防护,但要知道「它防什么」】
⭐ Next 会检查「**请求的 `Origin`/`Host` 是否与部署的域名一致**」→
默认能挡住「跨站表单提交」
⚠️ 但要注意:
· 「自定义实现(非 Next)」要自己做(⭐ 或「用 SameSite cookie」)
· ⚠️ 「`Origin` 缺失」时的行为(某些老的/代理环境)
· ⚠️ **「不需要 CSRF」不代表「不需要鉴权」**(两者是不同的问题)
④ 【闭包变量:不要「把敏感数据藏在闭包里」】⚠️
`action` 可以捕获外层变量(如 `action.bind(null, { price: 100 })`)→
⭐ Next 会「加密闭包参数」(`encryptActionBoundArgs`),
⚠️ 但**不要把「价格/权限/用户 id」这类「应该服务端决定」的值放进闭包**
(⭐ 那是「把信任放在客户端」)——**服务端应该「自己查」**。
// ✅ 一个「安全骨架」:把「鉴权 + 校验 + 错误」变成「可复用的模式」
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
/** ============ ① 统一的「取当前用户」(⭐ 每个 action 都要过这一关) ============ */
async function requireUser(): Promise<{ id: string; permissions: string[] }> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) throw new AuthError('未登录');
// ⭐ 这里要「真实验签 + 查库」(⚠️ 不能「只看 cookie 存在」)
const user = await verifySessionAndLoadUser(session);
if (!user) throw new AuthError('登录已失效');
return user;
}
class AuthError extends Error {}
class ValidationError extends Error {
constructor(public fieldErrors: Record<string, string[] | undefined>) { super('参数不合法'); }
}
class ForbiddenError extends Error {}
/** ============ ② 统一的「校验」 ============ */
function parseInput<S extends z.ZodTypeAny>(schema: S, raw: unknown): z.infer<S> {
const result = schema.safeParse(raw);
if (!result.success) throw new ValidationError(result.error.flatten().fieldErrors);
return result.data;
}
/** ============ ③ 一个「包装器」:把「错误」统一变成「返回值」(⭐ 而不是抛到用户脸上) ============ */
export type ActionState<T = void> =
| { ok: true; data?: T }
| { ok: false; error: string; fieldErrors?: Record<string, string[] | undefined> };
function withActionErrorHandling<Input, Output>(
handler: (input: Input) => Promise<Output>
): (input: Input) => Promise<ActionState<Output>> {
return async (input: Input) => {
try {
const data = await handler(input);
return { ok: true, data };
} catch (err) {
// ⚠️⚠️ 注意:`redirect()` 在 Next 里是「抛一个特殊错误」来实现的 →
// ⭐ **必须「在 catch 里重新抛出它」**,否则「重定向会被吞掉」
if (isRedirectError(err)) throw err;
if (isNotFoundError(err)) throw err; // ⭐ `notFound()` 同理
if (err instanceof AuthError) return { ok: false, error: '请先登录' };
if (err instanceof ForbiddenError) return { ok: false, error: '没有权限' };
if (err instanceof ValidationError) return { ok: false, error: '请检查表单', fieldErrors: err.fieldErrors };
if (err instanceof z.ZodError) {
return { ok: false, error: '请检查表单', fieldErrors: err.flatten().fieldErrors };
}
// ⚠️ 未知错误:⭐ 上报(见 8.7)但「不把内部细节暴露给用户」
reportServerError(err);
return { ok: false, error: '服务异常,请稍后重试' };
}
};
}
/** ⭐ Next 的特殊错误判定(⭐ 版本间 API 名可能变,要用「鸭子类型」+ 兜底) */
function isRedirectError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'digest' in err &&
typeof (err as { digest?: string }).digest === 'string' &&
(err as { digest: string }).digest.startsWith('NEXT_REDIRECT');
}
function isNotFoundError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'digest' in err &&
(err as { digest?: string }).digest === 'NEXT_NOT_FOUND';
}
function reportServerError(err: unknown) {
// ⭐ 与 8.7 配合:服务端错误也要能「被看到」
console.error('[server-action]', err);
}
三、完整实现:一个端到端类型安全的 CRUD(240 行)
// app/actions/post.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { requireUser, parseInput, withActionErrorHandling, ForbiddenError, type ActionState } from '@/lib/action-kit';
/** ============ 1. Schema(⭐ 服务端「必须」校验;客户端「可选」复用做即时反馈) ============ */
const PostSchema = z.object({
title: z.string().trim().min(1, '标题不能为空').max(120, '标题最多 120 字'),
content: z.string().max(20_000, '正文过长'),
tags: z.array(z.string().min(1)).max(10).default([]),
/** ⭐ 乐观锁(⭐ 防「覆盖别人的修改」,见 7.11/7.12) */
version: z.coerce.number().int().nonnegative().optional(),
});
/** ============ 2. 创建 ============ */
export const createPost = withActionErrorHandling(async (formData: FormData): Promise<{ id: string }> => {
const user = await requireUser();
// ⭐⭐ 校验(⭐ 表单来的数据「全是字符串」→ 用 `Object.fromEntries` 后「按 schema 收窄」)
const raw = Object.fromEntries(formData);
const input = parseInput(PostSchema.omit({ version: true }), {
...raw,
// ⭐ 「多值字段」(如 tags)要特殊处理(`formData.getAll`)
tags: formData.getAll('tags').map(String),
});
const post = await db.post.create({
data: { ...input, authorId: user.id, version: 1 },
});
// ⭐⭐ 失效缓存(⭐ 见 9.9:「实体 tag + 列表 tag」都要失效)
revalidateTag(`post-${post.id}`);
revalidateTag('posts');
// ⭐ 也可以「重定向到详情页」(⭐ redirect 会「抛错」→ 由包装器重新抛出)
// redirect(`/posts/${post.id}`);
return { id: post.id };
});
/** ============ 3. 更新(含乐观锁) ============ */
export const updatePost = withActionErrorHandling(
async (formData: FormData): Promise<{ id: string; version: number }> => {
const user = await requireUser();
const id = String(formData.get('id') ?? '');
if (!id) throw new z.ZodError([]);
const input = parseInput(PostSchema.extend({ id: z.string().min(1) }), {
...Object.fromEntries(formData),
tags: formData.getAll('tags').map(String),
});
const existing = await db.post.findUnique({ where: { id: input.id } });
if (!existing) throw new Error('NOT_FOUND');
// ⭐⭐ 归属校验(⭐ 「能改自己的」,不能改别人的)
if (existing.authorId !== user.id) throw new ForbiddenError('无权修改这条内容');
// ⭐⭐ 乐观锁:版本不匹配 → 「别人先改了」
if (input.version !== undefined && input.version !== existing.version) {
// ⚠️ 这里「返回明确错误」让用户知道要刷新(而不是「静默覆盖」)
throw new Error('CONFLICT');
}
const updated = await db.post.update({
where: { id: input.id },
data: {
title: input.title,
content: input.content,
tags: input.tags,
version: existing.version + 1,
},
});
revalidateTag(`post-${id}`);
revalidateTag('posts');
return { id, version: updated.version };
}
);
/** ============ 4. 删除(⭐ 用「幂等语义」:删不存在的也返回成功) ============ */
export const deletePost = withActionErrorHandling(async (formData: FormData): Promise<void> => {
const user = await requireUser();
const id = String(formData.get('id') ?? '');
const existing = await db.post.findUnique({ where: { id } });
// ⭐ 「已经不存在」→ 幂等成功(⭐ 让「重复点击/重试」安全,见 7.12)
if (!existing) return;
if (existing.authorId !== user.id) throw new ForbiddenError('无权删除');
await db.post.delete({ where: { id } });
revalidateTag(`post-${id}`);
revalidateTag('posts');
});
// ============ 5. 客户端:渐进增强的表单(⭐ `action` + `useActionState`) ============
'use client';
import { useActionState, useOptimistic, useRef, useEffect } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost, updatePost, type ActionState } from '@/app/actions/post';
/** ⭐⭐ `useFormStatus` 必须用在「`<form>` 的**子组件**」里(⭐ 在 form 组件自身里读不到) */
function SubmitButton({ children }: { children: React.ReactNode }) {
// ⭐ 这个 Hook 会「自动」拿到「最近的 form 的提交状态」
const { pending, data, method } = useFormStatus();
void data; void method;
return (
<button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? '提交中…' : children}
</button>
);
}
/** ⭐⭐ `useActionState`:把「action 的返回值」变成「组件可读的状态」 */
export function PostForm({ initial }: { initial?: { id: string; title: string; content: string; tags: string[]; version: number } }) {
const action = initial ? updatePost : createPost;
// ⭐ 签名是「(prevState, formData) => newState」
const [state, formAction, isPending] = useActionState<ActionState<{ id: string }> | null, FormData>(
async (prevState, formData) => {
void prevState;
return await action(formData as never);
},
null
);
const formRef = useRef<HTMLFormElement>(null);
// ⭐ 「创建成功」后清空表单(⭐ 因为「创建不需要保留内容」)
useEffect(() => {
if (state?.ok && !initial) formRef.current?.reset();
}, [state, initial]);
return (
<form ref={formRef} action={formAction}>
{initial && <input type="hidden" name="id" value={initial.id} />}
{initial && <input type="hidden" name="version" value={initial.version} />}
<label>
标题
<input
name="title"
defaultValue={initial?.title}
required
maxLength={120}
// ⭐ 无障碍:把「字段错误」关联起来(见 10.12)
aria-invalid={state?.ok === false && state.fieldErrors?.title ? true : undefined}
aria-describedby={state?.ok === false && state.fieldErrors?.title ? 'title-error' : undefined}
/>
</label>
{state?.ok === false && state.fieldErrors?.title && (
<p id="title-error" role="alert">{state.fieldErrors.title.join(',')}</p>
)}
<label>
正文
<textarea name="content" defaultValue={initial?.content} rows={8} />
</label>
{/* ⭐ 多选标签(⚠️ 同名 input → `formData.getAll('tags')`) */}
<fieldset>
<legend>标签</legend>
{['技术', '生活', '随笔'].map((tag) => (
<label key={tag}>
<input type="checkbox" name="tags" value={tag} defaultChecked={initial?.tags.includes(tag)} />
{tag}
</label>
))}
</fieldset>
{/* ⭐⭐ 表单级错误(⭐ `state.error`) */}
{state?.ok === false && <p role="alert">{state.error}</p>}
<SubmitButton>{initial ? '保存' : '发布'}</SubmitButton>
{/* ⭐ 用 `isPending` 做「整表单」的提示(⚠️ 与 `useFormStatus` 的区别见下文) */}
{isPending && <span aria-live="polite">正在保存…</span>}
</form>
);
}
// ============ 6. 乐观更新(⭐ 与 2.15/7.4 的思想一致) ============
'use client';
import { useOptimistic, useTransition } from 'react';
import { deletePost } from '@/app/actions/post';
export function PostList({ posts }: { posts: Array<{ id: string; title: string }> }) {
const [isPending, startTransition] = useTransition();
// ⭐ 「乐观移除」:点了删除 → 「列表里立刻少一条」
const [optimisticPosts, removeOptimistic] = useOptimistic(
posts,
(current, removedId: string) => current.filter((p) => p.id !== removedId)
);
return (
<ul style={{ opacity: isPending ? 0.7 : 1 }}>
{optimisticPosts.map((post) => (
<li key={post.id}>
{post.title}
<form
action={(formData) => {
// ⭐⭐ 注意:`useOptimistic` 的更新「必须在 transition 里」(否则 React 会警告)
startTransition(async () => {
removeOptimistic(post.id);
// ⭐ 调 Server Action(它内部会 revalidateTag → 「刷新后」服务端数据接管)
await deletePost(formData);
});
}}
>
<input type="hidden" name="id" value={post.id} />
<button type="submit">删除</button>
</form>
</li>
))}
</ul>
);
}
⭐⭐⭐ 这四段代码里的六个「工程要点」:
① 【「包装器」统一处理「鉴权/校验/错误」】⭐
`withActionErrorHandling` 把「错误 → 返回值」的转换集中在一处 →
① **每个 action 都自动有「鉴权 + 校验 + 友好错误」**;
② ⭐ **`redirect()`/`notFound()` 必须「重新抛出」**(它们是「用抛特殊错误实现的」,⚠️ 被 catch 吞掉就「重定向失效」)。
② 【返回值用「判别联合」`ActionState`】⭐
`{ ok: true, data } | { ok: false, error, fieldErrors? }` →
⭐ 客户端 `switch (state.ok)` 就能「穷举处理」,且 `fieldErrors` 能「映射到具体字段」(见 1.13)。
③ 【`useFormStatus` vs `useActionState` 的 `isPending`】⚠️
· `useFormStatus()` → **必须在「`<form>` 的子组件」里**(⭐ 在 form 自身组件里读不到)
· `useActionState` 返回的 `isPending` → 「**整个 action 期间**为 true」
⭐ 实践:「提交按钮」用 `useFormStatus`(⭐ 更精确地「跟表单绑定」);「页面级提示」用 `isPending`。
④ 【`useOptimistic` 的更新「必须在 transition 里」】⭐
⚠️ 否则 React 会警告(且行为不可预期)→ ✅ 用 `startTransition` 包住(⭐ 与 4.16 的 transition 语义一致)。
⑤ 【「渐进增强」是 Server Action 的独特价值】⭐
表单用的是 **`<form action={fn}>`** → ⭐ **在「JS 还没加载/加载失败」时,浏览器会「原生提交」** →
用户在「弱网/JS 报错」时**依然能提交**(⭐ 这是 API Route + `onSubmit` 做不到的)。
⑥ 【「幂等」在 Server Action 里尤其重要】⚠️
因为「表单提交」天然会「重复」(用户连点、浏览器重试、返回后重新提交)→
⭐ 「删除不存在的 → 返回成功」这种「幂等语义」比「报错」更安全(见 7.12)。
四、本篇特有的坑
// ① ⭐⭐ 「没有鉴权」(以为「客户端不显示按钮」就是保护)
'use server';
export async function deleteAll() { await db.post.deleteMany(); } // ❌❌ 任何人都能 POST 它
// ✅ 每个 action「第一行」就 `requireUser()` + 权限校验
// ② ⭐ 「没有参数校验」(信任了 TS 类型)
export async function update(input: { price: number }) { // ❌ 运行时 input 可能是任意值
// ✅ zod 校验
// ③ ⭐⭐ 「`redirect()` 被 try/catch 吞掉」(重定向失效)
try { redirect('/x'); } catch (e) { return { ok: false }; } // ❌
// ✅ 「在 catch 里重新抛出」(见 `isRedirectError`)
// ④ ⭐ 「`useFormStatus` 用在「form 组件自身」里」(拿不到 pending)
function Form() { const { pending } = useFormStatus(); return <form>…</form>; } // ❌
// ✅ 抽成「子组件」:<form><SubmitButton /></form>
// ⑤ ⭐ 「`useOptimistic` 的更新不在 transition 里」(React 警告)
removeOptimistic(id); await action(formData); // ❌
// ✅ startTransition(async () => { removeOptimistic(id); await action(formData); });
// ⑥ ⭐ 「把敏感值放进闭包参数」(信任客户端)
const action = deletePost.bind(null, { priceToRefund: 999 }); // ⚠️ 价格不该由客户端定
// ✅ 服务端「自己查价格」
// ⑦ 「忘记 `revalidate`」(Server Action 后 UI 不更新)
await db.post.update(...); return { ok: true }; // ⚠️ 缓存还是旧的
// ✅ `revalidateTag(...)`(⭐ 它会「同时清客户端 Router Cache」)
// ⑧ ⭐ 「`revalidateTag` 了但「页面还是旧的」」(tag 不一致 / 漏了列表 tag)
// ✅ 「tag 集中定义」(见 9.9)+ 「实体 + 列表」都失效
// ⑨ ⭐ 「FormData 里的值全是字符串」(直接当 number 用)
const qty = formData.get('qty') as number; // ❌ 实际是 string
// ✅ `z.coerce.number()` 或「显式转换」
// ⑩ ⭐ 「同名多值字段」只取了第一个
formData.get('tags'); // ⚠️ 只拿到第一个
// ✅ `formData.getAll('tags')`
// ⑪ ⭐ 「return 了不可序列化的东西」(如「类实例」/函数)
return { post, destroy() {} }; // ❌ 序列化失败
// ✅ 只返回「普通对象」(⭐ 与 3.15 的序列化规则同源)
// ⑫ ⭐ 「action 里抛错但客户端看不到有用信息」(只看到「服务异常」)
// ✅ 「分类错误」(校验/权限/冲突/未知)+ 「把「未知错误」上报」(见 8.7)
// ⑬ ⭐ 「并发修改静默覆盖」(没做乐观锁)
// ✅ 用 `version` + 「冲突时提示用户」(⭐ 见 7.11/7.12)
// ⑭ 「把 Server Action 当「通用 RPC」用」(大量小 action)
// ⚠️ 每次都是一个 POST 请求(⭐ 且有「序列化 + 鉴权」的固定开销)
// ✅ 「按业务动作聚合」(一个 action 做一件完整的事)
// ⑮ ⭐ 「action 里调了「外部接口」但没设超时」(请求挂住)
// ✅ `AbortSignal.timeout(...)`(⭐ 与 7.1 的纪律一致)
// ⑯ ⭐ 「CSRF 依赖 Next 的内建检查」但「自定义了 endpoint」
// ⚠️ 自定义的「会改状态的 GET 接口」没有 CSRF 保护
// ✅ 「改状态用 POST」+ 「校验 Origin」+ SameSite cookie
// ⑰ ⭐ 「以为 Server Action「不能被并发调用」」
// ⚠️ 同一个人开两个标签页 → 两个并发 action(⭐ 会有竞态)
// ✅ 「乐观锁 + 幂等」处理(见 7.12)
// ⑱ ⭐ 「Server Action 里「读 cookies()」让页面变动态」(见 9.9)
// ⭐ 这个「不是 bug」但要知道(⭐ 它会影响「页面能否静态化」)
// ⑲ ⭐ 「`'use server'` 写在「组件文件」顶部」(整个文件都变 server)
'use client';
'use server'; // ❌ 冲突
// ✅ 「action 放在单独文件」或「函数体内加 'use server'」(⭐ 只标记那个函数)
// ⑳ ⭐ 「测试困难」(action 依赖「请求上下文」如 cookies)
// ✅ 「把「业务逻辑」抽成「纯函数/service」(⭐ 那部分好测)→ action 只做「管道」
// 本实现里的 `withActionErrorHandling` + 「service 层」就是这个思路
// ⑳ 的完整处理(把「可测的业务逻辑」与「action 管道」分开)
/** ⭐⭐ ① service 层(纯业务,好测) */
export async function updatePostService(
input: { id: string; title: string; content: string; tags: string[]; version?: number },
actor: { id: string }
): Promise<{ id: string; version: number }> {
const existing = await db.post.findUnique({ where: { id: input.id } });
if (!existing) throw new NotFoundError('内容不存在');
if (existing.authorId !== actor.id) throw new ForbiddenError('无权修改');
if (input.version !== undefined && input.version !== existing.version) throw new ConflictError('已被修改');
const updated = await db.post.update({
where: { id: input.id },
data: { ...input, version: existing.version + 1 },
});
return { id: updated.id, version: updated.version };
}
/** ⭐ ② action 只是「管道」:取用户 → 校验 → 调 service → 失效缓存 → 转换错误 */
export const updatePostAction = withActionErrorHandling(async (formData: FormData) => {
const user = await requireUser();
const input = parseInput(PostSchema, {
...Object.fromEntries(formData),
tags: formData.getAll('tags').map(String),
version: formData.get('version'),
});
const result = await updatePostService(input, { id: user.id });
revalidateTag(`post-${input.id}`);
revalidateTag('posts');
return result;
});
五、面试延伸
- 「Server Actions 是什么?它安全吗?」
它的本质是「一个 Next.js 自动生成的 POST 端点」——你在客户端写<form action={createPost}>,Next 会把它编译成「服务端入口」+「客户端引用」,提交时发一个 POST 请求。⭐⭐ 所以它「没有任何隐式安全」:① ⚠️ 它是「公开可访问」的(任何人知道名字就能 POST,⭐ 「客户端不显示按钮」完全不构成保护);② ⚠️ 参数完全不可信(可以「用 curl 发任意 payload」)。因此四条纪律必须自己上:① 每个 action 都要requireUser()+ 权限校验(⭐ 它就是服务端的边界,与 6.12 的铁律一致);② 用 zod 做运行时校验(⭐ TS 类型运行时不存在);③ CSRF——Next 会检查Origin/Host,但「自定义 endpoint」要自己做;④ 不要把「价格/权限/用户 id」这类「应由服务端决定」的值放进闭包参数(⭐ 那是「把信任放在客户端」)。 -
「Server Action 和 API Route 怎么选?」
⭐ 「内部用(表单/变更)→ Server Action;对外用(第三方/Webhook)→ API Route」。Server Action 的优势:① ⭐ 渐进增强——表单用<form action={fn}>,在「JS 未加载/加载失败」时浏览器会「原生提交」(⭐ 弱网/JS 报错时用户依然能提交,这是 API Route +onSubmit做不到的);② ⭐ 与「重新验证」内建集成(在 action 里revalidateTag会「同时清客户端 Router Cache」→ UI 立刻更新);③ 「像调函数」的写法(⭐ 不用「拼 URL + 写 fetch」)。API Route 的优势:⭐ 「给外部用」(Webhook、第三方集成、纯 GET 数据接口);而且 ⭐ 「GET 可缓存」(见 9.9),而 Server Action 天然是「POST → 不缓存」。 -
「
useFormStatus和useActionState有什么区别?」
⭐ 作用范围不同:①useFormStatus()——从「最近的父级<form>」读「提交状态」(pending/data/method),⭐ 它必须用在「<form>的子组件」里(⚠️ 在「渲染<form>的那个组件自身」里读不到——这是最常见的坑);②useActionState(action, initialState)——把「action 的返回值」变成「组件可读的状态」,并返回「包装后的 formAction」与「isPending」,⭐ 它的isPending是「整个 action 期间」。实践:「提交按钮」用useFormStatus(⭐ 它「与 form 绑定」,多个按钮可以各自不同);「页面级提示 / 读取 action 的返回值 / 字段错误」用useActionState。 -
「为什么
redirect()会被 try/catch 吞掉?」
因为 ⭐ Next.js 的redirect()/notFound()是「通过抛一个特殊错误来实现」的(它抛一个带有digest: 'NEXT_REDIRECT;...'的错误,由框架在上层捕获并处理跳转)。⚠️ 所以如果你在一个「统一的错误处理包装器」里 catch 了所有错误→ 重定向会被「当成普通错误」吞掉(表现为「调了redirect但没跳转」)。✅ 解法:在 catch 里先判断「是不是框架的特殊错误」并「重新抛出」(见实现里的isRedirectError/isNotFoundError——⚠️ 用「鸭子类型 +digest前缀」判断,因为「API 名字在版本间会变」)。⭐ 这条也适用于任何「统一错误处理」的场景(⭐ 与 7.10 的「分清两层错误」是同一类问题)。 -
「Server Action 里的『乐观更新』怎么做?」
用useOptimistic:① 定义——const [optimisticX, addOptimistic] = useOptimistic(x, (current, update) => ...);② 在「提交时」先调addOptimistic(...)(⭐ UI 立刻反映);③ 然后「调 Server Action」(它内部revalidateTag→ 服务端数据接管,⭐ 乐观状态自动「被真实值替换」)。⭐⚠️ 一个关键约束:useOptimistic的更新「必须在 transition 里」(✅ 用startTransition(async () => { addOptimistic(...); await action(formData); }))——否则 React 会警告且行为不可预期(⭐ 与 4.16 的 transition 语义一致)。与 7.4(React Query)的关系:⭐ 思路完全一样(「先改本地、失败回滚、成功后以服务端为准」),只是「Server Action 的版本更薄」(因为「重新验证」是框架帮你做的)。 -
「Server Action 为什么容易出『并发/重复提交』问题?」
因为 ⚠️ 它的触发方式(表单提交)天然会「重复」:① 用户连点提交按钮;② 浏览器「返回后重新提交」;③ 网络超时后的重试;④ ⭐ 同一个人开两个标签页/两个设备(⭐ 两个并发 action 会有竞态)。解法(与 7.12 同源):① ⭐ 幂等——「删除不存在的 → 返回成功」(⭐ 别报错);② ⭐ 乐观锁——提交时带version,不匹配就返回「冲突」(⭐ 而不是「静默覆盖」);③ UI 防重——useFormStatus的pending→ 禁用按钮;④ 关键操作要「服务端保证唯一」(⭐ 如「加唯一索引」或「用事务」——不要只靠前端的disabled)。⭐ 一句话:「前端防重是体验,服务端幂等才是保证」。 -
「Server Action 怎么做测试?」
⭐ 核心思路:把「可测的业务逻辑」从 action 里抽出来。因为 action 依赖「请求上下文」(cookies()/headers()),直接测很别扭。做法:① ⭐ service 层——把「查库、校验归属、乐观锁、事务」写成纯函数式的 service(⭐ 参数显式传入actor: { id },而不是「从 cookie 里读」)→ 这个可以「直接单测」;② ⭐ action 只是「管道」——「取用户 →parseInput→ 调 service →revalidateTag→ 错误转换」;③ 测试管道——mock「cookies()(返回一个假 session)」+ 「service」+ 「revalidateTag」,断言「参数被正确校验」「service 被调用」「tag 被失效」;④ E2E——用 Playwright「真点表单」(⭐ 这能顺便验证「渐进增强」,如「禁用 JS 后仍能提交」)。⭐ 一句话:「action 应该是『薄管道』,业务在 service —— 这样既能测,也能在『API Route 与 Server Action 两种入口』之间复用」。 -
「如果让你制定团队的 Server Action 规范,会写哪些?」
八条:① ⭐ 每个 action 必须「先鉴权」(requireUser()作为第一件事);② ⭐ 每个 action 必须「用 zod 校验所有输入」;③ ⭐ 统一用「包装器」处理错误(⭐ 并重新抛出redirect/notFound);④ ⭐ 返回值用「判别联合」({ ok: true, data } | { ok: false, error, fieldErrors });⑤ ⭐ 写操作后必须revalidateTag(⭐ 且「实体 tag + 列表 tag」一起);⑥ ⭐ 业务逻辑写在 service 层(action 只做管道 → ⭐ 可测 + 可复用);⑦ ⭐ 关键操作要有「幂等 + 乐观锁」(⭐ 别只靠前端disabled);⑧ ⭐ 不在闭包里传「服务端该决定的值」(价格、权限、用户 id)。另外:「外部接口一律用 API Route」(⭐ 别用 Server Action 对外)。
一句话速记
Server Action 的本质是「Next 自动生成的 POST 端点」——⭐⭐ 它没有任何隐式安全(「客户端不显示按钮」不构成保护、参数可被任意构造),所以四条纪律必须自己上:① 每个 action 第一件事就是
requireUser()+ 权限校验;② zod 运行时校验(TS 类型运行时不存在);③ CSRF 靠 Next 的 Origin 检查但自定义端点要自己做;④ 不把「价格/权限/用户 id」放进闭包参数;⭐ 与 API Route 的分工:「内部(表单/变更)用 Server Action(⭐ 独有优势是渐进增强——JS 未加载也能原生提交;且revalidateTag会顺带清客户端 Router Cache)/ 对外(Webhook/第三方/纯 GET)用 API Route」;⚠️redirect()/notFound()是「抛特殊错误」实现的——统一错误包装器里必须重新抛出它们(用digest前缀判断),否则「重定向失效」;⚠️useFormStatus必须用在「<form>的子组件」里(在 form 自身读不到),useActionState的isPending是「整个 action 期间」;⭐useOptimistic的更新「必须在startTransition里」;⭐ 表单数据的三个坑:全是字符串(用z.coerce)/ 同名多值要用getAll/ 返回值必须可序列化;⭐ 并发与重复提交靠「幂等(删不存在的返回成功)+ 乐观锁version+ 服务端唯一约束」(⚠️ 前端disabled只是体验);⭐ 可测性的关键是「action 只做薄管道、业务放 service 层」(service 参数显式传actor,不依赖cookies())。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!