Menu

服务器操作和数据变更

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

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

约定

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

服务器组件

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

app/page.tsx
export default function Page() {
  // 服务器操作
  async function create() {
    "use server";
    // 变更数据
  }
 
  return "...";
}
app/page.jsx
export default function Page() {
  // 服务器操作
  async function create() {
    "use server";
    // 变更数据
  }
 
  return "...";
}

客户端组件

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

app/actions.ts
"use server";
 
export async function create() {}
app/actions.js
"use server";
 
export async function create() {}
app/ui/button.tsx
"use client";
 
import { create } from "@/app/actions";
 
export function Button() {
  return <Button onClick={create} />;
}
app/ui/button.js
"use client";
 
import { create } from "@/app/actions";
 
export function Button() {
  return <Button onClick={create} />;
}

将操作作为 props 传递

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

<ClientComponent updateItemAction={updateItem} />
app/client-component.tsx
"use client";
 
export default function ClientComponent({
  updateItemAction,
}: {
  updateItemAction: (formData: FormData) => void;
}) {
  return <form action={updateItemAction}>{/* ... */}</form>;
}
app/client-component.jsx
"use client";
 
export default function ClientComponent({ updateItemAction }) {
  return <form action={updateItemAction}>{/* ... */}</form>;
}

通常,Next.js TypeScript 插件会标记 client-component.tsx 中的 updateItemAction,因为它是一个函数,通常无法跨客户端-服务器边界序列化。 然而,名为 action 或以 Action 结尾的 props 被假定为接收服务器操作。 这只是一个启发式方法,因为 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 方法提取数据:

app/invoices/page.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>;
}
app/invoices/page.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 方法向服务器操作传递额外参数。

app/client-component.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>
  );
}
app/client-component.js
"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 参数,以及表单数据:

app/actions.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 事件:

app/entry.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>
  );
}
app/entry.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 这样的库来验证表单字段,然后再变更数据:

app/actions.ts
"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,
    };
  }
 
  // 变更数据
}
app/actions.js
"use server";
 
import { z } from "zod";
 
const schema = z.object({
  email: z.string({
    invalid_type_error: "无效的电子邮件",
  }),
});
 
export default async function createUser(formData) {
  const validatedFields = schema.safeParse({
    email: formData.get("email"),
  });
 
  // 如果表单数据无效,提前返回
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  // 变更数据
}

一旦在服务器上验证了字段,你可以在操作中返回一个可序列化的对象,并使用 React 的 [useActionState](https://react.dev/```jsx reference/react/useActionState) hook 向用户显示消息。

  • 通过将操作传递给 useActionState,操作的函数签名会发生变化,接收一个新的 prevStateinitialState 参数作为其第一个参数。
  • useActionState 是一个 React hook,因此必须在客户端组件中使用。
app/actions.ts
"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");
}
app/actions.js
"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 hook,并使用返回的 state 来显示错误消息。

app/ui/signup.tsx
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction] = 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>注册</button>
    </form>
  );
}
app/ui/signup.js
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction] = 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>注册</button>
    </form>
  );
}

需要了解的要点:

  • 在变更数据之前,你应该始终确保用户有权执行该操作。请参阅身份验证和授权
  • 在早期的 React Canary 版本中,这个 API 是 React DOM 的一部分,称为 useFormState

等待状态

useActionState hook 暴露了一个 pending 状态,可用于在执行操作时显示加载指示器。

app/submit-button.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" className="sr-only">
        {state?.message}
      </p>
      <button aria-disabled={pending} type="submit">
        {pending ? "提交中..." : "注册"}
      </button>
    </form>
  );
}
app/submit-button.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" className="sr-only">
        {state?.message}
      </p>
      <button aria-disabled={pending} type="submit">
        {pending ? "提交中..." : "注册"}
      </button>
    </form>
  );
}

需要了解的要点: 另外,你还可以使用 useFormStatus hook 为特定表单显示等待状态。

乐观更新

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

app/page.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) => {
    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>
  );
}
app/page.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)调用。例如,要增加点赞数:

app/like-button.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>
    </>
  );
}
app/like-button.js
"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 时保存表单字段:

app/ui/edit-post.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 hook 在组件挂载或依赖项更改时调用服务器操作。这对于依赖全局事件或需要自动触发的变更很有用。例如,onKeyDown 用于应用快捷键,用于无限滚动的交叉观察器 hook,或者在组件挂载时更新查看次数:

app/view-count.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>;
}
app/view-count.js
"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> 边界捕获。我们建议使用 try/catch 来返回错误,以便由你的 UI 处理。

例如,你的服务器操作可能通过返回一条消息来处理创建新项目时的错误:

app/actions.ts
"use server";
 
export async function createTodo(prevState: any, formData: FormData) {
  try {
    // 变更数据
  } catch (e) {
    throw new Error("创建任务失败");
  }
}
app/actions.js
"use server";
 
export async function createTodo(prevState, formData) {
  try {
    // 变更数据
  } catch (e) {
    throw new Error("创建任务失败");
  }
}

需要了解的要点:

重新验证数据

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

app/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidatePath("/posts");
}
app/actions.js
"use server";
 
import { revalidatePath } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidatePath("/posts");
}

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

app/actions.ts
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts");
}
app/actions.js
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts");
}

重定向

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

app/actions.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}`); // 导航到新文章页面
}
app/actions.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 在服务器操作中 getsetdelete cookie:

app/actions.ts
"use server";
 
import { cookies } from "next/headers";
 
export async function exampleAction() {
  // 获取 cookie
  const value = cookies().get("name")?.value;
 
  // 设置 cookie
  cookies().set("name", "Delba");
 
  // 删除 cookie
  cookies().delete("name");
}
app/actions.js
"use server";
 
import { cookies } from "next/headers";
 
export async function exampleAction() {
  // 获取 cookie
  const value = cookies().get("name")?.value;
 
  // 设置 cookie
  cookies().set("name", "Delba");
 
  // 删除 cookie
  cookies().delete("name");
}

查看其他示例以了解如何从服务器操作中删除 cookie。

安全性

身份验证和授权

你应该像处理公开的 API 端点一样处理服务器操作,并确保用户有权执行该操作。例如:

app/actions.ts
"use server";
 
import { auth } from "./lib";
 
export function addItem() {
  const { user } = auth();
  if (!user) {
    throw new Error("你必须登录才能执行此操作");
  }
 
  // ...
}

闭包和加密

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

app/page.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>
  );
}
app/page.js
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 taint APIs 主动防止特定数据被发送到客户端。

覆盖加密密钥(高级)

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

为了缓解这个问题,你可以使用 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量覆盖加密密钥。指定此变量可确保你的加密密钥在构建之间保持一致,并且所有服务器实例使用相同的密钥。

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

需要了解的要点: 部署到 Vercel 的 Next.js 应用程序会自动处理这个问题。

允许的来源(高级)

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

在幕后,服务器操作使用 POST 方法,并且只有这种 HTTP 方法被允许调用它们。这可以防止现代浏览器中的大多数 CSRF 漏洞,特别是考虑到 SameSite cookie 是默认设置。

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

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

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

了解更多关于安全性和服务器操作的信息。

其他资源

有关服务器操作的更多信息,请查看以下 React 文档:

了解如何在 Next.js 中配置服务器操作