陌上人如玉
公子世无双

9.10 Server Actions 工程化

9.10 Server Actions 工程化

一句话: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;
});

五、面试延伸

  1. 「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」这类「应由服务端决定」的值放进闭包参数(⭐ 那是「把信任放在客户端」)。

  2. 「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 → 不缓存」。

  3. 「useFormStatus 和 useActionState 有什么区别?」
    ⭐ 作用范围不同:① useFormStatus()——从「最近的父级 <form>」读「提交状态」(pending/data/method),⭐ 它必须用在「<form> 的子组件」里(⚠️ 在「渲染 <form> 的那个组件自身」里读不到——这是最常见的坑);② useActionState(action, initialState)——把「action 的返回值」变成「组件可读的状态」,并返回「包装后的 formAction」与「isPending」,⭐ 它的 isPending 是「整个 action 期间」。实践:「提交按钮」用 useFormStatus(⭐ 它「与 form 绑定」,多个按钮可以各自不同);「页面级提示 / 读取 action 的返回值 / 字段错误」用 useActionState。

  4. 「为什么 redirect() 会被 try/catch 吞掉?」
    因为 ⭐ Next.js 的 redirect() / notFound() 是「通过抛一个特殊错误来实现」的(它抛一个带有 digest: 'NEXT_REDIRECT;...' 的错误,由框架在上层捕获并处理跳转)。⚠️ 所以如果你在一个「统一的错误处理包装器」里 catch 了所有错误→ 重定向会被「当成普通错误」吞掉(表现为「调了 redirect 但没跳转」)。✅ 解法:在 catch 里先判断「是不是框架的特殊错误」并「重新抛出」(见实现里的 isRedirectError/isNotFoundError——⚠️ 用「鸭子类型 + digest 前缀」判断,因为「API 名字在版本间会变」)。⭐ 这条也适用于任何「统一错误处理」的场景(⭐ 与 7.10 的「分清两层错误」是同一类问题)。

  5. 「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 的版本更薄」(因为「重新验证」是框架帮你做的)。

  6. 「Server Action 为什么容易出『并发/重复提交』问题?」
    因为 ⚠️ 它的触发方式(表单提交)天然会「重复」:① 用户连点提交按钮;② 浏览器「返回后重新提交」;③ 网络超时后的重试;④ ⭐ 同一个人开两个标签页/两个设备(⭐ 两个并发 action 会有竞态)。解法(与 7.12 同源):① ⭐ 幂等——「删除不存在的 → 返回成功」(⭐ 别报错);② ⭐ 乐观锁——提交时带 version,不匹配就返回「冲突」(⭐ 而不是「静默覆盖」);③ UI 防重——useFormStatus 的 pending → 禁用按钮;④ 关键操作要「服务端保证唯一」(⭐ 如「加唯一索引」或「用事务」——不要只靠前端的 disabled)。⭐ 一句话:「前端防重是体验,服务端幂等才是保证」。

  7. 「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 两种入口』之间复用」。

  8. 「如果让你制定团队的 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())。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.10 Server Actions 工程化

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫