React 动作(Actions)闭环:在 RSC 中利用 Server Actions 实现全栈类型的端到端同步

React 动作(Actions)闭环:在 RSC 中利用 Server Actions 实现全栈类型的端到端同步

各位好,我是你们的“全栈救火队员”。今天我们不聊那些花里胡哨的 UI 动画,也不聊那些让人头秃的 CSS 布局,我们来聊点“痛”。

痛在哪里?痛在“全栈”这个词本身。

在过去的几年里,前端工程师和后端工程师就像两个在同一个房间里但互不说话的室友。前端说:“我要一个 user_id 的类型定义。” 后端说:“行,给你。” 结果前端一运行,发现类型定义是 any,或者后端传了个字符串,前端却期待个数字。于是,前端开始疯狂地写 as any,后端开始疯狂地写 @ts-ignore

这就是“全栈”的痛点:数据流断裂

直到 React Server Components(RSC)和 Server Actions 的出现,这种断裂才被缝合。今天,我们要深入探讨如何利用 Server Actions,在 RSC 的生态里,构建一个完美的、闭环的、全栈类型的端到端同步系统。

准备好了吗?让我们把键盘敲得像打字机一样响,开始这场代码的狂欢。


第一部分:告别 Fetch,拥抱“隐形”的魔法

在讲 Server Actions 之前,我们得先聊聊它的前任——fetch

在传统的 Next.js App Router 时代,如果你想在客户端组件里调用服务器接口,你通常得这么写:

// app/components/MyForm.tsx (客户端组件)
'use client';

import { useState } from 'react';

export default function MyForm() {
  const [data, setData] = useState(null);

  const handleSubmit = async (formData) => {
    // 噩梦开始了:手动序列化,手动处理 Headers,手动处理 Cookie
    const res = await fetch('/api/submit', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // 这里可能还需要手动把 Cookie 搬运过去
        'Cookie': document.cookie 
      },
      body: JSON.stringify(formData)
    });

    const json = await res.json();
    setData(json);
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

看到上面的代码,是不是觉得一股寒意从脚底板升起?为了传递一个表单,你不仅要写业务逻辑,还得操心 HTTP 协议的细节。而且,最糟糕的是类型安全

如果你在 formData 里传了一个不存在的字段,后端报错了,前端可能要在运行时才能发现。如果你在 TypeScript 里定义了输入类型,但 fetch 发送的数据结构不对,TS 编译器根本管不着。

Server Actions 就是为了终结这种“搬运工”生活而生的。

Server Actions 是一种特殊的函数。它们在服务器上运行,可以被服务器组件直接调用,也可以被客户端组件通过表单提交来调用。最重要的是,它们是“隐形”的。你不需要定义 API 路由,不需要写 fetch,你只需要定义一个函数,然后在 JSX 里把它 use 起来。


第二部分:构建“闭环”的基石——类型定义

Server Actions 的核心魅力在于,它强制你进行类型定义。它不允许你把数据像垃圾一样随意扔来扔去。

我们以一个经典的“待办事项”为例。我们要实现一个闭环:前端输入 -> 后端验证 -> 数据库存储 -> 前端更新。

1. 定义 Schema:真理的源头

首先,我们得定义什么是“真理”。在 Server Actions 中,真理通常来自 zod(或者 yup,但 Zod 是 React 社区的宠儿)。Zod 既能验证数据,又能生成 TypeScript 类型。

// app/actions.ts
import { z } from 'zod';

// 定义输入类型 Schema
const TodoSchema = z.object({
  id: z.string().uuid().optional(), // 可选的 ID,新增时没有
  title: z.string().min(3, "标题太短了,至少三个字!"),
  completed: z.boolean(),
});

// 利用 Zod 的 infer 获取 TypeScript 类型
// 这一步非常重要!这是闭环的起点。
type TodoInput = z.infer<typeof TodoSchema>;
type TodoOutput = TodoInput; // 假设我们不做额外的转换

// Server Action 函数
// 注意 'use server' 指令,这告诉 Next.js:把这段代码放到服务器上运行
export async function addTodo(input: TodoInput): Promise<{ success: boolean; error?: string }> {
  try {
    // 这里就是你的数据库逻辑了
    // 比如 await db.todo.create({ data: input })

    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500));

    console.log("服务器收到了:", input);
    return { success: true };
  } catch (error) {
    console.error("服务器出错了", error);
    return { success: false, error: "服务器内部错误" };
  }
}

看!这就是闭环的起点。我们在 actions.ts 里定义了 TodoInput 类型。这个类型不仅仅是一个字符串,它包含了所有的验证规则。它就是整个系统的“宪法”。

2. 服务器组件:数据的静态展示

现在,让我们回到最纯粹的 RSC 世界——服务器组件。在这里,我们不需要担心 Cookie,不需要担心序列化,我们直接定义动作。

// app/page.tsx (服务器组件)
import { addTodo } from './actions';
import { TodoList } from './components/TodoList';

// 初始数据,从数据库或 API 获取
const initialTodos = [
  { id: '1', title: '学习 React Server Actions', completed: false },
  { id: '2', title: '喝一杯咖啡', completed: true },
];

export default function Page() {
  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-6">全栈类型闭环演示</h1>

      {/* 
         在服务器组件中,我们直接调用 Server Action。
         这就像调用一个普通的函数,但是它是在服务器上跑的。
         这里的类型是完美的!
      */}
      <TodoList initialTodos={initialTodos} addTodo={addTodo} />
    </div>
  );
}

注意看 addTodo={addTodo}。这里没有任何魔法,就是一个普通的函数传递。但是,Page 组件知道这个函数接收什么类型(TodoInput),返回什么类型(Promise<{ success: boolean... }>)。TypeScript 编译器会在这里帮你把关。


第三部分:客户端组件的交互——useFormState 的艺术

接下来,我们需要一个客户端组件来处理用户输入。这是 Server Actions 展现其魔法的地方。

我们不再需要手动构造 FormData 对象,也不需要手动 fetch。我们可以使用 React 18 引入的 useFormState Hook。

// app/components/TodoList.tsx
'use client';

import { useState } from 'react';
import { addTodo, TodoInput } from '../actions';

export function TodoList({ initialTodos, addTodo }: { 
  initialTodos: TodoInput[], 
  addTodo: (input: TodoInput) => Promise<{ success: boolean; error?: string }> 
}) {
  const [todos, setTodos] = useState<TodoInput[]>(initialTodos);
  const [isPending, startTransition] = useState(false); // 用于乐观更新

  // handleSubmit 的逻辑
  // 注意:这里我们不需要手动构建 FormData,Server Action 接收的就是普通的对象!
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 获取表单数据
    const formData = new FormData(e.currentTarget);

    // 将 FormData 转换为普通对象
    // 注意:这里需要手动转换一下,因为 Server Action 接收的是普通对象
    // 或者你可以使用 react-hook-form 等库来处理这个转换
    const data = {
      title: formData.get('title') as string,
      completed: formData.get('completed') === 'on',
    };

    // 调用 Server Action
    // 这里我们不需要 await,因为 React 会自动处理状态更新
    addTodo(data);
  };

  return (
    <div className="max-w-md mx-auto bg-white p-6 rounded shadow">
      <form onSubmit={handleSubmit} className="space-y-4">
        <input
          name="title"
          type="text"
          placeholder="输入待办事项..."
          required
          className="w-full border p-2 rounded"
        />
        <label className="flex items-center space-x-2">
          <input name="completed" type="checkbox" className="rounded" />
          <span>已完成</span>
        </label>
        <button
          type="submit"
          disabled={isPending}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
        >
          {isPending ? '提交中...' : '添加任务'}
        </button>
      </form>

      <ul className="mt-6 space-y-2">
        {todos.map((todo) => (
          <li key={todo.id || 'new'} className="flex justify-between items-center border-b pb-2">
            <span className={todo.completed ? 'line-through text-gray-500' : ''}>
              {todo.title}
            </span>
            <span>{todo.completed ? '✅' : '⏳'}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

等等,这里有个问题! 你可能会问:“上面的代码虽然能跑,但数据更新了吗?列表怎么刷新?”

这就是 Server Actions 的“半成品”状态。上面的代码只是把数据发给了服务器,但服务器并没有把新数据传回来。我们需要利用 Server Components 的状态同步机制 来解决这个问题。


第四部分:真正的闭环——状态同步与乐观更新

要实现“端到端同步”,我们不能仅仅依赖 useState。我们需要一种机制,让服务器组件的 todos 状态能够响应客户端的操作。

这里有两个层面的同步:

  1. 数据同步:服务器组件获取最新数据,客户端组件展示最新数据。
  2. 状态同步:客户端操作后,立即更新 UI,无需等待服务器响应。

让我们重构代码,引入 useFormStateuseOptimistic

1. 修改 Server Action:返回新数据

Server Action 不应该只返回 success,它应该返回操作后的新状态。

// app/actions.ts
// 假设我们有一个数据库客户端
// import { db } from '@/lib/db'; 

export async function addTodo(input: TodoInput): Promise<{ success: boolean; error?: string; newTodos: TodoInput[] }> {
  try {
    // 模拟数据库插入
    const newTodo = { ...input, id: crypto.randomUUID() };

    // 模拟从数据库获取所有数据
    // const allTodos = await db.todo.findMany(); 
    // const newTodos = [...allTodos, newTodo];

    // 模拟数据
    const newTodos = [
      { id: '3', title: input.title, completed: input.completed }
    ];

    return { success: true, newTodos };
  } catch (error) {
    return { success: false, error: "Error" };
  }
}

2. 修改客户端组件:利用 useFormState

这是 React 18 的杀手级特性。useFormState 允许我们监听 Server Action 的返回值,并自动更新组件状态。

// app/components/TodoList.tsx
'use client';

import { useFormState } from 'react-dom';
import { addTodo, TodoInput } from '../actions';

// 定义 State 的类型:成功/失败 的结果
type State = {
  success: boolean;
  error?: string;
  newTodos?: TodoInput[];
};

export function TodoList({ initialTodos }: { initialTodos: TodoInput[] }) {
  // 初始化 state
  const [state, formAction] = useFormState<State, TodoInput>(addTodo, {
    success: true,
    newTodos: initialTodos
  });

  // 使用 useOptimistic 进行乐观更新
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(state.newTodos || initialTodos);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const data = {
      title: formData.get('title') as string,
      completed: formData.get('completed') === 'on',
    };

    // 关键步骤:使用 formAction 替代 addTodo
    // React 会自动拦截这个函数的执行,并传递返回值给 state
    formAction(data);
  };

  return (
    <div className="max-w-md mx-auto bg-white p-6 rounded shadow">
      <form action={formAction} className="space-y-4">
        <input
          name="title"
          type="text"
          placeholder="输入待办事项..."
          required
          className="w-full border p-2 rounded"
        />
        <label className="flex items-center space-x-2">
          <input name="completed" type="checkbox" className="rounded" />
          <span>已完成</span>
        </label>
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          添加任务
        </button>
      </form>

      {state.error && <div className="text-red-500 mt-2">Error: {state.error}</div>}

      <ul className="mt-6 space-y-2">
        {/* 展示乐观更新后的数据,或者最终确认的数据 */}
        {optimisticTodos.map((todo) => (
          <li key={todo.id || 'optimistic'} className="flex justify-between items-center border-b pb-2">
            <span className={todo.completed ? 'line-through text-gray-500' : ''}>
              {todo.title}
            </span>
            <span>{todo.completed ? '✅' : '⏳'}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

这太神奇了!

  1. 类型闭环addTodo 的参数类型是 TodoInput,在 formAction 调用时,React 会自动推导出传入的 FormData 符合这个类型。
  2. 状态同步useFormState 拦截了 Server Action 的返回值。一旦服务器返回了 newTodos,React 自动更新了 UI。
  3. 乐观更新useOptimistic 让我们在数据真正到达服务器之前,就先更新 UI。用户体验瞬间从“等待 500ms”变成了“即时响应”。

第五部分:深入 RSC 内核——服务端与客户端的契约

在 Server Actions 的世界里,有一个概念叫 “契约”。这个契约就是你的函数签名。

当你在服务器组件中调用一个 Server Action 时,React 的运行时(RSC Runtime)会生成一个特殊的“操作对象”。这个对象包含:

  1. 函数名。
  2. 函数的参数序列化后的数据。
  3. 用于标识当前请求的 Token(用于权限验证)。

当这个操作对象被发送到客户端(通过 RSC payload),客户端的运行时会:

  1. 反序列化数据。
  2. 调用服务器上的实际函数。
  3. 将结果(RSC 片段)发回服务器组件。

代码示例:Server Component 调用 Server Action

这是 Server Actions 最强大的地方:在服务器组件里直接处理表单提交。

// app/page.tsx
import { addTodo } from './actions';
import { TodoList } from './components/TodoList';
import { redirect } from 'next/navigation'; // 用于重定向

// 这是一个 Server Action
// 注意:它定义在 Server Component 内部!
// 这意味着它可以直接访问服务器的环境变量、数据库、文件系统!
async function createTodo(formData: FormData) {
  const title = formData.get('title') as string;

  if (!title) {
    // 直接抛出错误,Server Actions 会自动处理并显示 Error Boundary
    throw new Error('Title is required');
  }

  // 直接调用 actions.ts 里的 addTodo
  // 因为它们在同一个文件作用域下,或者通过 import 引入
  // 这里为了演示,我们假设直接调用逻辑
  await addTodo({ title, completed: false });

  // 重定向到当前页面,触发重新渲染
  redirect('/'); 
}

export default function Page() {
  return (
    <div>
      <h1>Server Side Form</h1>
      {/* 
        直接在 Server Component 中使用 formAction!
        不需要任何 'use client' 指令。
        这是全栈同步的极致体现。
      */}
      <form action={createTodo}>
        <input name="title" required />
        <button type="submit">Create</button>
      </form>
    </div>
  );
}

为什么这很重要?
这意味着你的业务逻辑(验证、数据库操作、重定向)可以完全放在服务器组件里。客户端只需要负责“触发”这个动作。你不需要写两套逻辑(一套在 api/route.ts,一套在 page.tsx)。


第六部分:错误处理与边界

Server Actions 让错误处理变得更优雅,但也更隐蔽。

1. Try/Catch

在 Server Action 内部,你可以像写 Node.js 代码一样使用 try/catch

export async function addTodo(input: TodoInput) {
  try {
    // 业务逻辑
  } catch (error) {
    // 捕获错误
    console.error(error);
    // 返回错误信息给客户端
    return { success: false, error: "Database connection failed" };
  }
}

2. Error Boundaries

如果 Server Action 抛出了一个未被捕获的异常(例如数据库连接断开),React 会捕获这个异常,并在客户端显示一个红色的错误页面。

如果你想在页面内捕获这个错误,你需要使用 SuspenseErrorBoundary(React 19 新特性)。

// app/page.tsx
import { ErrorBoundary } from 'react-error-boundary';

function Fallback({ error, resetErrorBoundary }: any) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export default function Page() {
  return (
    <ErrorBoundary FallbackComponent={Fallback}>
      <TodoList initialTodos={[]}/>
    </ErrorBoundary>
  );
}

第七部分:进阶话题——并发与缓存

Server Actions 并不是只有 CRUD。它们是可缓存的,并且支持并发。

1. 缓存控制

默认情况下,Server Actions 是非缓存的。每次调用都会发送到服务器。

但你可以控制缓存策略,这对于性能优化至关重要。

// app/actions.ts
import { revalidatePath } from 'next/cache';

// 这个函数会被缓存 10 秒
export async function getTodos() {
  // ... 查询数据库
  return data;
}

// 这个函数会缓存,但每次调用后都会失效(比如写操作后)
export async function updateTodo(id: string) {
  // ... 更新数据库
  // 手动触发缓存失效
  revalidatePath('/'); 
}

2. 并发模式

在 RSC 中,多个 Server Actions 可以同时运行。

// app/components/ParallelTodos.tsx
'use client';

import { useOptimistic, startTransition } from 'react';
import { toggleTodo, deleteTodo } from '../actions';

export function ParallelTodos({ todos }: { todos: TodoInput[] }) {
  const [optimisticTodos, toggleOptimistic] = useOptimistic(todos);
  const [optimisticTodos2, deleteOptimistic] = useOptimistic(todos);

  const handleToggle = (id: string) => {
    // 并发处理:先更新 UI,再发请求
    startTransition(() => {
      toggleOptimistic({ id, completed: true });
    });
    toggleTodo(id);
  };

  const handleDelete = (id: string) => {
    startTransition(() => {
      deleteOptimistic({ id });
    });
    deleteTodo(id);
  };

  return (
    <div>
      {/* ... */}
    </div>
  );
}

第八部分:实战演练——构建一个博客评论系统

为了巩固刚才的知识,我们来构建一个博客评论系统。这涉及到表单提交、数据展示、乐观更新和错误处理。

1. 定义 Schema

// app/actions.ts
import { z } from 'zod';

const CommentSchema = z.object({
  name: z.string().min(1),
  content: z.string().min(1),
  postId: z.string().uuid(),
});

export type CommentInput = z.infer<typeof CommentSchema>;

export async function postComment(input: CommentInput) {
  // 模拟 API 调用
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log("Comment posted:", input);
  return { success: true };
}

2. 客户端组件

// app/components/CommentForm.tsx
'use client';

import { useFormState } from 'react-dom';
import { postComment, CommentInput } from '../actions';

type State = { success: boolean; error?: string };

export function CommentForm({ postId }: { postId: string }) {
  // 初始状态
  const [state, formAction] = useFormState<State, CommentInput>(postComment, {
    success: true
  });

  return (
    <form action={formAction} className="mb-8">
      <input 
        name="postId" 
        type="hidden" 
        value={postId} 
      />
      <div className="grid grid-cols-1 gap-4">
        <input 
          name="name" 
          placeholder="Your Name" 
          required 
          className="border p-2 rounded"
        />
        <textarea 
          name="content" 
          placeholder="Write a comment..." 
          required 
          className="border p-2 rounded h-32"
        />
        <button 
          type="submit" 
          className="bg-black text-white px-4 py-2 rounded"
        >
          Submit Comment
        </button>
      </div>
      {state.error && <p className="text-red-500 mt-2">{state.error}</p>}
    </form>
  );
}

3. 服务器组件

// app/posts/[id]/page.tsx
import { CommentForm } from '@/components/CommentForm';
import { getComments } from '@/lib/db';

export default async function PostPage({ params }: { params: { id: string } }) {
  const comments = await getComments(params.id);

  return (
    <div className="max-w-2xl mx-auto py-10">
      <h1 className="text-2xl font-bold">React Server Actions Deep Dive</h1>
      <p className="text-gray-600 mb-8">This is a dummy post content.</p>

      <div className="border-t pt-8">
        <h3 className="text-xl font-bold mb-4">Comments ({comments.length})</h3>

        {/* 
          在这里,我们不需要 Client Component。
          CommentForm 是一个 Server Action,它在服务器上运行。
          但为了处理交互,我们通常需要 'use client'。
          这里为了演示 Server Action 的调用,我们假设 CommentForm 已经是 Client Component。
        */}
        <CommentForm postId={params.id} />

        <ul>
          {comments.map(comment => (
            <li key={comment.id} className="mb-4 border-b pb-2">
              <strong>{comment.name}</strong>
              <p className="text-gray-700">{comment.content}</p>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

总结与展望

我们刚刚经历了一场从“混乱的 Fetch”到“有序的闭环”的旅程。

通过 Server Actions 和 RSC,我们实现了以下目标:

  1. 类型安全:在客户端和服务端共享同一套 TypeScript 类型定义,消除了 any 的诱惑。
  2. 代码复用:业务逻辑不需要在 API 路由和页面组件之间重复。
  3. 性能优化:利用 RSC 的优势,减少 JavaScript 包体积,实现服务端渲染。
  4. 用户体验:利用 useOptimisticuseFormState 实现了丝滑的交互体验。

Server Actions 不是银弹,它也有它的局限性(比如难以调试复杂的异步流,或者对某些遗留系统的集成挑战)。但在 React 的全栈演进中,它无疑是最耀眼的一颗星。

它让我们重新思考了“组件”的定义。以前,组件只负责 UI;现在,组件可以是函数,可以是逻辑,可以是数据源。这就是全栈的终极形态——逻辑与视图的统一

下次当你写 fetch 的时候,试着停下来想一想:我是不是应该把这段逻辑变成一个 Server Action? 相信我,你的代码会感谢你的。

好了,今天的讲座就到这里。我要去写代码了,因为我的待办事项列表里,还有一堆 zod schema 没写完呢。再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注