大家好,欢迎来到今天的“前端炼金术”讲座。
今天我们要聊的话题,听起来可能有点枯燥,但如果你曾经为了一个表单提交写过 useEffect、useState、fetch、setLoading、setErrorMessage,最后还因为水合不匹配在控制台看到红色的 Hydration failed 而抓狂过,那你就会知道,这个话题有多么让人热血沸腾。
我们今天的主角是 React Server Actions(RSC)。
在这个讲座里,我会剥开那些教科书式的定义,带你看看如何在服务器组件里实现那种“丝般顺滑”的表单提交与状态同步。别担心,没有枯燥的理论,只有代码、吐槽和实战经验。
第一章:旧世界的痛苦
在 React Server Actions 出现之前,我们在客户端处理表单通常是这样的:
- 用户点击提交。
- JS 拦截事件。
- 设置
isLoading = true。 - 发送
fetch请求到后端 API。 - 等待响应。
- 设置
isLoading = false。 - 根据响应更新
formData或跳转页面。
在这个过程中,你需要在服务器端维护一套数据结构,在客户端维护一套数据结构。如果服务器返回的数据格式变了,你还得两头改。更糟糕的是,如果你忘了处理 useEffect 的依赖数组,或者忘了清理 AbortController,你的应用就会变成一个充满了内存泄漏和竞态条件的“泥潭”。
而 Server Actions,就是一把铲子,帮你直接挖通了通往服务器的隧道。
第二章:Server Action 到底是个什么鬼?
首先,纠正一个最常见的误解。Server Action 不是 API 路由(比如 /api/create-post)。API 路由本质上是一个独立的 HTTP 端点,它不知道谁在调用它,你需要手动把 Token 放进 Header 里,还得手动处理 CSRF 保护。
而 Server Action,就是一个函数。
这个函数定义在服务器上(通常在 Server Component 里),但它可以被 React 客户端直接调用。它就像是直接运行在服务器上的代码,拥有服务器的所有能力(数据库访问、文件读写、环境变量),同时它又像客户端组件一样,能无缝地嵌入到你的 UI 树中。
让我们先看一个最简单的例子。
app/actions.ts
import { revalidatePath } from 'next/cache';
import { auth } from '@/auth';
// 这是一个 Server Action
export async function createPost(formData: FormData) {
// 1. 认证:你不需要手动传 Token,auth() 会自动从请求上下文中获取
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized! You shall not pass!');
}
// 2. 业务逻辑:你可以像写普通 Node.js 代码一样写数据库操作
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.create({
data: { title, content, authorId: session.user.id }
});
// 3. 重新验证:告诉 React “缓存失效,给我最新数据”
revalidatePath('/posts');
}
看到了吗?这段代码没有 HTTP 请求,没有路由,没有 headers。它就是一段函数。它知道你是谁(因为 React 会在调用它时自动附上认证信息),它也知道如何更新数据。
第三章:在客户端“召唤”这个函数
既然这是服务器端的函数,那么客户端组件怎么用呢?你不能直接 import { createPost } from ... 然后调用它。你需要一个包装器。
在 Next.js 中,我们使用 createServerAction(来自 next-server-action 或 @kinde-oss/management-sdk 等)来创建一个客户端可用的 hook。
app/components/CreatePostForm.tsx
'use client'; // 必须标记为客户端组件,因为我们要用 hook
import { createServerAction } from 'next-server-action';
import { createPost } from '../actions'; // 引入上面的函数
// 定义状态类型
type FormState = {
error?: string;
success?: boolean;
};
// 1. 创建一个 Server Action Hook
// 这会返回两个东西:执行函数 和 状态对象
const [createPostAction, isPending, error] = createServerAction<FormState, FormData>(
createPost
);
export default function CreatePostForm() {
// 2. 使用 useTransition 来处理乐观 UI
const [isPending, startTransition] = useTransition();
// 3. 处理表单提交
const handleSubmit = (formData: FormData) => {
// startTransition 告诉 React:“嘿,在后台跑这个异步任务吧,别阻塞 UI”
startTransition(async () => {
const result = await createPostAction(formData);
if (result?.error) {
alert(result.error); // 这里可以换成 Toast 组件
}
});
};
return (
<form action={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={isPending || error}>
{isPending ? 'Saving...' : 'Post It'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
</form>
);
}
这里的关键点在于 createServerAction。它不仅仅是把函数暴露出来,它还帮你处理了:
- 认证注入:自动把当前的 session 信息传给服务器函数。
- 状态同步:返回的
isPending和error是实时的。 - 安全性:防止 CSRF 攻击。
第四章:终极同步方案——useFormState
上面的代码虽然能用,但 handleSubmit 里的 startTransition 看起来有点繁琐。而且,我们通常希望表单提交后,能够立即在 UI 上看到状态变化(比如输入框变灰、按钮禁用),而不需要手动去管理 isPending 的状态。
这就是 useFormState 的用武之地。它允许你将服务器的状态(比如错误信息)直接同步到客户端组件的 state 中。
app/components/AdvancedForm.tsx
'use client';
import { useFormState } from 'react-dom'; // 或者 useFormStatus
import { createServerAction } from 'next-server-action';
import { createPost } from '../actions';
type FormState = {
error?: string;
success?: boolean;
};
// 初始状态
const initialState: FormState = {
error: undefined,
success: undefined,
};
// 包装 Server Action
const [createPostAction, formState] = createServerAction<FormState, FormData>(createPost);
export default function AdvancedForm() {
// useFormState 接收两个参数:
// 1. Server Action (或者一个返回 Promise 的函数)
// 2. 初始 state
// 它会返回 [当前 state, 执行函数]
const [state, formAction, isPending] = useFormState(createPostAction, initialState);
return (
<form action={formAction}>
<div className="mb-4">
<label className="block mb-2">Title</label>
<input
name="title"
className="border p-2 w-full"
disabled={isPending} // 直接利用 isPending 禁用表单
/>
</div>
<div className="mb-4">
<label className="block mb-2">Content</label>
<textarea
name="content"
className="border p-2 w-full"
disabled={isPending}
/>
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
>
{isPending ? 'Processing...' : 'Submit'}
</button>
{/* 错误信息展示 */}
{state?.error && (
<div className="mt-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded">
<p>{state.error}</p>
</div>
)}
{/* 成功信息展示 */}
{state?.success && (
<div className="mt-4 p-4 bg-green-100 text-green-700 border border-green-300 rounded">
<p>Post created successfully!</p>
</div>
)}
</form>
);
}
深度解析 useFormState:
你可能会有疑问:createServerAction 返回的是 [action, isPending, error],而 useFormState 返回的是 [state, formAction, isPending]。这两个 isPending 是一回事吗?
是的。useFormState 内部会自动调用 createServerAction,并利用 React 的状态机制来同步 loading 状态。
当你调用 formAction(formData) 时,React 会:
- 检测到状态变化。
- 触发
createPost函数在服务器上运行。 - 服务器返回的
FormState(包含错误信息)会自动更新到state中。 - UI 重新渲染,显示新的状态。
这就是“无缝状态同步”的精髓。你不需要手动写 fetch,不需要手动写 setState,React 会自动帮你搞定。
第五章:乐观 UI——让用户感觉比光速还快
现在,我们的表单提交已经很快了。但如果我们能做得更快呢?快到用户感觉不到网络延迟,这就是 Optimistic UI(乐观 UI)。
假设你在做一个购物车功能,或者点赞功能。用户点击“点赞”时,我们希望按钮瞬间变红,而不是等待 200ms 的请求。
useTransition 就是实现乐观 UI 的瑞士军刀。
app/components/LikeButton.tsx
'use client';
import { useTransition } from 'react';
import { createServerAction } from 'next-server-action';
import { likePost } from '../actions';
type LikeState = { liked: boolean; count: number };
const [likeAction, isPending] = createServerAction<LikeState, { postId: string }>(likePost);
export default function LikeButton({ postId, initialCount, initialLiked }: { postId: string, initialCount: number, initialLiked: boolean }) {
const [isTransitioning, startTransition] = useTransition();
const [state, setState] = useState<LikeState>({ liked: initialLiked, count: initialCount });
const handleLike = () => {
// 1. 立即更新 UI (乐观更新)
setState(prev => ({ liked: !prev.liked, count: prev.count + (prev.liked ? -1 : 1) }));
// 2. 启动一个 Transition
// 这告诉 React:“把接下来的更新推入批处理队列,不要阻塞当前的渲染”
startTransition(async () => {
// 3. 发送请求
const result = await likeAction({ postId });
// 4. 处理错误 (如果服务器拒绝了操作)
if (result?.error) {
// 回滚状态
setState(prev => ({ liked: prev.liked, count: prev.count - (prev.liked ? -1 : 1) }));
alert(result.error);
} else if (result) {
// 更新为服务器确认的状态
setState(result);
}
});
};
return (
<button
onClick={handleLike}
disabled={isTransitioning}
className={isTransitioning ? "opacity-50" : ""}
>
{isTransitioning ? '...' : state.liked ? 'Liked' : `Like (${state.count})`}
</button>
);
}
为什么这很酷?
在这个例子中,当用户点击时,按钮瞬间变色。如果网络延迟是 200ms,用户会感觉不到延迟。只有当网络请求失败(比如服务器返回 403,或者网络断开),我们才会在 startTransition 的回调中把状态回滚。
这就是 React Server Actions 带来的体验升级。它让我们能轻松写出这种“无感”的交互。
第六章:错误处理的艺术
在 Server Actions 中,错误处理非常直接。你只需要 throw 一个错误。
但是,怎么在客户端显示这个错误呢?createServerAction 返回的 error 对象会自动传递给 hook。
app/actions.ts (更新版)
import { revalidatePath } from 'next/cache';
import { auth } from '@/auth';
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user) {
// 抛出错误,不需要返回 { error: "..." }
throw new Error('Unauthorized! User not found.');
}
const title = formData.get('title') as string;
if (!title) {
throw new Error('Title is required!');
}
// ... save to db
}
app/components/ErrorHandling.tsx
'use client';
import { createServerAction } from 'next-server-action';
import { createPost } from '../actions';
const [createPostAction, isPending, error] = createServerAction(createPost);
export default function ErrorExample() {
return (
<form action={createPostAction}>
{/* ... inputs ... */}
<button type="submit" disabled={isPending}>Submit</button>
{/*
error 是一个 PromiseRejectedResult 对象。
它包含 message, stack 等信息。
*/}
{error && (
<div className="p-4 bg-red-500 text-white">
<h3>Something went wrong:</h3>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
)}
</form>
);
}
第七章:安全性与认证——服务器端的上帝视角
这是 Server Actions 最大的优势之一。
在传统的 API 路由中,为了安全,你通常需要手动处理 Authorization: Bearer <token>。如果你忘了加,或者 token 过期了,服务器就会返回 401。
而在 Server Actions 中,auth() 函数(通常来自 NextAuth.js 或类似库)会自动从请求头中读取 Session。如果你没有登录,它会直接返回 null 或抛出错误。
这意味着,你不需要在客户端组件里管理任何认证逻辑。
// 这是一个 Server Action
export async function deleteAccount() {
const session = await auth();
if (!session) {
throw new Error('Must be logged in');
}
// 只有登录用户才能执行此操作
await db.user.delete({ where: { id: session.user.id } });
}
对于 CSRF(跨站请求伪造)攻击,Server Actions 也自带防护。它们被包装在 HTML 表单中,浏览器会自动验证来源。
第八章:缓存与重新验证
表单提交后,最痛苦的事情莫过于数据更新了,但页面上的列表还是旧的。你需要手动刷新页面。
Server Actions 引入了 revalidatePath 和 revalidateTag。
app/actions.ts
import { revalidatePath } from 'next/cache';
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string;
// ... update db
// 告诉 React:“我刚刚修改了 /profile 页面,请清除该页面的缓存”
revalidatePath('/profile');
// 或者告诉 React:“我修改了所有包含 'blog' 标签的页面”
revalidateTag('blog');
}
当你在服务器组件中使用这些数据时,React 会自动检查缓存。如果缓存过期(因为我们调用了 revalidate),它会重新运行 Server Component 生成页面。
app/profile/page.tsx (Server Component)
import { getProfile } from '../actions'; // 假设这是获取数据的函数
import { revalidatePath } from 'next/cache';
// 这个组件在服务器上运行
export default async function ProfilePage() {
// React 会检查缓存。如果刚刚调用了 updateProfile,这里会重新获取数据
const profile = await getProfile();
return (
<div>
<h1>{profile.name}</h1>
{/* ... */}
<form action={updateProfile}>
{/* ... */}
</form>
</div>
);
}
这创造了一种非常高效的循环:用户提交 -> 服务器更新 -> 服务器标记缓存失效 -> 下次渲染时自动获取新数据。
第九章:进阶技巧与陷阱
虽然 Server Actions 很强大,但它们不是银弹。作为一个资深专家,我必须告诉你几个坑。
1. 不要在 Server Actions 里用 useEffect
Server Actions 运行在服务器上。服务器没有浏览器环境,没有 window 对象,没有 document。如果你试图在 Server Action 里 useEffect,你会得到一个运行时错误。
2. 递归调用 Server Action
Server Actions 可以互相调用。
// actions.ts
export async function stepOne(data: any) {
const result = await stepTwo(data);
return result;
}
export async function stepTwo(data: any) {
return data;
}
这允许你将复杂的逻辑拆分成多个小的函数,方便测试和维护。
3. 文件上传
Server Actions 原生支持文件上传!你不需要使用 FormData 来手动处理二进制数据,React 会自动帮你处理。
export async function uploadAvatar(file: File) {
// file 是一个真实的 File 对象
const buffer = Buffer.from(await file.arrayBuffer());
// ... save buffer to disk
}
4. 缓存策略
默认情况下,Server Actions 的结果可能会被缓存。如果你希望每次都获取最新数据,可以使用 { cache: 'no-store' }。
export async function getPosts() {
'use server';
return await db.post.findMany(); // 默认可能被缓存
}
export async function getPostsFresh() {
'use server';
return await db.post.findMany(); // 默认可能被缓存
}
注意:'use server' 指令必须出现在文件的最顶部,在任何 import 之前。
第十章:总结与展望
我们今天花了大量时间在 Server Actions 上。从最基础的表单提交,到复杂的乐观 UI,再到安全性和缓存策略。
React Server Actions 最大的贡献,是模糊了客户端和服务器之间的界限。
- 客户端组件 负责交互、状态、用户体验。
- 服务器组件 负责数据获取、业务逻辑、安全性。
Server Actions 是连接这两者的桥梁。它消除了 fetch API 的样板代码,消除了手动管理 hydration 的噩梦,让开发者可以专注于业务本身。
想象一下,你不再需要为了一个简单的“保存”按钮写 50 行代码,只需要几行 Server Action 函数,配合 useFormState,就能实现一个健壮、安全、体验极佳的表单。
这就是现代 React 开发的魅力。不要害怕拥抱这些新特性,它们已经足够成熟,可以帮你把那些陈旧的、臃肿的代码扔进垃圾桶。
好了,今天的讲座就到这里。现在,去把你的那个满是 useEffect 的表单重构一下吧!如果有任何问题,或者重构过程中遇到了什么坑,欢迎在评论区吐槽。谢谢大家!