第 11 章

Server Actions:表单与数据变更的新范式

第11章:Server Actions:表单与数据变更的新范式

Server Actions 将表单提交这个已存在几十年的 Web 原语与现代 TypeScript 全栈开发无缝连接——渐进增强、类型安全、与缓存系统深度集成。

本章核心问题:'use server' 指令如何工作?Server Actions 如何实现渐进增强?如何构建类型安全的表单验证?

读完本章你将理解


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 的三个返回值各司其职:

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

变更后的缓存失效

变更后必须同步更新缓存,否则用户会看到陈旧数据:

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 缓存系统的深度集成(变更后立即失效相关缓存)。这三者合一,让表单处理从一个繁琐的基础设施问题变成了清晰的业务逻辑表达。

本章评分
4.8  / 5  (29 评分)

💬 留言讨论