Server Actions:表单与数据变更的新范式
第11章:Server Actions:表单与数据变更的新范式
Server Actions 将表单提交这个已存在几十年的 Web 原语与现代 TypeScript 全栈开发无缝连接——渐进增强、类型安全、与缓存系统深度集成。
本章核心问题:'use server' 指令如何工作?Server Actions 如何实现渐进增强?如何构建类型安全的表单验证?
读完本章你将理解:
- 文件级和内联两种 'use server' 用法
- useActionState 管理表单状态、Zod 实现字段级验证
- 变更后的缓存失效策略(revalidateTag/revalidatePath)
Level 1 · 你需要知道的(1-3年经验)
重新理解"表单提交"
在 Web 开发的历史中,表单提交经历了三个时代:纯 HTML 表单提交(整页刷新)、Ajax 时代(XMLHttpRequest / fetch,前端拦截表单)、以及 Next.js 带来的 Server Actions(在保持渐进增强的同时获得完整的 TypeScript 类型安全和服务端执行能力)。
Server Actions 的核心是:一个标记了 'use server' 指令的异步函数,可以直接绑定到 HTML 表单的 action 属性。当表单提交时,Next.js 会通过 HTTP POST 请求将表单数据传递给这个函数——即使 JavaScript 尚未加载或被禁用,这依然有效。这就是渐进增强(Progressive Enhancement)在现代框架中的体现。
'use server' 指令的两种用法
文件级指令
在文件顶部添加 'use server',该文件导出的所有异步函数都成为 Server Action:
// app/actions/product.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
await prisma.product.create({ data: { name, price } })
revalidateTag('products')
redirect('/products')
}
export async function deleteProduct(id: string) {
await prisma.product.delete({ where: { id } })
revalidateTag('products')
}
内联指令
在 Server Component 内部,可以将单个函数标记为 Server Action:
// app/products/new/page.tsx(Server Component)
export default function NewProductPage() {
async function createProduct(formData: FormData) {
'use server' // 内联指令,只影响这个函数
const name = formData.get('name') as string
await prisma.product.create({ data: { name } })
redirect('/products')
}
return (
<form action={createProduct}>
<input name="name" type="text" required />
<button type="submit">创建</button>
</form>
)
}
内联指令适合简单的一次性操作,文件级指令更适合需要复用的业务逻辑。
Level 2 · 它是怎么运行的(3-5年经验)
渐进增强:无 JavaScript 也能工作
这是 Server Actions 最被低估的特性。当你将 Server Action 绑定到 <form action={...}>时,表单在没有 JavaScript 的环境中也能正常工作:
// 这个表单完全不依赖 JavaScript 就能提交
export default function ContactForm() {
async function sendMessage(formData: FormData) {
'use server'
const message = formData.get('message') as string
await sendEmail(message)
redirect('/thanks')
}
return (
<form action={sendMessage}>
<textarea name="message" rows={5} required />
<button type="submit">发送</button>
</form>
)
}
浏览器将表单序列化为 multipart/form-data 并发送到 Next.js 的内部端点,Next.js 反序列化后调用 Server Action。整个过程中 JavaScript 是可选的增强,而非必需。这对于追求高可访问性或在低网速环境下运行的应用至关重要。
useActionState:管理表单状态
真实的表单需要处理加载状态、错误反馈、成功消息。useActionState Hook 专门为此而设计:
// app/products/new/page.tsx
'use client'
import { useActionState } from 'react'
import { createProduct } from '@/app/actions/product'
type ActionState = {
error?: string
success?: boolean
}
export default function NewProductForm() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
createProduct,
{ error: undefined, success: false }
)
return (
<form action={formAction}>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.success && (
<div className="success">商品创建成功!</div>
)}
<input name="name" type="text" required disabled={isPending} />
<input name="price" type="number" step="0.01" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '创建中...' : '创建商品'}
</button>
</form>
)
}
对应的 Server Action 需要接受 prevState 参数:
// app/actions/product.ts
'use server'
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(1, '商品名称不能为空').max(100),
price: z.coerce.number().positive('价格必须大于 0'),
})
type ActionState = { error?: string; success?: boolean }
export async function createProduct(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const raw = {
name: formData.get('name'),
price: formData.get('price'),
}
const parsed = ProductSchema.safeParse(raw)
if (!parsed.success) {
return { error: parsed.error.errors[0].message }
}
try {
await prisma.product.create({ data: parsed.data })
revalidateTag('products')
return { success: true }
} catch (e) {
return { error: '数据库写入失败,请重试' }
}
}
useActionState 的三个返回值各司其职:
state:上一次 Action 执行后的返回值(初始为第二个参数)formAction:绑定到<form action>的增强版 ActionisPending:Action 正在执行时为true,可用于显示 loading 状态
Zod 验证:让错误信息精确到字段
上面的例子中,错误消息是单条字符串。实际应用中通常需要字段级别的错误提示:
// 返回结构化的字段错误
type FieldErrors = {
name?: string[]
price?: string[]
_form?: string[] // 表单级别的错误
}
type ActionState = { errors?: FieldErrors; success?: boolean }
export async function createProduct(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = ProductSchema.safeParse({
name: formData.get('name'),
price: formData.get('price'),
})
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors as FieldErrors,
}
}
try {
await prisma.product.create({ data: parsed.data })
revalidateTag('products')
return { success: true }
} catch (e) {
return { errors: { _form: ['服务器错误,请稍后重试'] } }
}
}
在表单中使用字段级错误:
<div>
<input name="name" aria-describedby="name-error" />
{state.errors?.name && (
<p id="name-error" className="text-red-500 text-sm">
{state.errors.name[0]}
</p>
)}
</div>
Level 3 · 规范怎么定义的(资深)
安全性:内置 CSRF 防护与强制输入验证
Server Actions 有两个重要的内置安全机制:
CSRF 防护:Next.js 自动为 Server Actions 添加了 Origin 检查。Action 请求必须来自与应用同源的页面,跨域的恶意请求会被拒绝。这个保护是框架层面透明实现的,无需手动添加 CSRF token。
绝对不能信任客户端数据:尽管有 CSRF 防护,Server Action 接收到的数据依然必须视为不可信的用户输入:
export async function updateProduct(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 永远不要直接使用 formData 中的 id 来执行权限敏感操作
// 而应该从服务端会话中获取当前用户,然后验证权限
const session = await getServerSession()
if (!session?.user) {
return { errors: { _form: ['请先登录'] } }
}
const productId = formData.get('id') as string
// 验证当前用户是否有权限修改这个商品
const product = await prisma.product.findUnique({
where: { id: productId },
select: { ownerId: true },
})
if (product?.ownerId !== session.user.id) {
return { errors: { _form: ['无权限执行此操作'] } }
}
// 验证通过后再执行变更
const parsed = ProductSchema.safeParse(/* ... */)
// ...
}
完整 CRUD 示例:商品管理
将上述知识整合为一个完整的商品管理 CRUD 系统:
// app/actions/product.ts
'use server'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'
const ProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.coerce.number().positive(),
description: z.string().optional(),
})
type State = { errors?: Record<string, string[]>; message?: string }
// 创建
export async function createProduct(_: State, formData: FormData): Promise<State> {
const session = await getServerSession()
if (!session) return { errors: { _form: ['Unauthorized'] } }
const parsed = ProductSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
await prisma.product.create({
data: { ...parsed.data, ownerId: session.user.id },
})
revalidateTag('products')
redirect('/products')
}
// 更新
export async function updateProduct(id: string, _: State, formData: FormData): Promise<State> {
const session = await getServerSession()
if (!session) return { errors: { _form: ['Unauthorized'] } }
const product = await prisma.product.findUnique({ where: { id } })
if (product?.ownerId !== session.user.id) {
return { errors: { _form: ['Forbidden'] } }
}
const parsed = ProductSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
await prisma.product.update({ where: { id }, data: parsed.data })
revalidatePath(`/products/${id}`)
revalidateTag('products')
return { message: '更新成功' }
}
// 删除(使用 .bind 预绑定参数)
export async function deleteProduct(id: string) {
const session = await getServerSession()
if (!session) throw new Error('Unauthorized')
await prisma.product.delete({ where: { id } })
revalidateTag('products')
redirect('/products')
}
在组件中使用预绑定参数的 Server Action:
// app/products/[id]/page.tsx
import { deleteProduct } from '@/app/actions/product'
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
const deleteWithId = deleteProduct.bind(null, params.id) // 预绑定 id
return (
<div>
<h1>{product.name}</h1>
<form action={deleteWithId}>
<button type="submit">删除商品</button>
</form>
</div>
)
}
.bind(null, params.id) 是将参数预绑定到 Server Action 的惯用方式。这样绑定后的函数签名与普通的无参数 Action 相同,可以直接用作 form action。
变更后的缓存失效
变更后必须同步更新缓存,否则用户会看到陈旧数据:
revalidateTag('products'):使所有带'products'标签的缓存失效revalidatePath('/products'):使特定路径的 Full Route Cache 失效revalidatePath('/products', 'layout'):同时失效该路径下所有子路由的缓存
redirect() 必须在 revalidateTag/revalidatePath 之后调用。redirect 在内部抛出一个特殊错误,如果放在 try-catch 中可能被意外捕获——这是一个常见的陷阱。
Level 4 · 边界与陷阱(所有人)
陷阱1:redirect() 必须在 revalidateTag/revalidatePath 之后调用——redirect 内部抛出特殊错误,放在 try-catch 中会被意外捕获。
陷阱2:Server Action 接收到的数据必须视为不可信的用户输入——即使有 CSRF 防护,也必须在服务端做完整的权限验证和输入校验。
陷阱3:.bind(null, id) 是将参数预绑定到 Server Action 的惯用方式——绑定后的函数可以直接用作 form action。
小结
Server Actions 将"表单提交"这个已存在几十年的 Web 原语与现代 TypeScript 全栈开发无缝连接。其关键创新在于三点:渐进增强(无 JS 也能工作)、类型安全的服务端验证(Zod 集成)、以及与 Next.js 缓存系统的深度集成(变更后立即失效相关缓存)。这三者合一,让表单处理从一个繁琐的基础设施问题变成了清晰的业务逻辑表达。