Lesson 20:Server Actions — 全栈 CRUD 与表单处理
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:90~150 分钟(首次学习)
- 先修要求:完成 L19 的 Prisma 配置;写入验收还需 L21 的管理员认证
- 学习产出:编写带类型、输入校验和授权检查的商品 Actions 与表单
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:使用 Next.js Server Actions 实现无需 API 路由的全栈数据操作,掌握错误处理、缓存刷新和 React 19 的
useActionStateHook。📦 本节产出:商品管理列表、新增表单和增删改 Actions;编辑页面与乐观更新作为练习。管理员登录在 L21 完成后再验收写入。
一、什么是 Server Actions?
在传统的全栈开发中,前端和后端的通信链路是这样的:
Server Actions 让框架处理客户端到服务端的调用协议,省去手写这一条 API 路由;网络请求仍然存在:
二、创建 Server Actions
先安装与 Phase 2 一致的 Zod 4:
npm install zod@4Server Actions 是可从网络触发的服务端入口。隐藏按钮和保护页面都不能替代 Action 自身的授权检查,见 Next.js 数据安全文档。本课还没有登录能力,所以先让授权函数默认拒绝访问。L21 会用真实 session 和数据库角色替换它;此时不要把下面的 throw 改成放行。
// src/lib/authorization.ts(L21 完成认证后替换此文件)
import 'server-only'
export async function requireAdmin(): Promise<void> {
throw new Error('请先完成 L21 的认证配置,再使用管理员功能')
}// src/app/admin/products/types.ts
export type ProductFormState = {
message?: string
errors?: Partial<Record<'name' | 'description' | 'price' | 'category' | 'stock', string[]>>
} | null// 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 中仍保留独立检查,因为布局不会覆盖外部直接调用:
// 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 — 管理表单状态
// 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 的情况下,在任何表单子组件内部获取表单状态:
// 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> 内部即可:
// 在表单文件顶部补充 import { SubmitButton } from '@/components/SubmitButton'
<form action={action} className="space-y-6">
{/* ...其他表单字段... */}
<SubmitButton>创建商品</SubmitButton> {/* 不需要传 isPending! */}
</form>IMPORTANT
useFormStatus 必须在 <form> 的子组件中使用。 如果你在包含 <form> 的同一个组件里调用它,它会找不到表单上下文。必须抽成子组件!
五、商品管理列表页(Server Component)
// 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 的返回值丢掉:
// 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 — 缓存刷新策略
// 下列调用放在 Server Action 等允许重新验证的服务端入口中。
import { revalidatePath, revalidateTag } from 'next/cache'
revalidatePath('/products')
revalidatePath('/products/[id]', 'page') // 动态路由模式必须指定类型
revalidateTag('products') // Next.js 15 的单参数写法按标签重新验证,需要先给缓存结果绑定同一标签,例如单独的数据模块:
// 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 并替换授权模块后,再用管理员账户检查新增、更新、删除及错误提示。
八、练习
- 在删除商品时添加确认弹窗(使用 Lesson 13 学过的
Dialog组件)。 - 实现商品编辑页面
/admin/products/[id]/edit,使用updateProductAction。 - 使用
useOptimistic(Lesson 15)让删除操作变成乐观更新——点击删除后立即从列表中移除。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 编写了增删改的受保护 Actions(L21 后验收写入) | "use server" 声明 + Zod 验证 |
| 实现了带错误提示的表单页面 | useActionState + field-level errors |
| 构建了商品管理后台列表 | Server Component 查询 + 客户端删除反馈 |
| — | revalidatePath vs revalidateTag 缓存刷新 |
| — | 全栈类型安全链路:Prisma → Zod → Action → UI |