Next.js

数据更新

深入理解 Next.js 数据更新,包括 Server Functions、Server Actions、表单处理、数据重新验证和重定向

Server Functions 概述

Server Function 是在服务器上运行的异步函数。它们可以通过网络请求从客户端调用,这就是为什么它们必须是异步的。

在 action 或 mutation 上下文中,它们也被称为 Server Actions。

按照惯例,Server Action 是与 startTransition 一起使用的异步函数。当函数被以下方式使用时,这会自动发生:

  • 使用 action 属性传递给 <form>
  • 使用 formAction 属性传递给 <button>

在 Next.js 中,Server Actions 与框架的缓存架构集成。当调用 action 时,Next.js 可以在单次服务器往返中返回更新的 UI 和新数据。

在幕后,actions 使用 POST 方法,只有这个 HTTP 方法可以调用它们。

创建 Server Functions

Server Function 可以通过使用 use server 指令来定义。你可以将指令放在异步函数的顶部以将函数标记为 Server Function,或放在单独文件的顶部以标记该文件的所有导出。

文件级别

'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')

  // 更新数据
  // 重新验证缓存
}

export async function deletePost(formData: FormData) {
  const id = formData.get('id')

  // 更新数据
  // 重新验证缓存
}

函数级别

export default function Page() {
  async function createPost(formData: FormData) {
    'use server'
    
    const title = formData.get('title')
    const content = formData.get('content')

    // 更新数据
    // 重新验证缓存
  }

  return <form action={createPost}>...</form>
}

调用 Server Functions

在 Server Components 中

Server Components 支持渐进式增强,这意味着即使 JavaScript 尚未加载或被禁用,调用 Server Actions 的表单也会被提交。

export default function Page() {
  async function createPost(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')
  }

  return (
    <form action={createPost}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit">Create Post</button>
    </form>
  )
}

在 Client Components 中

在 Client Components 中,调用 Server Actions 的表单将在 JavaScript 未加载时排队提交,并将优先进行水合。水合后,浏览器在表单提交时不会刷新。

使用 action 属性

'use client'

import { createPost } from '@/app/lib/actions'

export default function Page() {
  return (
    <form action={createPost}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit">Create Post</button>
    </form>
  )
}

使用 startTransition

如果不使用 <form>,可以直接调用 Server Action 并使用 startTransition 来显示加载状态:

'use client'

import { createPost } from '@/app/lib/actions'
import { useTransition } from 'react'

export default function Page() {
  const [isPending, startTransition] = useTransition()

  const handleSubmit = (formData: FormData) => {
    startTransition(async () => {
      await createPost(formData)
    })
  }

  return (
    <form action={handleSubmit}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

使用 useActionState

使用 useActionState hook 可以访问额外的状态,如响应数据或错误:

'use client'

import { createPost } from '@/app/lib/actions'
import { useActionState } from 'react'

const initialState = {
  message: '',
}

export default function Page() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState
  )

  return (
    <form action={formAction}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

Server Action 需要返回状态:

'use server'

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  try {
    await db.post.create({
      data: { title, content }
    })
    
    revalidatePath('/posts')
    return { message: 'Post created successfully' }
  } catch (error) {
    return { message: 'Failed to create post' }
  }
}

使用事件处理器

可以在事件处理器(如 onClick)中调用 Server Action:

'use client'

import { incrementLike } from '@/app/lib/actions'
import { useState, useTransition } from 'react'

export default function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes)
  const [isPending, startTransition] = useTransition()

  const handleClick = () => {
    startTransition(async () => {
      const updatedLikes = await incrementLike()
      setLikes(updatedLikes)
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      Like ({likes})
    </button>
  )
}

使用 useEffect

可以使用 useEffect hook 在组件挂载或依赖项更改时调用 Server Action:

'use client'

import { incrementViews } from './actions'
import { useState, useEffect, useTransition } from 'react'

export default function ViewCount({ initialViews }: { initialViews: number }) {
  const [views, setViews] = useState(initialViews)
  const [isPending, startTransition] = useTransition()

  useEffect(() => {
    startTransition(async () => {
      const updatedViews = await incrementViews()
      setViews(updatedViews)
    })
  }, [])

  return <p>Total Views: {views}</p>
}

表单处理

基本表单

React 扩展了 HTML <form> 元素,允许使用 action 属性调用 Server Actions。

export default function Page() {
  async function createInvoice(formData: FormData) {
    'use server'

    const rawFormData = {
      customerId: formData.get('customerId'),
      amount: formData.get('amount'),
      status: formData.get('status'),
    }

    // 变更数据
    // 重新验证缓存
  }

  return (
    <form action={createInvoice}>
      <input type="text" name="customerId" />
      <input type="number" name="amount" />
      <select name="status">
        <option value="pending">Pending</option>
        <option value="paid">Paid</option>
      </select>
      <button type="submit">Create Invoice</button>
    </form>
  )
}

提取表单数据

使用 Object.fromEntries() 处理多个字段:

async function createInvoice(formData: FormData) {
  'use server'

  const rawFormData = Object.fromEntries(formData)
  // { customerId: '...', amount: '...', status: '...' }
}

表单验证

使用 Zod 等库进行服务器端验证:

'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export async function signup(formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // 处理注册逻辑
}

显示加载状态

使用 useActionState 显示待处理状态:

'use client'

import { signup } from '@/app/lib/actions'
import { useActionState } from 'react'

export default function SignupPage() {
  const [state, formAction, isPending] = useActionState(signup, null)

  return (
    <form action={formAction}>
      <input type="email" name="email" required />
      <input type="password" name="password" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Signing up...' : 'Sign up'}
      </button>
      {state?.errors && (
        <div>
          {state.errors.email && <p>{state.errors.email}</p>}
          {state.errors.password && <p>{state.errors.password}</p>}
        </div>
      )}
    </form>
  )
}

乐观更新

使用 useOptimistic hook 在 Server Action 完成之前乐观地更新 UI:

'use client'

import { useOptimistic } from 'react'
import { send } from './actions'

type Message = {
  message: string
}

export function Thread({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: string) => [
      ...state,
      { message: newMessage, sending: true },
    ]
  )

  const formAction = async (formData: FormData) => {
    const message = formData.get('message') as string
    addOptimisticMessage(message)
    await send(message)
  }

  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <div key={i}>
          {m.message}
          {m.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

嵌套表单元素

可以在 <form> 内嵌套的元素(如 <button><input type="submit">)中调用 Server Actions。这些元素接受 formAction 属性或事件处理器。

export default function Page() {
  async function publishPost(formData: FormData) {
    'use server'
    // 发布帖子
  }

  async function saveDraft(formData: FormData) {
    'use server'
    // 保存草稿
  }

  return (
    <form action={publishPost}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit">Publish</button>
      <button formAction={saveDraft}>Save Draft</button>
    </form>
  )
}

程序化表单提交

使用 requestSubmit() 方法程序化触发表单提交:

'use client'

export function Entry() {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === 'Enter' || e.key === 'NumpadEnter')
    ) {
      e.preventDefault()
      e.currentTarget.form?.requestSubmit()
    }
  }

  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  )
}

数据重新验证

revalidatePath

revalidatePath 允许你按需使特定路径的缓存数据失效。

revalidatePath(path: string, type?: 'page' | 'layout'): void

参数

  • path:要重新验证的数据对应的路由模式(例如 /product/[slug])或特定 URL(例如 /product/123)。不要附加 /page/layout,使用 type 参数代替。必须不超过 1024 个字符。此值区分大小写。
  • type:(可选)'page''layout' 字符串以更改要重新验证的路径类型。如果 path 包含动态段(例如 /product/[slug]),则此参数是必需的。如果 path 是特定 URL(/product/1),则省略 type

重新验证特定 URL

'use server'

import { revalidatePath } from 'next/cache'

export async function updatePost() {
  await updatePostInDatabase()
  revalidatePath('/blog/post-1')
}

重新验证页面路径

import { revalidatePath } from 'next/cache'

revalidatePath('/blog/[slug]', 'page')

这将在下次页面访问时使任何匹配的 URL 失效。例如,/blog/post-1 不会使 /blog/post-1/comments 失效。

重新验证布局路径

import { revalidatePath } from 'next/cache'

revalidatePath('/blog/[slug]', 'layout')

这将使任何匹配提供的 layout 文件的 URL 在下次页面访问时失效。这将导致具有相同布局的下方页面在下次访问时失效并重新验证。

重新验证所有数据

import { revalidatePath } from 'next/cache'

revalidatePath('/', 'layout')

这将清除客户端路由器缓存,并在下次页面访问时使数据缓存失效。

在 Server Action 中使用

'use server'

import { revalidatePath } from 'next/cache'

export default async function submit() {
  await submitForm()
  revalidatePath('/')
}

在 Route Handler 中使用

import { revalidatePath } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const path = request.nextUrl.searchParams.get('path')

  if (path) {
    revalidatePath(path)
    return Response.json({ revalidated: true, now: Date.now() })
  }

  return Response.json({
    revalidated: false,
    now: Date.now(),
    message: 'Missing path to revalidate',
  })
}

revalidateTag

revalidateTag 允许你按需使特定缓存标签的缓存数据失效(已在 Cache Components 笔记中详细介绍)。

'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost() {
  await updatePostInDatabase()
  revalidateTag('posts', 'max')
}

重定向

redirect 函数

redirect 函数允许你将用户重定向到另一个 URL。可以在 Server Components、Client Components、Route Handlers 和 Server Actions 中使用。

redirect(path, type)

参数

  • path:要重定向到的 URL。可以是相对路径或绝对路径。
  • type:要执行的重定向类型。
    • 'replace'(默认):替换浏览器历史堆栈中的当前 URL
    • 'push'(Server Actions 中的默认值):向浏览器历史堆栈添加新条目

在 Server Action 中使用

'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  const post = await db.post.create({
    data: { title, content }
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

在 Server Component 中使用

import { redirect } from 'next/navigation'

export default async function Page() {
  const user = await getUser()

  if (!user) {
    redirect('/login')
  }

  return <div>Welcome, {user.name}</div>
}

在 Client Component 中使用

'use client'

import { navigate } from '@/app/actions'

export default function Page() {
  return (
    <form action={navigate}>
      <input type="text" name="id" />
      <button type="submit">Navigate</button>
    </form>
  )
}
'use server'

import { redirect } from 'next/navigation'

export async function navigate(data: FormData) {
  redirect(`/posts/${data.get('id')}`)
}

permanentRedirect

对于永久重定向(308 状态码),使用 permanentRedirect

import { permanentRedirect } from 'next/navigation'

export default async function Page() {
  const user = await getUser()

  if (!user) {
    permanentRedirect('/login')
  }

  return <div>Welcome, {user.name}</div>
}

错误处理

在 Server Actions 中处理错误

可以使用 JavaScript 的 try/catch 语句处理错误:

'use server'

export async function createPost(formData: FormData) {
  try {
    const title = formData.get('title') as string
    const content = formData.get('content') as string

    await db.post.create({
      data: { title, content }
    })

    revalidatePath('/posts')
    return { success: true }
  } catch (error) {
    return { success: false, error: 'Failed to create post' }
  }
}

使用 useActionState 显示错误

'use client'

import { createPost } from '@/app/lib/actions'
import { useActionState } from 'react'

export default function Page() {
  const [state, formAction, isPending] = useActionState(createPost, null)

  return (
    <form action={formAction}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">Post created!</p>}
    </form>
  )
}

安全性

验证和授权

始终在 Server Actions 中验证用户输入并检查授权:

'use server'

import { auth } from '@/lib/auth'
import { z } from 'zod'

const schema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(1),
})

export async function createPost(formData: FormData) {
  // 验证用户身份
  const session = await auth()
  if (!session) {
    throw new Error('Unauthorized')
  }

  // 验证输入
  const validatedFields = schema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // 执行操作
  await db.post.create({
    data: {
      ...validatedFields.data,
      authorId: session.user.id,
    }
  })

  revalidatePath('/posts')
}

使用闭包捕获值

如果需要在 Server Action 中使用来自外部作用域的值,可以使用闭包:

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params

  async function deletePost() {
    'use server'
    // id 在这里可用
    await db.post.delete({ where: { id } })
    revalidatePath('/posts')
    redirect('/posts')
  }

  return (
    <form action={deletePost}>
      <button type="submit">Delete Post</button>
    </form>
  )
}

最佳实践

  1. 使用 Server Actions 进行数据变更:始终使用 Server Actions 而不是 API 路由来处理表单提交和数据变更。

  2. 验证所有输入:在服务器端验证所有用户输入,即使客户端已经验证过。

  3. 重新验证缓存:在数据变更后使用 revalidatePathrevalidateTag 重新验证相关缓存。

  4. 提供用户反馈:使用 useActionStateuseTransition 提供加载状态和错误消息。

  5. 处理错误:使用 try/catch 处理错误并向用户提供有意义的错误消息。

  6. 保护敏感操作:始终检查用户授权,特别是对于删除或更新操作。

  7. 使用渐进式增强:确保表单在 JavaScript 禁用时仍然可以工作(在 Server Components 中)。

  8. 乐观更新:对于更好的用户体验,使用 useOptimistic 在等待服务器响应时乐观地更新 UI。

总结

Next.js 提供了强大的数据更新机制:

  1. Server Functions/Actions:在服务器上运行的异步函数,可以从客户端调用
  2. 表单处理:使用 action 属性与 Server Actions 集成
  3. 状态管理:使用 useActionState 管理表单状态和错误
  4. 乐观更新:使用 useOptimistic 提供即时反馈
  5. 数据重新验证:使用 revalidatePathrevalidateTag 使缓存失效
  6. 重定向:使用 redirectpermanentRedirect 导航用户
  7. 渐进式增强:Server Components 中的表单即使在 JavaScript 禁用时也能工作

通过合理使用这些特性,可以构建既安全又用户友好的数据变更流程。

在 GitHub 上编辑

上次更新于