React 服务器动作(Server Actions)底层调用:分析客户端如何通过 Fetch API 提交序列化 Action 数据

各位好,欢迎来到今天的“React 服务器动作底层原理”黑客松现场。我是你们的主讲人,一个在代码堆里刨食、看着 React 每次更新都忍不住想给它鼓掌的资深老司机。

今天我们不聊“怎么写”,我们要聊“它是怎么跑起来的”。很多人觉得 React Server Actions(RSC)是魔法,是 React 团队刚从火星带回来的黑科技。其实,它没那么玄乎。它就是一场精心策划的“越狱”——只不过这次,你是从浏览器越狱到服务器,而且越狱的门票是一串 JSON。

我们要深入到底层,看看当你在 useServerAction 里敲下 action(data) 时,到底发生了什么。准备好了吗?把你的键盘擦干净,我们开始。


第一部分:客户端的“魔术戏法”——useServerAction 的伪装

首先,我们要看的是客户端。你以为你调用的是一个普通的函数 action(formData) 吗?错。在 React Server Actions 的世界里,action 本身其实是一个包装器,或者说是一个诱饵

当你定义一个 Server Action 时:

// app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  return { id: 1, title: 'Hello World' }
}

然后你在组件里这样用:

// components/PostForm.tsx
import { useServerAction } from 'next/actions'
import { createPost } from './actions'

const { execute, isPending, error } = useServerAction(createPost)

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  await execute(new FormData(e.currentTarget))
}

你以为 createPost 是个函数?不,它是个对象。一个拥有 execute 方法的对象。

底层逻辑:

当你调用 useServerAction(createPost) 时,React 并没有直接把那个函数扔给你。它创建了一个 Action Handler

这个 Handler 的核心任务就是欺骗。它假装它是一个普通的异步函数,但实际上,它是一个 Promise 的制造机。

// 这就是 useServerAction 内部大概干的事(伪代码)
function useServerAction(actionCreator) {
  // 1. 创建一个 Promise 包装器
  const promiseWrapper = () => {
    return new Promise(async (resolve, reject) => {
      try {
        // 2. 准备数据
        const serializedData = serializeArgs(arguments) // 我们后面细说这步

        // 3. 发起请求
        const response = await fetch('/api/actions', {
          method: 'POST',
          body: serializedData,
          headers: {
            'X-Action-Request-ID': generateUUID(), // 关键!追踪 ID
            'Content-Type': 'application/json'
          }
        })

        // 4. 解析结果
        const result = await response.json()
        resolve(result)
      } catch (err) {
        reject(err)
      }
    })
  }

  // 5. 返回一个看起来像函数,实际上是 Promise 的对象
  return {
    execute: promiseWrapper,
    isPending: false, // 状态管理
    error: null
  }
}

看懂了吗?execute 本身就是一个异步函数。 当你写 await execute(...) 时,你实际上是在等待一个网络请求完成。这就是为什么你不能在 Server Action 里直接使用 useStateuseEffect,因为它根本不是在组件的渲染线程里跑,而是在浏览器的网络线程里跑。


第二部分:数据的“打包”——序列化(Serialization)的艺术

既然 execute 是个网络请求,那它要带什么东西过去呢?总不能把你的整个组件实例打包过去吧?那服务器得崩溃,或者至少会报个错:“你这个组件对象太大了,我不认识。”

所以,序列化 是这场越狱中最关键的一步。

React Server Actions 使用了一种混合策略:JSON 用于传递对象和基本类型,FormData 用于处理文件。

1. 基础序列化:JSON.stringify 的挣扎

当你提交一个简单的对象时:

const data = {
  name: "React",
  version: 18,
  isMagic: true,
  timestamp: new Date() // 危险!
}

React 会尝试把它扔给 JSON.stringify

  • Date 对象JSON.stringify 会把它变成字符串 "2023-10-27T12:00:00.000Z"
  • 函数:直接被丢弃。
  • 循环引用:直接报错。
  • 不可序列化的对象:比如带有 toJSON 方法但没正确实现的类实例。

这就是为什么你的 Server Action 参数类型通常要是 anyunknown,或者你必须手动转换。 React 的序列化器非常严格,它不想让服务器收到一堆乱七八糟的东西。

2. FormData:文件上传的救星

如果你传的是 <input type="file">,React 就不能傻傻地把它转成 JSON 了,因为二进制数据在 JSON 里是个灾难。于是,它切换到了 FormData 模式。

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  const formData = new FormData(e.currentTarget)

  // React 会检测到 FormData,自动切换序列化策略
  await execute(formData) 
}

在这里,React 做了一件很聪明的事。它利用了 FormData 的原生 API,将文件流和文本字段分开打包。对于文本字段,它可能还是用 JSON 字符串;但对于文件,它直接利用了浏览器的二进制传输能力。

代码示例:序列化器的内部实现

想象一下 React 内部的一个 serialize 函数大概长这样:

function serializeArgs(args: any[]) {
  // 假设我们只处理第一个参数,也就是 Action 的参数
  const arg = args[0]

  if (arg instanceof FormData) {
    // 模式 A:文件处理
    return new FormData();
  } else {
    // 模式 B:对象处理
    try {
      // 尝试 JSON 序列化
      return JSON.stringify(arg)
    } catch (e) {
      // 如果失败,尝试手动转换 Date 或其他对象
      return JSON.stringify(arg, (key, value) => {
        if (value instanceof Date) return value.toISOString();
        if (value instanceof File) return { type: value.type, name: value.name };
        return value;
      });
    }
  }
}

第三部分:传输协议——Fetch API 的秘密信使

序列化完成后,数据被塞进了 HTTP 请求里。这里就是 fetch API 发挥作用的舞台。

1. 请求头(Headers)的暗号

React Server Actions 发送的请求,不是普通的 GET 或 POST。它携带了一些特殊的“暗号”,服务器必须读懂这些暗号才能把包裹交给正确的收件人。

  • X-Action-Request-ID:这是最关键的头信息。它是一个全局唯一的 UUID。它就像是你在快递单上写的“取件码”。服务器收到请求后,会把这个 ID 存下来,然后把这个 ID 和服务器的处理结果绑定。当服务器返回响应时,它会把这个 ID 带回去。客户端的 Promise 就靠这个 ID 来匹配服务器返回的结果。如果 ID 不匹配,那就是网络乱了。

  • Content-Type:通常是 application/json,除非是 FormData。

2. 请求体(Body)的结构

如果你发送的是一个普通对象,Body 可能长这样:

{
  "args": [
    {
      "name": "Alice",
      "age": 30
    }
  ],
  "actionId": "createPost_abc123" // 哪个 Action 函数
}

注意,React Server Actions 还会发送 actionId。这是因为同一个服务器上可能注册了无数个 Server Actions,服务器需要知道你到底想调用哪一个。

代码示例:拦截网络请求

为了让你更直观地看到这一切,我们可以写一个拦截器:

// 拦截 fetch,打印网络请求
const originalFetch = window.fetch;
window.fetch = async (url, options) => {
  if (url === '/api/actions' && options.method === 'POST') {
    console.log('🚀 React Server Action 正在发送请求!');
    console.log('URL:', url);
    console.log('Headers:', options.headers);
    console.log('Body:', options.body);
  }

  return originalFetch(url, options);
};

当你点击提交按钮时,控制台会打印出这些信息。你会发现,虽然 React 隐藏了细节,但本质上它就是在发一个 POST 请求。


第四部分:服务器的“接收”——Next.js 的 API 路由

现在,数据已经跨越了网络,到达了服务器。在 Next.js (App Router) 中,这通常是由 /app/actions 这个特殊的路由处理器处理的。

1. 路由处理器

Next.js 会在运行时拦截对 /api/actions/actions 的请求(取决于配置),然后交给 Server Actions 的注册表去处理。

2. 反序列化(Deserialization)

服务器收到请求后,第一步是反序列化

// app/actions.ts (服务端)
'use server'

export async function createPost(formData: FormData) {
  // 在这里,formData 已经被自动解析好了
  const title = formData.get('title')
  return { id: 1, title }
}

对于 FormData,Next.js 直接利用 Node.js 的 form-data-parser 库解析。对于 JSON,它解析 args 数组。

3. 执行函数

解析完参数后,服务器会根据 actionId 在全局注册表中找到对应的函数。

// 服务端伪代码
const action = registry.get(actionId)
const result = await action(...args)

此时,函数在服务器进程的内存中运行。它可以直接访问数据库、文件系统、环境变量,没有任何限制。


第五部分:状态的“回传”——Promise 的匹配

这是 React Server Actions 最精妙的地方:如何把服务器的异步结果,准确地送回客户端那个悬而未决的 Promise?

1. 流式响应

Server Actions 返回的响应通常是 流式(Stream) 的。

服务器执行函数时,可能会产生副作用(比如写入日志、发送 WebSocket 消息),但最重要的是,它会生成一个 ReadableStream

// 服务端伪代码
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("Processing...");
    // ... 业务逻辑
    controller.enqueue("Done!");
    controller.close();
  }
});

return new Response(stream, {
  headers: {
    'Content-Type': 'application/json',
    'X-Action-Request-ID': 'the-uuid-from-client' // 带回 ID
  }
});

2. 客户端匹配

客户端的 Promise 一直在等。当它收到这个 Response 时,它提取出 X-Action-Request-ID

  • 完美匹配:如果 ID 对得上,客户端就把这个 Response 解析成 JSON,然后 resolve 它的 Promise。
  • 错误处理:如果函数抛出异常,服务器会把异常包装成一个 JSON 对象,X-Action-Request-ID 一样会带回去。客户端收到后,reject Promise,并抛出错误。

3. 乐观更新(Optimistic UI)的实现

这就是为什么你可以写这样的代码:

const [state, setState] = useState({ posts: [] })

const addPost = useServerAction(createPost)

const handleClick = async () => {
  // 1. 乐观更新:先改 UI
  setState(prev => [...prev, { title: 'New Post', isPending: true }])

  try {
    // 2. 发起请求
    await addPost({ title: 'New Post' })
    // 3. 请求成功,UI 自动恢复(虽然这里没变,但逻辑是通的)
  } catch (err) {
    // 4. 请求失败,回滚 UI
    setState(prev => prev.filter(p => p.title !== 'New Post'))
  }
}

React Server Actions 的底层实现保证了 addPost 是一个异步函数。当你调用它时,isPending 状态会变。这个状态管理机制,本质上就是 useActionState 这个 Hook 的功劳。


第六部分:安全性与身份验证——守门人

你可能会问:“如果我直接在浏览器里发一个 POST 请求给 /api/actions,把 actionId 改成别人的 Action,我就能执行任意代码了?”

当然不能。因为 身份验证 是硬编码在请求里的。

1. Cookies 与 Headers

React Server Actions 在发起请求时,会自动携带当前域下的所有 Cookies。这通常是通过 fetchcredentials: 'include' 实现的。

服务端的 Action 处理函数,就像普通的 API 路由一样,可以访问 cookies()

// app/actions.ts
'use server'

import { cookies } from 'next/headers'

export async function adminAction() {
  const session = cookies().get('session')
  if (!session || session.value !== 'secret-token') {
    throw new Error('Unauthorized')
  }
  // 只有拿着正确 Token 的人才能执行
}

2. CSRF 保护

React Server Actions 还内置了 CSRF(跨站请求伪造)保护。它会在请求头里添加一个 X-CSRF-Token。这个 Token 是基于当前用户的会话和特定的域名动态生成的。如果你尝试在另一个网站(如 evil.com)发起请求,服务器的 Token 校验会失败,请求会被拒绝。


第七部分:进阶话题——零拷贝与上下文

1. 上下文传递

Server Action 是在服务器端运行的,它没有 React 的组件上下文(比如 useRouteruseParams)。但是,React Server Actions 允许你通过 useRef 来传递上下文。

这是 React 的一个 Hack 技巧。

// 在组件中
const actionRef = useRef(() => {
  // 这里可以访问 router 或 params
  const router = useRouter()
  return async (data) => {
    // 业务逻辑
  }
})

// 在 Server Action 中
'use server'
export async function myAction(data) {
  // 这里怎么拿到 router?
  // 实际上,React 会通过某种机制,在运行时把 actionRef 里的函数注入到这里
  // 或者更准确地说,React 允许你在 Action 内部使用 'use' 指令
}

实际上,Next.js 的实现更倾向于直接在服务端组件中定义 Action,从而直接拥有上下文,而不是通过这种 Hack 方式传递。

2. 零拷贝(Zero-Copy)

这是 React Server Actions 最大的卖点之一。通常,发送一个 HTTP 请求需要序列化(把对象转成 JSON 字符串)和反序列化(把 JSON 字符串转回对象)。这涉及 CPU 操作和内存分配。

React Server Actions 尽可能地减少这一步。

  • 对于简单的数据结构,它使用 structuredClone(浏览器和 Node.js 都支持的高性能拷贝 API)。
  • 对于 FormData,它直接传递二进制流。

这意味着,数据在客户端和服务端之间的传输是“最小化开销”的。


第八部分:错误处理的黑魔法

当 Server Action 抛出错误时,它不会导致页面崩溃,而是会被捕获并变成状态。

const { execute, error, isPending } = useServerAction(createPost)

// error 是一个对象,包含 message, cause 等
if (error) {
  return <div>Error: {error.message}</div>
}

在底层,服务器捕获了异常,将其序列化为一个特定的 JSON 结构(包含 message, stack, cause),然后通过流返回给客户端。客户端的 Promise 被拒绝,error 状态被更新。这就是为什么错误处理如此丝滑的原因。


第九部分:流式响应与进度条

想象一下,你上传一个大文件,或者运行一个耗时很长的 SQL 查询。

React Server Actions 支持流式返回。

// 服务端
export async function slowAction() {
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue('Step 1...n')
      setTimeout(() => controller.enqueue('Step 2...n'), 1000)
      setTimeout(() => controller.enqueue('Done!n'), 2000)
    }
  })
  return new Response(stream)
}

在客户端,useServerAction 会监听这个流。虽然它目前主要返回最终结果,但这个机制为未来的功能铺平了道路,比如在处理过程中显示进度条,或者流式渲染 HTML 片段。


第十部分:总结——它是如何工作的?

好了,各位,让我们把镜头拉远,从代码层面回到宏观视角。

React Server Actions 的工作流程就像是一场接力赛

  1. 起跑(客户端触发):你调用 execute()。React 创建一个 Promise,并生成了一个唯一的 ID。
  2. 打包(序列化):React 把你的参数(数据)打包成 JSON 或 FormData。如果是文件,就打包成二进制流。
  3. 发令枪(Fetch 请求):React 发起一个 POST 请求到服务器的 /actions 端点。请求头里带着 X-Action-Request-ID 和身份验证信息。
  4. 交接(服务器接收):服务器收到请求,解析出 actionId 和参数。
  5. 冲刺(执行业务逻辑):服务器找到对应的函数,执行它。此时,它拥有完整的权限,可以访问数据库、文件系统等。
  6. 折返(流式返回):服务器将结果(或错误)打包成 JSON,通过流的方式返回给客户端。
  7. 撞线(Promise 解析):客户端收到响应,提取出 X-Action-Request-ID,找到之前创建的 Promise,resolve 或 reject 它。UI 状态随之更新。

它没有魔法,它只是把“网络请求”这个概念封装得非常漂亮,让你感觉就像在调用本地函数一样。它利用了现有的 HTTP 协议,加上一些精巧的状态管理和序列化技巧,实现了前后端的无缝连接。

这就是 React Server Actions 的底层原理。现在,你可以自信地告诉你的面试官:“我懂它是怎么跑起来的,它不是黑盒,它是 HTTP 和 Promise 的优雅结合。”

好了,今天的讲座就到这里。现在,去写代码吧,记得把你的数据序列化好,别让你的服务器收到一个循环引用!

发表回复

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