Next.js 14 全栈 Server Actions 实战指南:像传送门一样把函数“端到端”调用

129 阅读8分钟

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 少少,快乐多多!🚀