Server Actions —— 前后端交互的未来形态

三葉Leaves Author

概述

Server Actions 是可以直接从客户端代码(尤其是 Client Components)中调用的异步函数,但它们的函数体本身只在服务器上执行

在这种模式下,你不会在浏览器 DevTools 的 Network 面板中看到任何请求,因为请求由服务器帮你完成了。这个看起来是多加了一层服务器在中间转发,不过也会带来几项优点:

  1. 服务器会自动缓存,大量客户端使用同一份数据的时候,不会多次请求;
  2. 发给客户端的都是已经渲染好的页面,这对 SEO 极其有利;
  3. 这种模式下,我们甚至可以直接操作数据库一步完成变更或者执行其他副作用,这在利用 NextJS 直接全栈开发的情况下尤其有利;
  4. 在某些禁用 JS 的情况下,它的渐进增强的性质使得出乎意料的仍能工作。

本质

它们是建立在 Web Fundamentals(特别是 HTML <form>)之上的一个 RPC (远程过程调用) 的抽象。你感觉像是在调用一个本地函数,但 Next.js 在底层为你处理了网络请求、数据序列化和反序列化。

优势

  • 零 API 路由: 你不再需要为了一个简单的创建或更新操作去手动创建 /api/ 目录下的文件。你的业务逻辑和数据变更函数可以放在任何地方(通常是和服务端代码放在一起)。
  • 简化数据变更: 你可以直接在 Server Action 内部调用数据库或服务层函数,修改数据,然后告诉 Next.js 哪些数据需要刷新。
  • 无缝的数据刷新: 通过 revalidatePathrevalidateTag,你可以精确地让 Next.js 重新获取特定页面的数据并更新 UI,无需客户端手动 refetch。
  • 渐进增强 (Progressive Enhancement): 这是一个非常优雅的特性。如果用户的浏览器禁用了 JavaScript,一个绑定了 Server Action 的 <form> 仍然可以作为标准 HTML 表单工作,实现全页刷新提交。当 JavaScript 可用时,Next.js 会接管它,实现无刷新的局部更新。

实战演示

定义 action 函数

我会用一个简单的 Todo List 来演示怎么使用。

先定义一个用 fetch 封装的请求函数,这个通常会在 @/services 文件夹里,但是我采用 Ducks 风格组织代码,所以:

src\features\todos\todoServices.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const addTodo = async (content: string) => {
try {
// await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await fetch(`${API_BASE_URL}/todos`, {
method: "POST",
body: JSON.stringify({
content,
done: false,
}),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("添加 todo 失败啦, 请重试");
}
return (await res.json()) as Todo;
} catch (error) {
console.log("网络层错误:", error);
throw new Error("添加 todo 失败啦, 检查网络连接");
}
};

接着,我来创建真正用于 server action 的函数。这个函数,本质上就是基于上面的函数多一层封装。

这个封装出来的函数,能直接用于 <form action = { ... } > 标签的 action 属性。

众所周知,如果直接在 action 属性里填写 URL,HTML 的默认行为会自己请求这个地址;然而,React 和 NextJS 对这个行为进行了原生的增强。

现在,你可以往里面传一个函数,React 还会自动把 form 表单里的数据填入这个函数,作为其形参。我们现在就来定义这个函数:

src\features\todos\todoActions.ts

1
2
3
4
5
6
7
8
9
10
11
export const createTodo = async (
formData: FormData
): Promise<FormState> => {
"use server";
try {
await addTodo(formData.get("content"));
revalidatePath("/");
} catch (error) {
// do something
}
};

核心就 3 点:

  1. 函数的形参是表单数据,React 自动填入。
  2. 函数定义处用了 use server,因为这个函数是在服务端执行
  3. 完成之后要 revalidatePath("..."),当我们调用它时,Next.js 会做两件事:
    • 清除服务端的数据缓存: 它会使 getTodos 在下次被调用时,必须重新 fetch 数据,而不是使用缓存
    • 触发 RSC 重新渲染: Next.js 知道 / 路径的数据“脏了”,它会重新在服务器上运行 Home 这个 Server Component。Home 内部会再次调用 getTodos,获取到包含了新 Todo 的列表,然后将新的 RSC Payload 发送给浏览器。浏览器端的 React 会高效地 diff 并更新 DOM,于是新添加的 Todo 就出现在了页面上!

这个函数目前还出奇的简单,其实我们可以在里面加一些服务端可以做的事情,比如验证之类的,下文中我会体现。

使用 action 函数

简单到难以置信,这里直接贴代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// components/add-todo-form.tsx
"use client";

import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createTodo } from "@/services/todos"; // 导入我们的 Server Action

export function AddTodoForm() {
return (
// 直接将 Server Action 传给 action 属性
<form action={createTodo} className="flex gap-2 mb-6">
<Input
type="text"
name="content"
placeholder="What needs to be done?"
required
/>
<Button type="submit">
<Plus className="h-4 w-4 mr-2" />
Add Todo
</Button>
</form>
);
}

我们没有写一行 fetch 的客户端代码,没有写一行 useState 来管理列表状态,没有写任何 useEffect 来同步数据,就实现了这个核心功能。这就是 Server Actions 的强大之处。

使用 useActionState 和 useFormStatus HOOK 进一步增强

[!warning] 请区分服务端和客户端(浏览器)
类似 TanStack Query 以及 React Form Hook 都有自己的 state 机制,很多时候在进行用户提示或者状态管理的时候会感到无从下手,我到底该用谁?!
请注意,本文中所有的校验、状态等,都是在反应服务器端的情况,因为这是配合 server action 用的。
在客户端可以进行一次校验,服务端再进行一次校验。state 作用的层级和位置是不同的,需要区分。

这一切很现代,很棒,但是还并没有友好的状态管理和错误处理机制。

下一步,我们需要使用两个 hook,这两个是 server action 的最佳搭档,我们的目标是在用户执行 add todo 成功或者失败的时候,都能用 toast 弹出一条友好的提示。

使用 useFormStatus hook

一个有趣的事实:
useFormStatus 现在是 “React DOM” 包里唯一一个 hook 了。

useFormStatus 的用法很简单,我直接用一个 submit button 举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"use client";

import { useFormStatus } from "react-dom";
import { Button } from "./ui/button";
import { Loader2 } from "lucide-react";

export function SubmitButton({
label,
children,
}: {
label?: string;
children: React.ReactNode;
}) {
// 使用 hook
const { pending } = useFormStatus();

return (
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : children} {label}
</Button>
);
}

其实就是从中解构出一个 pending 属性,这个在处理 loading 状态的时候很有用。

注意点:

useFormStatus hook 要求使用其的组件在 form 标签里

这个 hook 能很方便的创建出高度可复用的提交按钮这样的组件,而不依赖父组件表单的状态管理。

使用 useActionState hook

在早期的 React Canary 版本中,此 API 是 React DOM 的一部分,称为 useFormState,可见:useActionState – React 中文文档

useActionState 的签名大约长这样:

1
2
3
4
const [state, action, isPending] = useActionState(
actionFunction: (prevState: any, formData: FormData) => Promise<any>,
initialState: any
);

其中的 actionFunction 其实就是我们上面定义的 server action 函数,而解构出的 action 是一个增强过的函数,我们需要把它替换掉原来的 actionFunction 给 form 表单用。

这个 hook 本质是为 server action 提供一个状态机,其解构出的另外两样东西也很有用:

  • state 结构由我们自己定义,useActionState 会自动帮我们管理这个 state ,接下来我会详细说这件事;

  • isPending 属性的作用其实和上面 useFormStatus 解构出来的的 pending 没啥区别,但是为了抽象出可复用的组件,我宁愿用 useFormStatus。

定义好 state 结构

要管理 state,我们得先有 state

src\features\todos\todoActions.ts

1
2
3
4
5
6
7
8
export interface FormState {
message: string;
fieldErrors?: {
content?: string[];
};
type: "success" | "error" | "idle";
formValues?: todoSchemaType;
}

其中最主要就是 type 和 message 字段,前者标识状态类型,这个用于给用户提供友好的提示;

fieldErrors 用格式化输出详细报错信息,这个是给 zod 用的,能优雅的在出错时精确返回是哪一条出问题了。

formValues 用于数据的回填,想象一下用户提交信息失败了,这可能是因为网络原因之类的,用户并不想重新输入一次信息,所以你得把它出错的信息回填进去。

[!note]- 只在提交成功的时候手动清空表单,不就可以了吗?为什么要多此一举
这是因为提交后清空表单数据是 HTML 元素的默认行为。通常,我们会阻止此默认行为,但是由于 server action 本身又是利用了其默认行为,所以这里会很矛盾,显得不怎么优雅。不过目前来看,手动回填只能是不得已的方案了。

修改 server action 函数,增加返回值为 state

下面是完整的,修改好的 server action 函数。值得注意的是,这次我们设置了

  1. prevState 形参
  2. 以 state 为结构的返回值
    提供给 useActionState 使用。

代码里还多了一些 zod 的操作,请注意,此时 zod 的验证发生在服务端,因为这是服务端函数。这恰恰体现了 zod 的强大:双端同步校验。

src\features\todos\todoActions.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export const createTodo = async (
prevState: FormState,
formData: FormData
): Promise<FormState> => {
"use server";
// 这里拿数据的时候多包一层对象,我猜测是未来可能会有 property 等其他字段
const rawData: todoSchemaType = {
content: formData.get("content") as string,
};
const validatedFields = todoSchema.safeParse(rawData);
if (!validatedFields.success) {
return {
message: "添加失败",
type: "error",
fieldErrors: z.flattenError(validatedFields.error).fieldErrors,
formValues: rawData,
};
}

try {
await addTodo(validatedFields.data.content.trim());
revalidatePath("/");
return {
message: "添加成功",
type: "success",
};
} catch (error) {
return {
message: "添加失败,网络出错啦",
type: "error",
formValues: rawData,
};
}
};

在组件里使用 useActionState hook

在这里,我们利用 useEffect 消费 useActionState 返回的 state 信息,来实现我们需要的友好的用户提示功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const initialState: FormState = {
message: "",
type: "idle",
};

export function AddTodoForm() {
// 使用 hook ,formAction 函数会给 JSX form 标签用
const [state, formAction] = useActionState(createTodo, initialState);
// ...

// 这里消费 state 信息
useEffect(() => {
if (state.type === "success") {
// reset 方法是 RHF 提供的
reset({
content: "",
});
toast.success(state.message);
}
if (state.type === "error") {
if (state.formValues) {
reset(state.formValues);
}
if (state.fieldErrors?.content) {
setError("content", {
type: "server",
message: state.fieldErrors.content.join(", "),
});
} else {
toast.error(state.message || "操作失败,请稍后重试");
}
}
}, [state, reset, setError]);
return (
<>
    {/* 这里使用上面的 formAction 函数 */}
<form action={formAction} className="flex space-x-3">
{/* ... */}
</>

}
  • 标题: Server Actions —— 前后端交互的未来形态
  • 作者: 三葉Leaves
  • 创建于 : 2025-09-05 00:00:00
  • 更新于 : 2026-01-13 15:50:33
  • 链接: https://blog.oksanye.com/fbbe337a1649/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论