Next.js 14 的 Server Actions 就是这样一扇“传送门”。它让你在组件里像调用普通函数一样触发服务器端逻辑,自动处理序列化、路由、权限与缓存,让你“像写同步代码一样写全栈”。这篇文章从底层原理到实战代码,带你既专业严谨、又轻松愉快地全面掌握它。🪄
1. Server Actions 是什么?为什么它很香
- 它是运行在服务器上的函数,能被客户端组件、表单提交或事件处理器直接调用。
- 由 React 与 Next.js 共同实现:React 提供 action 的语义与序列化协议,Next.js 提供路由、边界与执行环境。
- 你不必手写 API 路由,也不用在客户端手搓 fetch 调用,只要“调用函数”,Next.js 会把这次调用打包成一个请求,服务器执行后把结果“回传”。
用一张拟人化的小比喻图来理解:
- 客户端组件:🧑💻“我要创建用户,参数是这个表单。”
- Server Action:🛰️“收到!我在云端跑一跑,把结果发还给你。”
- Next.js 内核:⚙️“路由、序列化、权限我来管,你们只管说人话。”
2. 基础用法:从最小可用例子起步
我们先来一个“加法器”的最小例子,专注概念:
- app/actions.ts(或任意 server 文件)里定义一个 action。
- 在客户端组件中调用它。
代码结构:
-
app/
- actions.ts
- page.tsx
actions.ts:
"use server";
export async function add(a, b) {
// 这里运行在服务端,可安全访问机密与数据库
// 加一点戏剧性延迟
await new Promise(r => setTimeout(r, 300));
return Number(a) + Number(b);
}
page.tsx:
"use client";
import { useState } from "react";
import { add } from "./actions";
export default function Page() {
const [a, setA] = useState("1");
const [b, setB] = useState("2");
const [sum, setSum] = useState(null);
const [loading, setLoading] = useState(false);
async function handleAdd() {
setLoading(true);
try {
const res = await add(a, b); // 像本地函数一样调用
setSum(res);
} finally {
setLoading(false);
}
}
return (
<main style={{ padding: 24, maxWidth: 560 }}>
<h1>Next 14 Server Actions:加法小练习 ➕</h1>
<label>
A:
<input value={a} onChange={e => setA(e.target.value)} />
</label>
<br />
<label>
B:
<input value={b} onChange={e => setB(e.target.value)} />
</label>
<br />
<button onClick={handleAdd} disabled={loading}>
{loading ? "计算中…" : "相 加"}
</button>
{sum !== null && <p>结果:{sum}</p>}
</main>
);
}
亮点:
- "use server" 放在函数定义文件顶部,这告诉 Next:这些函数是 Server Actions。
- 客户端直接 import 并调用,Next 会把这次调用序列化成一个 POST 请求到隐形端点。
- 异常与返回值都可自动跨边界传递(需可序列化)。
3. 表单直连:Action 和 是天作之合
表单无需写 onSubmit + fetch,直接把 action 接上去。Server Actions 和 React 的表单是亲兄弟。
例:创建 Todo
app/actions.ts:
"use server";
let todos = []; // 演示用,真实项目请使用数据库
export async function createTodo(formData) {
const title = formData.get("title")?.toString().trim();
if (!title) {
throw new Error("标题不能为空");
}
const todo = { id: Date.now(), title, done: false };
todos.unshift(todo);
return todo;
}
export async function listTodos() {
return todos;
}
export async function toggleTodo(id) {
const item = todos.find(t => t.id === id);
if (item) item.done = !item.done;
return item;
}
app/page.tsx(服务端组件 + 客户端子组件):
import { createTodo, listTodos, toggleTodo } from "./actions";
import TodoList from "./todo-list";
export default async function Page() {
const initialTodos = await listTodos(); // 服务端预取
return (
<main style={{ padding: 24, maxWidth: 680 }}>
<h1>待办清单 ✅</h1>
<form action={createTodo} style={{ display: "flex", gap: 8 }}>
<input name="title" placeholder="准备统治世界…" />
<button type="submit">添加</button>
</form>
<TodoList initialTodos={initialTodos} />
</main>
);
}
app/todo-list.tsx(客户端组件调用 action):
"use client";
import { useOptimistic, useTransition, useState } from "react";
import { toggleTodo, listTodos } from "./actions";
export default function TodoList({ initialTodos }) {
const [todos, setTodos] = useState(initialTodos);
const [isPending, startTransition] = useTransition();
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, { id }) =>
state.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);
async function handleToggle(id) {
addOptimistic({ id });
try {
await toggleTodo(id);
const fresh = await listTodos();
startTransition(() => setTodos(fresh));
} catch (e) {
// 回滚逻辑可加
console.error(e);
}
}
const list = isPending ? optimisticTodos : todos;
return (
<ul style={{ listStyle: "none", padding: 0 }}>
{list.map(t => (
<li key={t.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={t.done}
onChange={() => handleToggle(t.id)}
/>
<span style={{ textDecoration: t.done ? "line-through" : "none" }}>
{t.title}
</span>
</li>
))}
</ul>
);
}
要点:
- form 的 action 直接指向 createTodo,浏览器提交表单即触发该 Server Action。
- useOptimistic 带来“先改 UI 再同步”的丝滑体验。
4. 底层原理一览:它到底做了哪些魔法
避开公式,我们用“流程清单”理解调用链:
-
编译阶段:
- 带 "use server" 的模块被标记为服务器边界,函数签名被注册为可调用入口。
-
客户端引用:
- 当你从客户端 import 这些函数,Next 在构建产物里生成一个“客户端代理”。你调用函数,实际是调用代理。
-
运行时调用:
- 代理把参数做安全序列化(必须是可结构化克隆的类型,比如字符串、数字、布尔值、对象、数组、FormData、File 等),构造 POST 请求到内部端点。
-
服务器执行:
- Next 定位到原函数,在服务器环境里执行(可以读写 DB、Secret、文件),返回值被再序列化后回传。
-
传输与错误:
- 成功结果与抛出的错误都能跨边界携带(注意不能传不可序列化内容,如大闭包、类实例等)。
这就是为什么“像本地函数一样调用”在底层却是一次稳健的 RPC(远程过程调用)。
5. 安全边界:防注入、防越权、防泄露
- 服务器运行环境可访问机密,客户端拿不到源代码中 “use server” 区块的实现,只能拿到代理引用。
- 参数校验要做(例如 zod/yup),别把“用户说了算”的内容直灌数据库。
- 鉴权可以使用 cookies/session,或与 next-auth、auth.js 集成。
- 限速与审计:对敏感 action 可加速率限制与日志记录。
例:带鉴权的 Server Action
"use server";
import { cookies } from "next/headers";
export async function createSecretNote(formData) {
const user = cookies().get("user_id")?.value;
if (!user) {
throw new Error("未登录,无法创建机密笔记");
}
const content = formData.get("content")?.toString().trim();
if (!content) throw new Error("内容不能为空");
// 写入数据库省略
return { ok: true };
}
6. 缓存与再验证:让数据像泉水一样“活”
Next 的数据层和 Server Actions 玩得很来。几个常用点:
- 读取数据:使用 fetch 时可通过 cache、next: { revalidate } 控制缓存策略。
- 写入后:调用 revalidatePath 或 revalidateTag 让页面或数据源“刷新”。
例:提交后刷新某列表页面
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData) {
const title = formData.get("title")?.toString().trim();
if (!title) throw new Error("标题必填");
// DB insert 省略
revalidatePath("/posts"); // 服务端让 /posts 页面下次请求刷新
return { ok: true };
}
7. 文件与二进制:处理上传也不怵
FormData 和 File 在 Server Actions 里是“头等公民”。你可以直接拿到 file 对象内容。
"use server";
export async function uploadAvatar(formData) {
const file = formData.get("avatar");
if (!file || typeof file === "string") throw new Error("文件缺失");
const bytes = await file.arrayBuffer();
// 存储到对象存储或转换为 Buffer
// const buffer = Buffer.from(bytes);
return { size: bytes.byteLength };
}
在页面里:
<form action={uploadAvatar}>
<input type="file" name="avatar" accept="image/*" />
<button type="submit">上传头像 📤</button>
</form>
8. 错误处理与用户体验:优雅,不惊扰
- try/catch 包裹调用,在 UI 展示友好的提示。
- 搭配 useTransition、pending UI,让用户知道“正在处理”。
- 对可恢复错误在边界组件里捕获,显示“重试”按钮。
示例:
"use client";
import { useState, useTransition } from "react";
import { createPost } from "./actions";
export default function Compose() {
const [error, setError] = useState(null);
const [isPending, startTransition] = useTransition();
async function submit(e) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setError(null);
startTransition(async () => {
try {
await createPost(fd);
e.currentTarget.reset();
} catch (err) {
setError(err.message || "提交失败");
}
});
}
return (
<form onSubmit={submit}>
<input name="title" placeholder="标题" />
<button disabled={isPending}>{isPending ? "发布中…" : "发布"}</button>
{error && <p style={{ color: "crimson" }}>⚠️ {error}</p>}
</form>
);
}
9. 常见坑位与最佳实践
- 仅在服务器文件使用 "use server"。不要在同一文件混放大段客户端 UI 与 server 代码。
- 传参必须可序列化:函数、类实例、Symbol、循环引用对象都不行。用基本类型、普通对象、FormData。
- 复杂对象请在服务端重组:把 id 传过去,服务端自己查库。
- 严谨的输入校验:后端不信任前端,Server Action 是后端。
- 关注边界大小:参数与返回别太庞大,上传下载请走流式或对象存储。
- 搭配 RSC 数据策略:读操作倾向在服务端组件获取,写操作用 Server Actions,并配合 revalidate。
10. 一个更“像样”的全栈样例
做个“笔记本”,实现增、查、切换收藏,并展示 SSR + Actions 的配合。
app/actions.ts:
"use server";
import { revalidatePath } from "next/cache";
let notes = [];
export async function createNote(formData) {
const content = formData.get("content")?.toString().trim();
if (!content) throw new Error("内容不能为空");
const note = { id: Date.now(), content, favorite: false };
notes.unshift(note);
revalidatePath("/"); // 刷新首页列表数据
return note;
}
export async function listNotes() {
return notes;
}
export async function toggleFavorite(id) {
const n = notes.find(x => x.id === id);
if (n) n.favorite = !n.favorite;
revalidatePath("/");
return n;
}
app/page.tsx:
import { createNote, listNotes, toggleFavorite } from "./actions";
import NotesClient from "./notes-client";
export default async function Page() {
const data = await listNotes();
return (
<main style={{ padding: 24, maxWidth: 720 }}>
<h1>🗒️ 我的笔记</h1>
<form action={createNote} style={{ display: "flex", gap: 8 }}>
<input name="content" placeholder="今天我学到了…" />
<button type="submit">记一下</button>
</form>
<NotesClient initial={data} />
</main>
);
}
app/notes-client.tsx:
"use client";
import { useOptimistic, useTransition, useState } from "react";
import { toggleFavorite, listNotes } from "./actions";
export default function NotesClient({ initial }) {
const [notes, setNotes] = useState(initial);
const [isPending, startTransition] = useTransition();
const [optimistic, mark] = useOptimistic(
notes,
(state, id) => state.map(n => (n.id === id ? { ...n, favorite: !n.favorite } : n))
);
async function toggle(id) {
mark(id);
try {
await toggleFavorite(id);
const fresh = await listNotes();
startTransition(() => setNotes(fresh));
} catch (e) {
console.error(e);
}
}
const list = isPending ? optimistic : notes;
return (
<ul style={{ padding: 0 }}>
{list.map(n => (
<li key={n.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => toggle(n.id)} title="收藏">
{n.favorite ? "⭐" : "☆"}
</button>
<span>{n.content}</span>
</li>
))}
</ul>
);
}
特点:
- 读在服务端组件,写在 Server Actions。
- 通过 revalidatePath 实现“写后刷新”。
- 客户端用 useOptimistic 保持交互顺滑。
11. 性能与部署小贴士
- 部署到 Vercel 或任何支持 Node 运行时的平台都可。Server Actions 需要服务器环境(或 Edge,但需注意 Node API 可用性)。
- Action 粒度要适中:别把整个业务流程塞进一个函数,以便缓存与重试的灵活性。
- 慎用大对象传输:偏爱 ID 与紧凑数据结构,避免超大 JSON。
- 监控与日志:为关键 Action 打点,统计调用次数、耗时与错误率。
12. 心智模型复盘:像“局部同步”的全栈
- 在代码层面,你在“同步”地调用函数;
- 在系统层面,它是一次严格受控的远程过程;
- 在用户体验层面,配合 RSC 与 useOptimistic,它像魔术一般自然。
可以把 Server Actions 想象成“低摩擦 API 层”:
- 不再写重复的路由、请求封装;
- 更安全地隔离秘钥与服务端逻辑;
- 更简洁地组织 SSR、缓存与用户交互。
13. 结语:给工程师灵魂的彩蛋
- 如果 API 路由是邮差,Server Actions 就是传送门。
- 如果你曾在前后端之间奔跑,Server Actions 会递上一杯冰美式,让你坐下来写代码。
去把你的业务函数换成 Action 吧。让 Next.js 帮你处理那些“本不该由人类重复”的活,你专注写出优雅、可维护的逻辑就好。祝开发顺利,Bug 少少,快乐多多!🚀