Skip to content

NextJS中文文档 - Server Actions And Mutations

服务器操作是在服务器上执行的异步函数。它们可以在服务器组件和客户端组件中调用,用于处理 Next.js 应用程序中的表单提交和数据变更。

🎥 观看: 了解更多关于使用服务器操作进行数据变更的内容 → YouTube (10 分钟)

约定

服务器操作可以通过 React 的 "use server" 指令定义。你可以将该指令放在 async 函数的顶部,将该函数标记为服务器操作,或者放在单独文件的顶部,将该文件的所有导出标记为服务器操作。

服务器组件

服务器组件可以使用函数级别或模块级别的 "use server" 指令。要内联服务器操作,请在函数体的顶部添加 "use server"

tsx
export default function Page() {
  // 服务器操作
  async function create() {
    'use server'
    // 修改数据
  }

  return '...'
}
jsx
export default function Page() {
  // 服务器操作
  async function create() {
    'use server'
    // 修改数据
  }

  return '...'
}

客户端组件

要在客户端组件中调用服务器操作,创建一个新文件并在其顶部添加 "use server" 指令。该文件中所有导出的函数都将被标记为服务器操作,可以在客户端和服务器组件中重用:

tsx
'use server'

export async function create() {}
js
'use server'

export async function create() {}
tsx
'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>创建</button>
}
jsx
'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>创建</button>
}

将操作作为属性传递

你还可以将服务器操作作为属性传递给客户端组件:

jsx
<ClientComponent updateItemAction={updateItem} />
tsx
'use client'

export default function ClientComponent({
  updateItemAction,
}: {
  updateItemAction: (formData: FormData) => void
}) {
  return <form action={updateItemAction}>{/* ... */}</form>
}
jsx
'use client'

export default function ClientComponent({ updateItemAction }) {
  return <form action={updateItemAction}>{/* ... */}</form>
}

通常,Next.js TypeScript 插件会标记 client-component.tsx 中的 updateItemAction,因为它是一个函数,一般情况下无法跨客户端-服务器边界序列化。 然而,名为 action 或以 Action 结尾的属性被假定为接收服务器操作。 这只是一种启发式方法,因为 TypeScript 插件实际上并不知道它接收的是服务器操作还是普通函数。 运行时类型检查仍然会确保你不会意外地将函数传递给客户端组件。

行为

  • 服务器操作可以使用 <form> 元素中的 action 属性调用:
    • 服务器组件默认支持渐进式增强,这意味着即使 JavaScript 尚未加载或被禁用,表单也会被提交。
    • 在客户端组件中,如果 JavaScript 尚未加载,调用服务器操作的表单将排队提交,优先考虑客户端水合。
    • 水合后,浏览器在表单提交时不会刷新。
  • 服务器操作不限于 <form>,可以从事件处理程序、useEffect、第三方库和其他表单元素(如 <button>)中调用。
  • 服务器操作与 Next.js 缓存和重新验证架构集成。当调用操作时,Next.js 可以在单个服务器往返中返回更新的 UI 和新数据。
  • 在幕后,操作使用 POST 方法,并且只有这种 HTTP 方法可以调用它们。
  • 服务器操作的参数和返回值必须可被 React 序列化。查看 React 文档了解可序列化的参数和值列表。
  • 服务器操作是函数。这意味着它们可以在应用程序的任何地方重用。
  • 服务器操作继承其所使用的页面或布局的运行时。
  • 服务器操作继承其所使用的页面或布局的路由段配置,包括 maxDuration 等字段。

示例

表单

React 扩展了 HTML <form> 元素,允许通过 action 属性调用服务器操作。

在表单中调用时,操作会自动接收 FormData 对象。你不需要使用 React 的 useState 来管理字段,而是可以使用原生的 FormData 方法提取数据:

tsx
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}>...</form>
}
jsx
export default function Page() {
  async function createInvoice(formData) {
    'use server'

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

    // 修改数据
    // 重新验证缓存
  }

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

值得了解:

传递额外参数

你可以使用 JavaScript 的 bind 方法向服务器操作传递额外参数。

tsx
'use client'

import { updateUser } from './actions'

export function UserProfile({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId)

  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">更新用户名</button>
    </form>
  )
}
jsx
'use client'

import { updateUser } from './actions'

export function UserProfile({ userId }) {
  const updateUserWithId = updateUser.bind(null, userId)

  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">更新用户名</button>
    </form>
  )
}

服务器操作将接收 userId 参数,除了表单数据之外:

ts
'use server'

export async function updateUser(userId: string, formData: FormData) {}
js
'use server'

export async function updateUser(userId, formData) {}

值得了解

  • 另一种方法是将参数作为隐藏输入字段传递到表单中(例如 <input type="hidden" name="userId" value={userId} />)。但是,该值将成为渲染的 HTML 的一部分,并且不会被编码。
  • .bind 在服务器组件和客户端组件中都可以工作。它还支持渐进式增强。

嵌套表单元素

你还可以在 <form> 内部嵌套的元素中调用服务器操作,例如 <button><input type="submit"><input type="image">。这些元素接受 formAction 属性或事件处理程序

这在你想在表单中调用多个服务器操作的情况下很有用。例如,你可以创建一个特定的 <button> 元素,用于保存帖子草稿,而不是发布它。有关更多信息,请参阅 React <form> 文档

程序化表单提交

你可以使用 requestSubmit() 方法以编程方式触发表单提交。例如,当用户使用 + Enter 键盘快捷键提交表单时,你可以监听 onKeyDown 事件:

tsx
'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>
  )
}
jsx
'use client'

export function Entry() {
  const handleKeyDown = (e) => {
    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>
  )
}

这将触发最近的 <form> 祖先的提交,从而调用服务器操作。

服务器端表单验证

你可以使用 HTML 属性,如 requiredtype="email" 进行基本的客户端表单验证。

对于更高级的服务器端验证,你可以使用像 zod 这样的库在修改数据之前验证表单字段:

tsx
'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string({
    invalid_type_error: '无效的邮箱',
  }),
})

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

  // 如果表单数据无效,提前返回
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // 修改数据
}
jsx
'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string({
    invalid_type_error: '无效的邮箱',
  }),
})

export default async function createsUser(formData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })

  // 如果表单数据无效,提前返回
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // 修改数据
}

一旦字段在服务器上被验证,你可以在操作中返回一个可序列化的对象,并使用 React 的 useActionState 钩子向用户显示消息。

  • 通过将操作传递给 useActionState,操作的函数签名会改变,以接收一个新的 prevStateinitialState 参数作为其第一个参数。
  • useActionState 是一个 React 钩子,因此必须在客户端组件中使用。
tsx
'use server'

import { redirect } from 'next/navigation'

export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch('https://...')
  const json = await res.json()

  if (!res.ok) {
    return { message: '请输入有效的电子邮件' }
  }

  redirect('/dashboard')
}
jsx
'use server'

import { redirect } from 'next/navigation'

export async function createUser(prevState, formData) {
  const res = await fetch('https://...')
  const json = await res.json()

  if (!res.ok) {
    return { message: '请输入有效的电子邮件' }
  }

  redirect('/dashboard')
}

然后,你可以将你的操作传递给 useActionState 钩子,并使用返回的 state 显示错误消息。

tsx
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

const initialState = {
  message: '',
}

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="email">电子邮件</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button disabled={pending}>注册</button>
    </form>
  )
}
jsx
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

const initialState = {
  message: '',
}

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="email">电子邮件</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button disabled={pending}>注册</button>
    </form>
  )
}

等待状态

useActionState 钩子暴露一个 pending 布尔值,可用于在操作执行期间显示加载指示器。

或者,你可以使用 useFormStatus 钩子在操作执行期间显示加载指示器。使用这个钩子时,你需要创建一个单独的组件来渲染加载指示器。例如,当操作待处理时禁用按钮:

tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button disabled={pending} type="submit">
      注册
    </button>
  )
}
jsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button disabled={pending} type="submit">
      注册
    </button>
  )
}

然后,你可以在表单中嵌套 SubmitButton 组件:

tsx
'use client'

import { SubmitButton } from './button'
import { createUser } from '@/app/actions'

export function Signup() {
  return (
    <form action={createUser}>
      {/* 其他表单元素 */}
      <SubmitButton />
    </form>
  )
}
jsx
'use client'

import { SubmitButton } from './button'
import { createUser } from '@/app/actions'

export function Signup() {
  return (
    <form action={createUser}>
      {/* 其他表单元素 */}
      <SubmitButton />
    </form>
  )
}

Good to know: In React 19, useFormStatus includes additional keys on the returned object, like data, method, and action. If you are not using React 19, only the pending key is available.

乐观更新

你可以使用 React 的 useOptimistic 钩子在服务器操作完成执行之前乐观地更新 UI,而不是等待响应:

tsx
'use client'

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

type Message = {
  message: string
}

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

  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}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">发送</button>
      </form>
    </div>
  )
}
jsx
'use client'

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

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

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

  return (
    <div>
      {optimisticMessages.map((m) => (
        <div>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">发送</button>
      </form>
    </div>
  )
}

事件处理程序

虽然在 <form> 元素中使用服务器操作很常见,但它们也可以通过事件处理程序(如 onClick)调用。例如,增加点赞数:

tsx
'use client'

import { incrementLike } from './actions'
import { useState } from 'react'

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

  return (
    <>
      <p>总点赞数:{likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike()
          setLikes(updatedLikes)
        }}
      >
        点赞
      </button>
    </>
  )
}
jsx
'use client'

import { incrementLike } from './actions'
import { useState } from 'react'

export default function LikeButton({ initialLikes }) {
  const [likes, setLikes] = useState(initialLikes)

  return (
    <>
      <p>总点赞数:{likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike()
          setLikes(updatedLikes)
        }}
      >
        点赞
      </button>
    </>
  )
}

你还可以向表单元素添加事件处理程序,例如,在 onChange 时保存表单字段:

tsx
'use client'

import { publishPost, saveDraft } from './actions'

export default function EditPost() {
  return (
    <form action={publishPost}>
      <textarea
        name="content"
        onChange={async (e) => {
          await saveDraft(e.target.value)
        }}
      />
      <button type="submit">发布</button>
    </form>
  )
}

对于这种情况,多个事件可能在短时间内快速触发,我们建议使用防抖来防止不必要的服务器操作调用。

useEffect

你可以使用 React 的 useEffect 钩子在组件挂载或依赖项变化时调用服务器操作。这对于依赖于全局事件或需要自动触发的变更很有用。例如,应用快捷键的 onKeyDown,用于无限滚动的交叉观察器钩子,或在组件挂载时更新浏览量:

tsx
'use client'

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

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

  useEffect(() => {
    const updateViews = async () => {
      const updatedViews = await incrementViews()
      setViews(updatedViews)
    }

    updateViews()
  }, [])

  return <p>总浏览量:{views}</p>
}
jsx
'use client'

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

export default function ViewCount({ initialViews }) {
  const [views, setViews] = useState(initialViews)

  useEffect(() => {
    const updateViews = async () => {
      const updatedViews = await incrementViews()
      setViews(updatedViews)
    }

    updateViews()
  }, [])

  return <p>总浏览量:{views}</p>
}

记得考虑 useEffect行为和注意事项

错误处理

当抛出错误时,它将被客户端上最近的 error.js<Suspense> 边界捕获。有关更多信息,请参阅错误处理

值得了解:

重新验证数据

你可以在服务器操作内使用 revalidatePath API 重新验证 Next.js 缓存

ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidatePath('/posts')
}

或者使用 revalidateTag 通过缓存标签使特定的数据获取失效:

ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts')
}
js
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts')
}

重定向

如果你想在服务器操作完成后将用户重定向到不同的路由,可以使用 redirect API。redirect 需要在 try/catch 块外调用:

ts
'use server'

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

export async function createPost(id: string) {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts') // 更新缓存的帖子
  redirect(`/post/${id}`) // 导航到新帖子页面
}
js
'use server'

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

export async function createPost(id) {
  try {
    // ...
  } catch (error) {
    // ...
  }

  revalidateTag('posts') // 更新缓存的帖子
  redirect(`/post/${id}`) // 导航到新帖子页面
}

Cookies

你可以使用 cookies API 在服务器操作内 获取设置删除 cookies:

ts
'use server'

import { cookies } from 'next/headers'

export async function exampleAction() {
  const cookieStore = await cookies()

  // 获取 cookie
  cookieStore.get('name')?.value

  // 设置 cookie
  cookieStore.set('name', 'Delba')

  // 删除 cookie
  cookieStore.delete('name')
}
js
'use server'

import { cookies } from 'next/headers'

export async function exampleAction() {
  // 获取 cookie
  const cookieStore = await cookies()

  // 获取 cookie
  cookieStore.get('name')?.value

  // 设置 cookie
  cookieStore.set('name', 'Delba')

  // 删除 cookie
  cookieStore.delete('name')
}

有关从服务器操作删除 cookie 的其他示例,请参阅文档。

安全性

默认情况下,当创建和导出服务器操作时,它会创建一个公共 HTTP 端点 和应该采用相同的安全假设和授权检查。这意味着,即使服务器操作或实用函数未在代码的其他地方导入,它仍然可以公开访问。

为了提高安全性,Next.js 具有以下内置功能:

  • 安全操作 ID: Next.js 创建加密的、非确定性 ID,允许客户端引用和调用服务器操作。这些 ID 在构建之间定期重新计算,以增强安全性。
  • 死代码消除: 未使用的服务器操作(通过其 ID 引用)从客户端 bundle 中移除,以避免第三方公开访问。

值得了解

ID 在编译期间创建,并最多缓存 14 天。当启动新的构建或构建缓存失效时,它们将被重新生成。 这种安全改进减少了在缺少认证层的情况下的风险。但是,你仍应将服务器操作视为公共 HTTP 端点。

jsx
// app/actions.js
'use server'

// 这个操作**在**我们的应用程序中使用,所以 Next.js
// 将创建一个安全 ID,允许客户端引用
// 并调用服务器操作。
export async function updateUserAction(formData) {}

// 这个操作**没有**在我们的应用程序中使用,所以 Next.js
// 将在 `next build` 期间自动删除此代码
// 并且不会创建公共端点。
export async function deleteUserAction(formData) {}

认证和授权

你应该确保用户有权执行操作。例如:

tsx
'use server'

import { auth } from './lib'

export function addItem() {
  const { user } = auth()
  if (!user) {
    throw new Error('你必须登录才能执行此操作')
  }

  // ...
}

闭包和加密

在组件内定义服务器操作会创建一个闭包,其中操作可以访问外部函数的作用域。例如,publish 操作可以访问 publishVersion 变量:

tsx
export default async function Page() {
  const publishVersion = await getLatestVersion();

  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('自按下发布以来版本已更改');
    }
    ...
  }

  return (
    <form>
      <button formAction={publish}>发布</button>
    </form>
  );
}
jsx
export default async function Page() {
  const publishVersion = await getLatestVersion();

  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('自按下发布以来版本已更改');
    }
    ...
  }

  return (
    <form>
      <button formAction={publish}>发布</button>
    </form>
  );
}

当你需要在渲染时捕获数据的快照(例如 publishVersion),以便稍后在调用操作时使用时,闭包非常有用。

然而,为了实现这一点,捕获的变量会发送到客户端,并在调用操作时发回服务器。为了防止敏感数据暴露给客户端,Next.js 自动对闭包变量进行加密。每次构建 Next.js 应用程序时,都会为每个操作生成一个新的私钥。这意味着操作只能针对特定的构建调用。

值得了解: 我们不建议仅依靠加密来防止敏感值在客户端上暴露。相反,你应该使用 React 污点 API 主动防止特定数据发送到客户端。

覆盖加密密钥(高级)

当在多个服务器上自托管 Next.js 应用程序时,每个服务器实例可能最终使用不同的加密密钥,导致潜在的不一致性。

为了缓解这种情况,你可以使用 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量覆盖加密密钥。指定此变量可确保你的加密密钥在构建之间保持持久,并且所有服务器实例使用相同的密钥。此变量必须使用 AES-GCM 加密。

这是一个高级用例,在这种情况下,多个部署之间的一致加密行为对你的应用程序至关重要。你应该考虑标准的安全实践,如密钥轮换和签名。

值得了解: 部署到 Vercel 的 Next.js 应用程序会自动处理这一点。

允许的来源(高级)

由于服务器操作可以在 <form> 元素中调用,这使它们容易受到 CSRF 攻击

在背后,服务器操作使用 POST 方法,并且只有这种 HTTP 方法才能调用它们。这可以防止现代浏览器中的大多数 CSRF 漏洞,特别是在 SameSite cookies 成为默认设置的情况下。

作为额外的保护,Next.js 中的服务器操作还会比较 Origin 标头Host 标头(或 X-Forwarded-Host)。如果这些不匹配,请求将被中止。换句话说,服务器操作只能在托管它的页面的同一主机上调用。

对于使用反向代理或多层后端架构的大型应用程序(其中服务器 API 与生产域不同),建议使用配置选项 serverActions.allowedOrigins 来指定安全来源列表。该选项接受字符串数组。

js
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ['my-site.com', '*.my-site.com'],
    },
  },
}

Learn more about Security and Server Actions.

Additional resources

For more information, check out the following React docs:

🎉有任何问题,欢迎联系我

WeChat QR Code
WeChat
QQ QR Code
QQ

赣ICP备2023003243号