Skip to content

Lesson 20:Server Actions — 全栈 CRUD 与表单处理 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:90~150 分钟(首次学习)
  • 先修要求:完成 L19 的 Prisma 配置;写入验收还需 L21 的管理员认证
  • 学习产出:编写带类型、输入校验和授权检查的商品 Actions 与表单
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:使用 Next.js Server Actions 实现无需 API 路由的全栈数据操作,掌握错误处理、缓存刷新和 React 19 的 useActionState Hook。

📦 本节产出:商品管理列表、新增表单和增删改 Actions;编辑页面与乐观更新作为练习。管理员登录在 L21 完成后再验收写入。

一、什么是 Server Actions? ​

在传统的全栈开发中,前端和后端的通信链路是这样的:

Server Actions 让框架处理客户端到服务端的调用协议,省去手写这一条 API 路由;网络请求仍然存在:


二、创建 Server Actions ​

先安装与 Phase 2 一致的 Zod 4:

bash
npm install zod@4

Server Actions 是可从网络触发的服务端入口。隐藏按钮和保护页面都不能替代 Action 自身的授权检查,见 Next.js 数据安全文档。本课还没有登录能力,所以先让授权函数默认拒绝访问。L21 会用真实 session 和数据库角色替换它;此时不要把下面的 throw 改成放行。

ts
// src/lib/authorization.ts(L21 完成认证后替换此文件)
import 'server-only'

export async function requireAdmin(): Promise<void> {
  throw new Error('请先完成 L21 的认证配置,再使用管理员功能')
}
ts
// src/app/admin/products/types.ts
export type ProductFormState = {
  message?: string
  errors?: Partial<Record<'name' | 'description' | 'price' | 'category' | 'stock', string[]>>
} | null
ts
// src/app/admin/products/actions.ts
'use server'

import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/authorization'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import type { ProductFormState } from './types'

const ProductSchema = z.object({
  name: z.string().trim().min(1, '商品名称不能为空').max(100),
  description: z.string().trim().max(5000),
  // 表单输入元,按十进制字符串转换为分,避免浮点乘法误差。
  price: z.string().regex(/^\d{1,6}(\.\d{1,2})?$/, '请输入最多两位小数的金额')
    .transform(value => {
      const [yuan, fraction = ''] = value.split('.')
      return Number(yuan) * 100 + Number(fraction.padEnd(2, '0'))
    }).pipe(z.number().int().min(1).max(10_000_000)),
  category: z.enum(['book', 'electronics', 'clothing']),
  stock: z.string().regex(/^\d+$/, '库存必须是非负整数')
    .transform(Number).pipe(z.number().int().min(0).max(100_000)),
})

function parseProduct(formData: FormData) {
  return ProductSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description') ?? '',
    price: formData.get('price'),
    category: formData.get('category'),
    stock: formData.get('stock'),
  })
}

function refreshProducts(id?: string) {
  revalidatePath('/admin/products')
  revalidatePath('/products')
  if (id) revalidatePath(`/products/${id}`)
}

export async function createProduct(
  _prevState: ProductFormState, formData: FormData,
): Promise<ProductFormState> {
  void _prevState
  await requireAdmin()
  const parsed = parseProduct(formData)
  if (!parsed.success) {
    return { errors: z.flattenError(parsed.error).fieldErrors, message: '表单校验失败' }
  }
  try {
    await prisma.product.create({ data: parsed.data })
  } catch {
    return { message: '创建商品失败,请重试' }
  }
  refreshProducts()
  redirect('/admin/products') // redirect 会抛出框架控制异常,放在 try/catch 外
}

export async function deleteProduct(
  productId: string, _prevState: ProductFormState, _formData: FormData,
): Promise<ProductFormState> {
  void _prevState
  void _formData
  await requireAdmin()
  if (!z.string().min(1).max(100).safeParse(productId).success) {
    return { message: '商品 ID 无效' }
  }
  try {
    await prisma.product.delete({ where: { id: productId } })
  } catch {
    return { message: '删除失败:商品可能已删除或已被订单引用' }
  }
  refreshProducts(productId)
  return { message: '已删除' }
}

export async function updateProduct(
  productId: string, _prevState: ProductFormState, formData: FormData,
): Promise<ProductFormState> {
  void _prevState
  await requireAdmin()
  if (!z.string().min(1).max(100).safeParse(productId).success) {
    return { message: '商品 ID 无效' }
  }
  const parsed = parseProduct(formData)
  if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors }
  try {
    await prisma.product.update({ where: { id: productId }, data: parsed.data })
  } catch {
    return { message: '更新失败' }
  }
  refreshProducts(productId)
  redirect('/admin/products')
}

给所有管理页添加服务端入口检查。Action 中仍保留独立检查,因为布局不会覆盖外部直接调用:

tsx
// src/app/admin/layout.tsx
import { requireAdmin } from '@/lib/authorization'

export const dynamic = 'force-dynamic' // 构建时不执行需要请求身份的管理页

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  await requireAdmin()
  return <>{children}</>
}

IMPORTANT

Server Actions 的错误处理最佳实践:

  • 业务可预期错误(如表单校验失败、库存不足)优先 return 结构化结果
  • 不可恢复的异常(如数据库连接中断)可以 throw,交给错误边界兜底
  • 前端组件根据返回值显示可操作的友好提示

三、React 19 useActionState — 管理表单状态 ​

tsx
// src/app/admin/products/new/page.tsx
'use client'

import { useActionState } from 'react'
import { createProduct } from '../actions'

export default function NewProductPage() {
  // useActionState 替代了之前的 useFormState
  // 参数:(action函数, 初始state)
  // 返回:[当前state, 包装后的action, 是否pending]
  const [state, action, isPending] = useActionState(createProduct, null)

  return (
    <div className="max-w-2xl mx-auto px-4 py-12">
      <h1 className="text-2xl font-bold mb-8">新增商品</h1>

      {/* 全局错误提示 */}
      {state?.message && (
        <div role="alert" className="bg-red-50 text-red-600 p-4 rounded-xl mb-6 text-sm">
          ⚠️ {state.message}
        </div>
      )}

      <form action={action} className="space-y-6">
        <div>
          <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">商品名称</label>
          <input id="name" name="name" required
            className="w-full border rounded-xl px-4 py-3 focus:ring-2 focus:ring-indigo-500" />
          {/* 字段级错误提示 */}
          {state?.errors?.name && (
            <p className="text-red-500 text-xs mt-1">{state.errors.name[0]}</p>
          )}
        </div>

        <div>
          <label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">价格 (元)</label>
          <input id="price" name="price" type="number" min="0.01" max="100000" step="0.01" required
            className="w-full border rounded-xl px-4 py-3" />
          {state?.errors?.price && (
            <p className="text-red-500 text-xs mt-1">{state.errors.price[0]}</p>
          )}
        </div>

        <div>
          <label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">分类</label>
          <select id="category" name="category" required
            className="w-full border rounded-xl px-4 py-3">
            <option value="">选择分类</option>
            <option value="book">📚 图书</option>
            <option value="electronics">💻 电子</option>
            <option value="clothing">👕 服饰</option>
          </select>
          {state?.errors?.category && (
            <p className="text-red-500 text-xs mt-1">{state.errors.category[0]}</p>
          )}
        </div>

        <div>
          <label htmlFor="stock" className="block text-sm font-medium text-gray-700 mb-1">库存</label>
          <input id="stock" name="stock" type="number" min="0" max="100000" step="1" required defaultValue={0}
            className="w-full border rounded-xl px-4 py-3" />
          {state?.errors?.stock && <p role="alert">{state.errors.stock[0]}</p>}
        </div>

        <div>
          <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">描述</label>
          <textarea id="description" name="description" rows={3} maxLength={5000}
            className="w-full border rounded-xl px-4 py-3" />
          {state?.errors?.description && <p role="alert">{state.errors.description[0]}</p>}
        </div>

        <button type="submit" disabled={isPending}
          className="w-full bg-indigo-600 text-white py-3 rounded-xl font-bold hover:bg-indigo-700 disabled:opacity-50 transition-colors">
          {isPending ? '⏳ 创建中...' : '创建商品'}
        </button>
      </form>
    </div>
  )
}

四、useFormStatus — 让提交按钮自动感知表单状态 ​

在上面的代码中,我们通过 useActionState 返回的 isPending 来禁用提交按钮。但如果提交按钮是一个独立的可复用组件,你就需要把 isPending 作为 prop 传进去。

React 19 提供了 useFormStatus,让你在不传 prop 的情况下,在任何表单子组件内部获取表单状态:

tsx
// src/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }: { children: React.ReactNode }) {
  // 会自动找到最近的祖先 <form> 的提交状态!
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}
      className="w-full bg-indigo-600 text-white py-3 rounded-xl font-bold
                 hover:bg-indigo-700 disabled:opacity-50 transition-colors">
      {pending ? '⏳ 提交中...' : children}
    </button>
  )
}

使用时,只需把它放在 <form> 内部即可:

tsx
// 在表单文件顶部补充 import { SubmitButton } from '@/components/SubmitButton'
<form action={action} className="space-y-6">
  {/* ...其他表单字段... */}
  <SubmitButton>创建商品</SubmitButton>  {/* 不需要传 isPending! */}
</form>

IMPORTANT

useFormStatus 必须在 <form> 的子组件中使用。 如果你在包含 <form> 的同一个组件里调用它,它会找不到表单上下文。必须抽成子组件!


五、商品管理列表页(Server Component) ​

tsx
// src/app/admin/products/page.tsx
import { prisma } from '@/lib/prisma'
import DeleteProductButton from './DeleteProductButton'
import { requireAdmin } from '@/lib/authorization'
import Link from 'next/link'

export default async function AdminProductsPage() {
  await requireAdmin()
  const products = await prisma.product.findMany({
    orderBy: { createdAt: 'desc' }
  })

  return (
    <div className="max-w-5xl mx-auto px-4 py-12">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-2xl font-bold">商品管理</h1>
        <Link href="/admin/products/new"
          className="bg-indigo-600 text-white px-4 py-2 rounded-xl hover:bg-indigo-700">
          + 新增商品
        </Link>
      </div>

      <div className="bg-white rounded-xl border overflow-hidden">
        <table className="w-full">
          <thead className="bg-gray-50 text-sm text-gray-500">
            <tr>
              <th className="text-left p-4">商品名</th>
              <th className="text-left p-4">分类</th>
              <th className="text-right p-4">价格</th>
              <th className="text-right p-4">库存</th>
              <th className="text-right p-4">操作</th>
            </tr>
          </thead>
          <tbody className="divide-y">
            {products.map(product => (
              <tr key={product.id} className="hover:bg-gray-50">
                <td className="p-4 font-medium">{product.name}</td>
                <td className="p-4 text-sm text-gray-500">{product.category}</td>
                <td className="p-4 text-right">¥{(product.price / 100).toFixed(2)}</td>
                <td className="p-4 text-right">{product.stock}</td>
                <td className="p-4 text-right">
                  <DeleteProductButton productId={product.id} />
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  )
}

删除也需要显示失败结果,不能把 Action 的返回值丢掉:

tsx
// src/app/admin/products/DeleteProductButton.tsx
'use client'
import { useActionState } from 'react'
import { deleteProduct } from './actions'

export default function DeleteProductButton({ productId }: { productId: string }) {
  const [state, action, pending] = useActionState(deleteProduct.bind(null, productId), null)
  return (
    <form action={action}>
      <button disabled={pending} type="submit" className="text-red-500 disabled:opacity-50">
        {pending ? '删除中…' : '删除'}
      </button>
      {state?.message && <p role="status">{state.message}</p>}
    </form>
  )
}

六、🧠 深度专题:全栈类型安全 ​

Server Actions 的一大优势是你可以共享类型。从 Prisma Schema → Zod 验证 → 前端表单,一条类型链贯穿全栈:

Prisma 类型与 Zod schema 在本课中分别维护,并不会自动互相生成。数据库字段约束和表单规则也不完全相同:表单输入的价格是字符串,验证后才转换为数据库需要的整数分。第三方生成器需另行核对 Prisma/Zod 版本兼容性。


七、revalidatePath vs revalidateTag — 缓存刷新策略 ​

ts
// 下列调用放在 Server Action 等允许重新验证的服务端入口中。
import { revalidatePath, revalidateTag } from 'next/cache'

revalidatePath('/products')
revalidatePath('/products/[id]', 'page') // 动态路由模式必须指定类型
revalidateTag('products') // Next.js 15 的单参数写法

按标签重新验证,需要先给缓存结果绑定同一标签,例如单独的数据模块:

ts
// src/lib/cached-products.ts(缓存机制演示,可选)
import 'server-only'
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

export const getCachedProducts = unstable_cache(
  () => prisma.product.findMany(),
  ['products'],
  { tags: ['products'] },
)

调用 getCachedProducts() 才会使用这份缓存;普通 Prisma 查询不会自动带上标签。本课列表仍使用动态查询。路径与标签的作用范围不同,参见 Next.js 15 revalidatePath。


本课完成时,/admin/products 被拒绝访问是预期结果。先保留受保护的表单和 Actions;完成 L21 并替换授权模块后,再用管理员账户检查新增、更新、删除及错误提示。

八、练习 ​

  1. 在删除商品时添加确认弹窗(使用 Lesson 13 学过的 Dialog 组件)。
  2. 实现商品编辑页面 /admin/products/[id]/edit,使用 updateProduct Action。
  3. 使用 useOptimistic(Lesson 15)让删除操作变成乐观更新——点击删除后立即从列表中移除。

📌 本节小结 ​

你做了什么你学到了什么
编写了增删改的受保护 Actions(L21 后验收写入)"use server" 声明 + Zod 验证
实现了带错误提示的表单页面useActionState + field-level errors
构建了商品管理后台列表Server Component 查询 + 客户端删除反馈
—revalidatePath vs revalidateTag 缓存刷新
—全栈类型安全链路:Prisma → Zod → Action → UI

项目驱动 · 边写边学 · React 19