Skip to content

Latest commit

 

History

History
389 lines (284 loc) · 16.9 KB

File metadata and controls

389 lines (284 loc) · 16.9 KB

← 返回首页

第五章 - Server Action 进阶:useActionState 与 revalidatePath

上一章的 Server Action 有两个明显的缺陷:提交表单时按钮没有任何反馈,用户不知道请求是否还在进行;请求失败时也没有错误提示,只是悄悄什么都没发生。这一章解决这两个问题,同时介绍 Next.js 的缓存失效机制。

示例代码:codes

运行方式:

cd codes
npm install
npm run dev

打开 http://localhost:3000/blogs 查看博客列表。

目录

  1. useActionState:给 Server Action 加上状态
  2. ActionState 类型设计
  3. Server Action 的新签名:prevState 参数
  4. 三种 Action 模式
  5. isPending:提交期间的 loading 状态
  6. 服务端组件 + 客户端表单的组合模式
  7. actions.ts 与 types.ts:文件组织
  8. revalidatePath:主动让缓存失效
  9. 服务端渲染到底带来了什么

1. useActionState:给 Server Action 加上状态

上一章的 Server Action 是一个普通的 async 函数,执行完要么 redirect,要么什么都不做。它没有办法把结果传回给页面:成功了跳转走,失败了也跳转走,用户完全不知道发生了什么。

useActionState 是 React 19 提供的 hook,专门解决这个问题。它把 Server Action 包装成一个有状态的版本,让 Action 的返回值能渲染到页面上:

const [state, action, isPending] = useActionState(serverAction, initialState);

三个返回值:

  • state:Server Action 最近一次的返回值,初始值是 initialState
  • action:包装后的 action,传给 <form action={action}>
  • isPending:Server Action 执行期间为 true,执行完后恢复 false

2. ActionState 类型设计

ActionState 是我们自己定义的类型,描述 Action 能返回的所有可能状态。本章用这个结构:

// app/blogs/types.ts
export type ActionState = {
  status: "error" | "success" | "confirm" | null;
  message?: string;
};

export const initialState: ActionState = { status: null };
  • status: null:初始状态,表示还没提交过,不显示任何提示
  • status: "error":请求失败,message 里是错误原因
  • status: "success":操作成功,message 里是成功提示
  • status: "confirm":等待二次确认,message 里是确认提示文字

message 是可选字段,始终由 Server Action 填写,前端只负责渲染,不拼任何文字。


3. Server Action 的新签名:prevState 参数

使用 useActionState 后,Server Action 的函数签名必须调整。原来只接收 formData,现在第一个参数必须是 prevState(上一次的状态),formData 变成第二个:

// 第四章的写法
async function createAction(formData: FormData) { ... }

// 第五章:配合 useActionState,必须加 prevState
async function createAction(prevState: ActionState, formData: FormData): Promise<ActionState> { ... }

prevState 是 React 在调用 Action 时自动注入的上一次状态。大多数情况下用不到它,但参数位置必须保留——下面的 deleteAction 会演示它真正有用的场景。


4. 三种 Action 模式

本章的三个 Action 展示了 useActionState 三种不同的使用方式,复杂度依次递进。

模式一:只处理错误,成功由服务端重定向(createAction)

最简单的模式:出错时返回错误状态,成功时直接在 Server Action 里调用 redirect()

// app/blogs/actions.ts
export async function createAction(prevState: ActionState, formData: FormData): Promise<ActionState> {
  let res: Response;
  try {
    res = await fetch(`${BASE_URL}/api/blogs`, { method: "POST", ... });
  } catch {
    return { status: "error", message: "Network error, please try again." };
  }
  if (!res.ok) return { status: "error", message: `Failed to create blog. (${res.status})` };
  revalidatePath("/blogs");
  redirect("/blogs"); // 成功:服务端直接跳转,不经过前端
}

注意 redirect() 必须在 try/catch 之外。Next.js 的 redirect() 内部通过抛出特殊异常工作,放在 try 块里会被意外捕获,导致跳转失效。正确做法是:先用提前 return 处理所有错误,最后在函数末尾调用 redirect()

前端只需要处理错误情况:

// app/blogs/add/add-form.tsx
export function AddForm() {
  const [state, action, isPending] = useActionState(createAction, initialState);
  return (
    <form action={action}>
      {state.status === "error" && <p className="text-red-500">{state.message}</p>}
      {/* 表单字段 */}
    </form>
  );
}

如何测试:本章的 app/api/blogs/route.ts 里,POST 接口被故意改成始终返回 500。提交新建表单,可以直接看到错误消息出现在表单顶部,表单内容保留。观察完效果后把注释恢复即可。

模式二:成功和失败都返回状态,前端决定是否重定向(updateAction)

编辑成功后,我们希望先在页面上显示"保存成功"的提示,停留一秒再跳转,而不是立刻跳走。这需要 Server Action 把成功状态也返回给前端,由前端控制跳转时机。

export async function updateAction(prevState: ActionState, formData: FormData): Promise<ActionState> {
  // ...
  if (!res.ok) return { status: "error", message: `Failed to update blog. (${res.status})` };
  revalidatePath("/blogs");
  revalidatePath(`/blogs/${id}`);
  return { status: "success", message: "Blog updated. Redirecting..." }; // 不 redirect,把状态交给前端
}

前端用 useEffect 监听 state.status,检测到成功后延迟跳转:

// app/blogs/[id]/edit/edit-form.tsx
export function EditForm({ blog }: { blog: Blog }) {
  const router = useRouter();
  const [state, action, isPending] = useActionState(updateAction, initialState);

  useEffect(() => {
    if (state.status === "success") {
      const timer = setTimeout(() => router.push(`/blogs/${blog.id}`), 1000);
      return () => clearTimeout(timer);
    }
  }, [state.status, blog.id, router]);

  return (
    <form action={action}>
      {state.status === "error" && <p className="text-red-500">{state.message}</p>}
      {state.status === "success" && <p className="text-green-600">{state.message}</p>}
      {/* ... */}
    </form>
  );
}

模式三:读取 prevState 实现二次确认(deleteAction)

前两种模式里 prevState 只是占位,并没有被读取。删除操作用它实现了二次确认:第一次点击不执行删除,只返回一个确认状态;第二次点击才真正删除。

export async function deleteAction(prevState: ActionState, formData: FormData): Promise<ActionState> {
  // 读取 prevState:第一次点击时 status 不是 "confirm",直接返回确认提示
  if (prevState.status !== "confirm") {
    return { status: "confirm", message: "Are you sure? Click again to confirm deletion." };
  }

  // 第二次点击:prevState.status === "confirm",执行真正的删除
  const id = formData.get("id") as string;
  let res: Response;
  try {
    res = await fetch(`${BASE_URL}/api/blogs/${id}`, { method: "DELETE" });
  } catch {
    return { status: "error", message: "Network error, please try again." };
  }
  if (!res.ok) return { status: "error", message: `Failed to delete blog. (${res.status})` };
  revalidatePath("/blogs");
  redirect("/blogs");
}

前端根据 status === "confirm" 改变按钮样式:

export function DeleteForm({ id }: { id: number }) {
  const [state, action, isPending] = useActionState(deleteAction, initialState);
  const isConfirming = state.status === "confirm";

  return (
    <form action={action}>
      <input type="hidden" name="id" value={id} />
      {(state.status === "error" || state.status === "confirm") && (
        <p className={isConfirming ? "text-orange-500" : "text-red-500"}>{state.message}</p>
      )}
      <button type="submit" disabled={isPending} className={isConfirming ? "bg-red-600 text-white" : "bg-red-100 text-red-600"}>
        {isPending ? "Deleting..." : isConfirming ? "Confirm Delete" : "Delete"}
      </button>
    </form>
  );
}

第一次点击几乎瞬间响应(没有网络请求),按钮变成实心红色,橙色警告文字出现。第二次点击才发出真正的删除请求。


5. isPending:提交期间的 loading 状态

isPendinguseActionState 的第三个返回值,在 Server Action 执行期间为 true。用它控制按钮状态,防止重复提交并给用户视觉反馈:

<button type="submit" disabled={isPending}>
  {isPending ? "Creating..." : "Create Blog"}
</button>

如何测试actions.ts 里每个 Action 都加了 1 秒的模拟延迟,提交后可以看到按钮变灰、文字切换的效果。


6. 服务端组件 + 客户端表单的组合模式

useActionState 是 hook,只能在客户端组件里用。但编辑页面需要先在服务端取到博客数据用于预填充,这就产生了一个组合需求:服务端取数据,客户端处理交互

解决方式是拆分组件:

EditBlogPage(服务端组件)
  ↓ 取数据,把 blog 作为 props 传下去
EditForm(客户端组件)
  ↓ 用 useActionState 处理提交
// app/blogs/[id]/edit/page.tsx —— 服务端组件,负责取数据
export default async function EditBlogPage({ params }) {
  const { id } = await params;
  const res = await fetch(`${BASE_URL}/api/blogs/${id}`, { cache: "no-store" });
  const blog: Blog = await res.json();
  return <EditForm blog={blog} />;
}
// app/blogs/[id]/edit/edit-form.tsx —— 客户端组件,负责交互
"use client";

export function EditForm({ blog }: { blog: Blog }) {
  const [state, action, isPending] = useActionState(updateAction, initialState);
  return (
    <form action={action}>
      <input type="hidden" name="id" value={blog.id} />
      <input name="title" defaultValue={blog.title} />
      {/* ... */}
    </form>
  );
}

这个"服务端取数据 + 客户端处理交互"的拆分是 Next.js App Router 里最常见的模式,值得记住。


7. actions.ts 与 types.ts:文件组织

上一章的 Server Action 是写在 page.tsx 里的内联函数("use server" 在函数体第一行)。当 Action 需要从客户端组件调用时,必须把它提取到单独的文件——客户端组件文件里不能内联定义 Server Action。

本章把所有 Action 集中在 actions.ts,文件顶部加 "use server",整个文件所有导出函数都自动成为 Server Action:

// app/blogs/actions.ts
"use server"; // 文件级指令,整个文件所有导出函数都是 Server Action

export async function createAction(...) { ... }
export async function updateAction(...) { ... }
export async function deleteAction(...) { ... }

"use server" 文件有一个限制:只能导出 async 函数,不能导出对象、常量或类型(TypeScript 类型除外,类型在编译后消失不算导出值)。所以 ActionState 类型和 initialState 常量单独放在 types.ts 里:

// app/blogs/types.ts(普通文件,没有 "use server")
export type ActionState = { ... };
export const initialState: ActionState = { status: null };

文件级与函数级 "use server" 的对比:

函数级 文件级
位置 函数体第一行 文件顶部
作用范围 单个函数 整个文件所有导出函数
适用场景 服务端组件里的内联 Action 独立的 actions 文件,供客户端组件导入

8. revalidatePath:主动让缓存失效

上一章的页面 fetch 都带了 cache: "no-store",每次请求都绕过缓存直接取最新数据。本章去掉了这个选项,让 Next.js 的默认缓存机制生效。

revalidatePath 真正发挥作用的场景是:API 是一个独立的外部服务(比如单独部署的后端),Next.js 会缓存对它的 fetch 结果来提升访问速度和性能。此时每当数据发生变化(新建、编辑、删除博客),Next.js 不会自动感知,缓存里还是旧数据,用户看到的页面就不会更新。revalidatePath 的作用就是在数据变更后主动通知 Next.js:这个路径的缓存已经过期,下次访问时重新获取。

本章的 API 和页面跑在同一个 Next.js 进程里,有一个额外问题:build 阶段 Next.js 会尝试静态预渲染页面,但此时 API 服务器还没启动,fetch 连不上 localhost:3000 就会报错。如果 API 是外部独立服务,build 时是可以正常访问的,就没有这个问题。为了绕过这个限制,本章在页面文件里加了:

export const dynamic = "force-dynamic";

这告诉 Next.js 跳过静态预渲染,每次请求时动态渲染。这是本章教学环境的特殊处理,生产环境中 API 独立部署时不需要这一行。

revalidatePath 就是用来解决缓存失效问题的——告诉 Next.js 指定路径的缓存已经过期:

import { revalidatePath } from "next/cache";

// 新建博客后,让列表页缓存失效
revalidatePath("/blogs");

// 编辑博客后,列表页和详情页都需要失效
revalidatePath("/blogs");
revalidatePath(`/blogs/${id}`);

调用后,Next.js 会在下一次有人访问这些路径时重新获取数据,而不是返回缓存的旧版本。

revalidatePath 只在 Server Action 和 Route Handler 里有效,在客户端组件里调用不起作用。

Next.js 的缓存机制远不止这些——还可以按标签失效、按时间过期、按需触发:我们会在后续章节会配合外部的API后端专门介绍。这里先记住:数据有变化,就调用 revalidatePath 通知 Next.js


9. 服务端渲染到底带来了什么

学完这两章,可能会有一个困惑:代码量没有明显减少,useActionState 的写法反而比 useState + fetch 更绕,那服务端组件到底解决了什么问题?

这个困惑有一个根本原因:本教程的 Server Component 调用的是自己项目里的 API Route,相当于服务端绕了一圈再调自己。这是教学上的妥协——为了让客户端渲染和服务端渲染能对比同一套 API,我们保留了 API 这一层。但这恰好把服务端组件最大的优势遮住了。

服务端组件真正的优势是:可以直接访问数据库,不需要 API 这一层。

// 不需要 API Route,不需要 fetch,直接查库
export default async function BlogsPage() {
  const blogs = await db.select().from(blogsTable);
  return <ul>...</ul>;
}
// Server Action 同理,直接写库
export async function createAction(prevState: ActionState, formData: FormData) {
  await db.insert(blogsTable).values({ title, content, author });
  revalidatePath("/blogs");
  redirect("/blogs");
}

API Route、序列化、fetch、错误处理——这些中间层全部消失。这才是"代码量减少"真正发生的地方。

参考案例:https://github.com/bao00022/nextjs_tutorial

整理一下服务端组件和 Server Action 带来的实际收益:

收益 说明
无 API 层 直接访问数据库或内部服务,省去整个 API 层
安全 数据库连接字符串、API 密钥永远不进客户端 bundle
Bundle 减小 只在服务端用的重型库(markdown 解析器、日期库等)不打包进客户端
首屏更快 HTML 直接携带数据,不需要客户端下载 JS 后再发 fetch
SEO 搜索引擎爬虫直接拿到完整 HTML

什么时候服务端组件的优势不明显?

如果后端是独立的外部服务(Fastify、Go、Rails),Next.js 无论如何都要通过 HTTP 调用它——此时服务端组件和客户端组件都在发 fetch,优势就小得多。useActionState 的复杂度也是真实的成本。在这种架构下,纯 React 前端 + 独立后端有时反而更直接。

服务端组件最适合的场景是 Next.js 全栈:前后端在同一个项目里,数据库连接直接在服务端复用,不需要额外的 API 层。后续章节引入独立后端(Fastify)时,两种架构的取舍会更清晰。