各位好,欢迎来到今天的“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 里直接使用 useState 或 useEffect,因为它根本不是在组件的渲染线程里跑,而是在浏览器的网络线程里跑。
第二部分:数据的“打包”——序列化(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 参数类型通常要是 any 或 unknown,或者你必须手动转换。 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。这通常是通过 fetch 的 credentials: '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 的组件上下文(比如 useRouter 或 useParams)。但是,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 的工作流程就像是一场接力赛:
- 起跑(客户端触发):你调用
execute()。React 创建一个 Promise,并生成了一个唯一的 ID。 - 打包(序列化):React 把你的参数(数据)打包成 JSON 或 FormData。如果是文件,就打包成二进制流。
- 发令枪(Fetch 请求):React 发起一个 POST 请求到服务器的
/actions端点。请求头里带着X-Action-Request-ID和身份验证信息。 - 交接(服务器接收):服务器收到请求,解析出
actionId和参数。 - 冲刺(执行业务逻辑):服务器找到对应的函数,执行它。此时,它拥有完整的权限,可以访问数据库、文件系统等。
- 折返(流式返回):服务器将结果(或错误)打包成 JSON,通过流的方式返回给客户端。
- 撞线(Promise 解析):客户端收到响应,提取出
X-Action-Request-ID,找到之前创建的 Promise,resolve 或 reject 它。UI 状态随之更新。
它没有魔法,它只是把“网络请求”这个概念封装得非常漂亮,让你感觉就像在调用本地函数一样。它利用了现有的 HTTP 协议,加上一些精巧的状态管理和序列化技巧,实现了前后端的无缝连接。
这就是 React Server Actions 的底层原理。现在,你可以自信地告诉你的面试官:“我懂它是怎么跑起来的,它不是黑盒,它是 HTTP 和 Promise 的优雅结合。”
好了,今天的讲座就到这里。现在,去写代码吧,记得把你的数据序列化好,别让你的服务器收到一个循环引用!