Skip to content

server_actions.tsx

文件信息

  • 📄 原文件:02_server_actions.tsx
  • 🔤 语言:TypeScript (Next.js / React)

本文件介绍 Next.js 中 Server Actions 的使用方式。Server Actions 允许在服务端直接执行函数,无需手动创建 API 路由。它们是处理表单提交、数据变更的推荐方式。

完整代码

tsx
/**
 * ============================================================
 *              Next.js Server Actions
 * ============================================================
 * 本文件介绍 Next.js 中 Server Actions 的使用方式。
 *
 * Server Actions 允许在服务端直接执行函数,无需手动创建 API 路由。
 * 它们是处理表单提交、数据变更的推荐方式。
 *
 * 核心概念:
 * - 'use server' 指令标记服务端函数
 * - 可直接作为 form action 使用
 * - 与 React 19 的 useActionState、useOptimistic 深度集成
 * ============================================================
 */

import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

// ============================================================
//               1. Server Actions 基础
// ============================================================

/**
 * 【Server Actions — 服务端操作】
 *
 * 通过 'use server' 指令标记:
 * - 文件顶部 'use server':所有导出函数都是 Server Action
 * - 函数体内 'use server':单个函数标记为 Server Action
 *
 * 工作原理:
 * 1. 编译时为每个 Server Action 生成唯一端点
 * 2. 客户端调用时自动发送 POST 请求
 * 3. 服务端执行函数,返回结果
 */

// --- 方式一:在服务端组件中内联定义 ---
async function InlineActionPage() {
    async function createItem(formData: FormData) {
        'use server';
        const name = formData.get('name') as string;
        // await db.item.create({ data: { name } });
        revalidatePath('/items');
    }

    return (
        <form action={createItem}>
            <input name="name" placeholder="名称" />
            <button type="submit">创建</button>
        </form>
    );
}

// --- 方式二:独立文件定义(推荐用于客户端组件)---
// app/actions.ts
// 'use server';   // 文件顶部标记,所有导出都是 Server Action

export async function createUser(formData: FormData) {
    'use server';
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    // await db.user.create({ data: { name, email } });
    revalidatePath('/users');
}

// --- 可传递的参数类型 ---
// 支持:string, number, boolean, Date, FormData, null, undefined, 普通对象/数组
// 不支持:函数、类实例、Symbol、DOM 节点


// ============================================================
//               2. 表单处理
// ============================================================

/**
 * 【表单处理 — Form Handling】
 *
 * Server Actions 最自然的使用场景是表单处理:
 * - form action 直接绑定 Server Action
 * - FormData 自动传入
 * - 支持渐进增强:即使 JS 未加载,表单也能提交
 * - 无需 preventDefault / fetch / axios
 */

async function BasicFormPage() {
    async function handleSubmit(formData: FormData) {
        'use server';
        const title = formData.get('title') as string;
        const content = formData.get('content') as string;
        // await db.post.create({ data: { title, content } });
        revalidatePath('/posts');
        redirect('/posts');    // 提交后重定向
    }

    return (
        <form action={handleSubmit}>
            <input type="text" name="title" required />
            <select name="category">
                <option value="tech">技术</option>
                <option value="life">生活</option>
            </select>
            <textarea name="content" rows={5} required />
            <button type="submit">发布文章</button>
        </form>
    );
}

// --- 使用 bind 传递额外参数 ---
async function EditFormPage({ postId }: { postId: string }) {
    async function updatePost(id: string, formData: FormData) {
        'use server';
        const title = formData.get('title') as string;
        // await db.post.update({ where: { id }, data: { title } });
        revalidatePath(`/posts/${id}`);
    }

    // bind 将 postId 绑定到第一个参数
    const updatePostWithId = updatePost.bind(null, postId);

    return (
        <form action={updatePostWithId}>
            <input name="title" defaultValue="原始标题" />
            <button type="submit">更新</button>
        </form>
    );
}


// ============================================================
//               3. useActionState
// ============================================================

/**
 * 【useActionState — 表单状态管理】
 *
 * React 19 中的 useActionState(原 useFormState,已更名):
 * - 跟踪 Server Action 的返回状态
 * - 管理 pending 状态
 *
 * 签名:
 *   const [state, formAction, isPending] = useActionState(action, initialState);
 *
 *   action       — Server Action(第一个参数是 prevState)
 *   state        — 当前状态(Action 的返回值)
 *   formAction   — 绑定到 form action 的函数
 *   isPending    — 是否正在提交
 */

type FormState = {
    success: boolean;
    message: string;
    errors?: Record<string, string[]>;
};

// --- 带状态返回的 Server Action ---
async function createAccount(prevState: FormState, formData: FormData): Promise<FormState> {
    'use server';
    const username = formData.get('username') as string;

    if (!username || username.length < 3) {
        return {
            success: false,
            message: '验证失败',
            errors: { username: ['用户名至少 3 个字符'] },
        };
    }

    try {
        // await db.account.create({ data: { username } });
        revalidatePath('/accounts');
        return { success: true, message: '账户创建成功!' };
    } catch {
        return { success: false, message: '创建失败,请稍后重试' };
    }
}

// --- 客户端组件中使用 useActionState ---
// 'use client';
// import { useActionState } from 'react';

function CreateAccountForm() {
    // const [state, formAction, isPending] = useActionState(createAccount, {
    //     success: false, message: '',
    // });
    // return (
    //     <form action={formAction}>
    //         {state.message && (
    //             <div className={state.success ? 'success' : 'error'}>
    //                 {state.message}
    //             </div>
    //         )}
    //         <input name="username" placeholder="用户名" />
    //         {state.errors?.username && (
    //             <span className="error">{state.errors.username[0]}</span>
    //         )}
    //         <button type="submit" disabled={isPending}>
    //             {isPending ? '创建中...' : '创建账户'}
    //         </button>
    //     </form>
    // );
    return null;  // 占位:实际需在 'use client' 文件中使用
}


// ============================================================
//               4. useFormStatus
// ============================================================

/**
 * 【useFormStatus — 提交状态】
 *
 * 获取父级 form 的提交状态,必须在 form 的子组件中使用。
 *
 * 签名:
 *   const { pending, data, method, action } = useFormStatus();
 *
 * 典型用法:创建可复用的提交按钮组件
 */

// 'use client';
// import { useFormStatus } from 'react-dom';

// --- 通用提交按钮 ---
function SubmitButton({ children }: { children: React.ReactNode }) {
    // const { pending } = useFormStatus();
    // return (
    //     <button type="submit" disabled={pending}>
    //         {pending ? '提交中...' : children}
    //     </button>
    // );
    return null;
}

// --- 显示提交详情 ---
function FormStatusDisplay() {
    // const { pending, data, method } = useFormStatus();
    // if (!pending) return null;
    // return (
    //     <div className="status">
    //         <p>正在通过 {method} 方法提交...</p>
    //         <p>提交的邮箱:{data?.get('email')}</p>
    //     </div>
    // );
    return null;
}


// ============================================================
//               5. 数据验证
// ============================================================

/**
 * 【数据验证 — Zod Schema Validation】
 *
 * Server Actions 中应始终验证输入数据:
 * - 永远不要信任客户端传来的数据
 * - 推荐使用 Zod 进行类型安全的验证
 * - 验证失败时返回结构化错误信息
 */

// import { z } from 'zod';
// const CreateProductSchema = z.object({
//     name: z.string().min(2, '商品名至少 2 字').max(100),
//     price: z.coerce.number().positive('价格必须为正数'),
//     category: z.enum(['electronics', 'clothing', 'food']),
// });

async function createProduct(prevState: FormState, formData: FormData): Promise<FormState> {
    'use server';

    const rawData = {
        name: formData.get('name'),
        price: formData.get('price'),
        category: formData.get('category'),
    };

    // const result = CreateProductSchema.safeParse(rawData);
    // if (!result.success) {
    //     return { success: false, message: '验证失败',
    //              errors: result.error.flatten().fieldErrors };
    // }
    // await db.product.create({ data: result.data });

    revalidatePath('/products');
    return { success: true, message: '商品创建成功!' };
}


// ============================================================
//               6. 错误处理
// ============================================================

/**
 * 【错误处理 — Error Handling】
 *
 * 错误处理策略:
 * - 预期错误(验证失败)→ 返回错误状态
 * - 意外错误(数据库故障)→ try/catch 捕获
 * - 安全原则:不要将内部错误详情暴露给客户端
 */

type ActionResult = {
    success: boolean;
    message: string;
    errors?: Record<string, string[]>;
};

async function updateProfile(
    prevState: ActionResult,
    formData: FormData
): Promise<ActionResult> {
    'use server';

    try {
        // 1. 认证检查
        // const session = await getSession();
        // if (!session) return { success: false, message: '请先登录' };

        // 2. 数据验证
        const name = formData.get('name') as string;
        if (!name?.trim()) {
            return { success: false, message: '验证失败', errors: { name: ['姓名不能为空'] } };
        }

        // 3. 执行更新
        // await db.user.update({ where: { id: session.user.id }, data: { name } });
        revalidatePath('/profile');
        return { success: true, message: '个人资料已更新' };

    } catch (error) {
        console.error('更新资料失败:', error);
        // 不要将原始错误信息返回给客户端
        return { success: false, message: '服务器内部错误,请稍后重试' };
    }
}

// --- redirect 必须放在 try/catch 之外 ---
async function actionWithRedirect(formData: FormData) {
    'use server';

    try {
        // await db.item.create({ ... });
    } catch (error) {
        return { success: false, message: '操作失败' };
    }

    // redirect 通过抛出异常实现,不能在 try 块内调用
    redirect('/success');
}


// ============================================================
//               7. 乐观更新
// ============================================================

/**
 * 【乐观更新 — Optimistic Updates】
 *
 * useOptimistic 在 Server Action 完成前乐观地更新 UI:
 * - 用户操作后立即显示预期结果
 * - 失败时自动回滚
 *
 * 签名:
 *   const [optimisticState, addOptimistic] = useOptimistic(
 *       currentState,
 *       (current, optimisticValue) => newState
 *   );
 */

// 'use client';
// import { useOptimistic } from 'react';

type Todo = { id: string; text: string; completed: boolean; pending?: boolean };

// --- 乐观添加待办 ---
function OptimisticTodoList({ todos }: { todos: Todo[] }) {
    // const [optimisticTodos, addOptimistic] = useOptimistic(
    //     todos,
    //     (current: Todo[], newTodo: Todo) => [...current, { ...newTodo, pending: true }]
    // );
    // async function handleAdd(formData: FormData) {
    //     addOptimistic({ id: crypto.randomUUID(), text: formData.get('text'), completed: false });
    //     await addTodoAction(formData.get('text') as string);
    // }
    // return (
    //     <form action={handleAdd}>
    //         <input name="text" />
    //         <button>添加</button>
    //         <ul>{optimisticTodos.map(t => (
    //             <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
    //         ))}</ul>
    //     </form>
    // );
    return null;
}

// --- 乐观点赞 ---
function OptimisticLikeButton({ likes, postId }: { likes: number; postId: string }) {
    // const [optimisticLikes, addLike] = useOptimistic(likes, (c, i: number) => c + i);
    // return <button onClick={() => { addLike(1); likePostAction(postId); }}>
    //     点赞 ({optimisticLikes})
    // </button>;
    return null;
}

async function likePostAction(postId: string) {
    'use server';
    // await db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
    revalidateTag('posts');
}


// ============================================================
//               8. 最佳实践
// ============================================================

/**
 * 【Server Actions 最佳实践】
 *
 * ✅ 推荐做法:
 * - 将 Server Actions 放在独立 actions.ts 文件中统一管理
 * - 始终验证输入数据,推荐 Zod 做 schema 验证
 * - 使用 useActionState 管理表单状态和错误反馈
 * - 操作完成后调用 revalidatePath/revalidateTag 刷新缓存
 * - 使用 try/catch 处理意外错误
 * - 敏感操作前检查用户认证和权限
 * - 使用 useOptimistic 提升交互体验
 *
 * ❌ 避免做法:
 * - 避免返回敏感信息(密码哈希、内部错误详情)
 * - 避免将 redirect 写在 try 块内
 * - 避免直接信任 FormData,始终做服务端验证
 * - 避免在服务端组件中使用 useActionState(它是客户端 hook)
 * - 避免执行耗时过长的任务(考虑队列)
 *
 * 【Server Actions vs Route Handlers】
 *
 *   表单提交/数据变更       → Server Actions
 *   RESTful API / Webhook  → Route Handlers
 *   自定义 HTTP 响应头      → Route Handlers
 *   大文件上传/流式响应      → Route Handlers
 *
 * 【文件组织推荐】
 *
 *   app/
 *   ├── actions/           # 集中管理 Server Actions
 *   │   ├── auth.ts
 *   │   ├── posts.ts
 *   │   └── users.ts
 *   ├── lib/
 *   │   └── validations.ts # Zod schema 定义
 *   └── (routes)/
 *       └── page.tsx        # 页面组件引用 actions
 */

💬 讨论

使用 GitHub 账号登录后即可参与讨论

基于 MIT 许可发布