前言
最近在学习next,学习的过程中,感觉server action很神奇,然后就看了一下源码,下面给大家分享一下server action的实现原理。
题外话
记得server action刚发布的时候,议论声比较多,很多人质疑安全性,等大家看完我下面分享的内容后,自然就会明白了。
实现demo
创建项目
找一个合适的目录,使用下面命令创建项目:
sh
npx create-next-app@latest
除了第一个输入自己项目名称外,其他都用默认就行了。项目创建成功后会自动使用npm
安装依赖,如果想用pnpm
安装,可以手动给终止掉,然后自己使用pnpm
安装依赖。
写一个例子
改造page.tsx文件,监听按钮点击事件,调用action获取userName,然后把userName显示在页面中。这里需要添加'use client'
变成客户端组件,因为使用了useState
。
tsx
// src/app/page.tsx
'use client'
import { useState } from 'react';
import { getUserName } from './action';
export default function Home() {
const [name, setName] = useState<string>('');
if (!name) {
return (
<button
onClick={async () => {
const userName = await getUserName();
setName(userName);
}}
>
获取用户信息
</button>
)
}
return (
<div>{name}</div>
)
}
action.ts的实现,这个要运行在服务端,所以使用'use server'
。
ts
// src/app/action.ts
'use server';
export const getUserName = async (): Promise<string> => {
return new Promise((resolve) => {
// 这里可以调用数据库查询数据,为了简单,模拟一个假方法
setTimeout(() => {
resolve('tom');
}, 1000);
});
};
效果展示
![](https://file.jishuzhan.net/article/1764898493660401666/6ffcb48e60113824673e0b8eaddeedbb.webp)
server action实现原理
前言
下面就以上面例子给大家讲一下这一块的流程
编译
当我们运行npm run dev
的时候,项目根目录下会生成一个.next文件夹,其中static文件夹存放静态资源,server文件夹存放服务端代码。
![](https://file.jishuzhan.net/article/1764898493660401666/a5119349142f0b83d9dad7b94493dd3a.webp)
page.tsx编译后的客户端代码文件是.next/static/chunks/app/page.js
,编译后的服务端代码文件是.next/server/app/page.js
。
编译阶段action.ts和page.tsx文件注入了一些代码,这一块的源码使用rust写的,我不懂这一块的代码,所以就不给大家分享它是如何注入的了。
客户端
发送请求
下面我们来分析一下page.tsx编译后的客户端代码,这一块存放的就是page.tsx编译过后的代码。
![](https://file.jishuzhan.net/article/1764898493660401666/5fe5c2733752d831b0c51ae4fcb5a9f7.webp)
在这段代码中找一个点击事件,和点击事件调用的方法,可以看到getUserName方法被存放到一个对象里了。
![](https://file.jishuzhan.net/article/1764898493660401666/32e22f75157619c58381294b000fa757.webp)
找到_action__WEBPACK_IMPORTED_MODULE_2__
变量
![](https://file.jishuzhan.net/article/1764898493660401666/e8e02598370fac9d746417792737d177.webp)
找到"(app-pages-browser)/./src/app/action.ts"
内容,getUserName方法被赋值成另外一个方法,记住这个1f99829abbd5e7e2feeb060777b2ee89d369b381
id。
js
(0, private_next_rsc_action_client_wrapper__WEBPACK_IMPORTED_MODULE_1__.createServerReference)("1f99829abbd5e7e2feeb060777b2ee89d369b381")
![](https://file.jishuzhan.net/article/1764898493660401666/ce25a8eb3a52579d1db8969138a4b5e0.webp)
![](https://file.jishuzhan.net/article/1764898493660401666/0e23290d7b78a5c02b80136458be021d.webp)
大家可以看到,action.ts
文件的代码不应该被编译成这样,那是因为使用在编译的过程中,注入和修改了一部分代码。这一部分源码是用rust写的,我对rust不熟,所以没办法给大家分享,截一段代码rust给大家看一下,我猜测是这里注入的createServerReference
方法。
![](https://file.jishuzhan.net/article/1764898493660401666/fa0ac28cc4323e2d1b56125988377050.webp)
从上面代码中,可以知道createServerReference
方法action-client-wrapper文件里导出的,所以从next源码中找到这个文件。
![](https://file.jishuzhan.net/article/1764898493660401666/e76623e8cb44af80bb4d1c486b670f1b.webp)
这里的代码,可以理解为createServerReference
返回值为callServer
。
app-call-server.ts文件里导出的callServer
![](https://file.jishuzhan.net/article/1764898493660401666/46d3d70e1f5a297254751f05b9354555.webp)
后面还有很多步骤,这里省略一下,最终会执行server-action-reducer
文件里的fetchServerAction
方法
![](https://file.jishuzhan.net/article/1764898493660401666/0d515ea0f0c8fd04678f85b58f3c3a7c.webp)
这个方法中会使用fetch方法一个POST请求,并且请求头上会注入Next-Action属性值是前面说的actionId,如果有参数的话,会调用encodeReply
方法格式化参数。
根据下图可以看到,发送的请求头上确实有Next-Action属性。
![](https://file.jishuzhan.net/article/1764898493660401666/626f28c3318c97cf74881d8e21d5aea3.webp)
请求响应
在fetch成功后,下面会调用createFromFetch
方法。
![](https://file.jishuzhan.net/article/1764898493660401666/bb7244c0943af503803bfc319bc289e6.webp)
react-server-dom-webpack-client.browser.development
文件中的createFromFetch
方法内部会对fetch请求结果进行格式化。格式化值主要使用了当前文件里的createFromJSONCallback
方法,解析返回值内容主要使用了parseModelString
方法。
![](https://file.jishuzhan.net/article/1764898493660401666/613c6343f4a8f1152259310c98fd14d3.webp)
会把下面返回值格式化,最终返回给前端调用的地方,也就是tom
。
![](https://file.jishuzhan.net/article/1764898493660401666/b7ac8eb47deb8460d93b6a79dc33ea9a.webp)
为啥返回的结果是一行一行的,而不是一个json,这是因为返回的结果也是流式响应的,以每行为边界,可以逐行读取并渲染,如果是json的话,只返回一部分是没办法读取的。
小结
调用server action相当于发送了一次http请求,不是直接在前端调用的action里的方法,所以这里可以回答大家上面问题了,不存在安全问题。
看完源码,我惊醒的发现,server action竟然支持返回组件,因为我看到了parseModelTuple
这个方法。
![](https://file.jishuzhan.net/article/1764898493660401666/687f124c0b2be5f30182a46ed78b0463.webp)
把action.ts改成action.tsx
![](https://file.jishuzhan.net/article/1764898493660401666/8b9c8670a66f9a50df0a1235ef876552.webp)
确实可以返回组件
![](https://file.jishuzhan.net/article/1764898493660401666/4426bde4cbed12839a2a063f3759fec1.webp)
看下响应值,我发现很像一些低代码平台的配置数据,我在想以后低代码平台是不是直接用React Server Component
方案就行了。
![](https://file.jishuzhan.net/article/1764898493660401666/61472e533af966e37eb4fa333dc00b3c.webp)
服务端
上面把前端调用action方法的整个流程说完了,下面该说服务端了。根据上面可以知道,调用action方法,实际上是给当前页面url发送了一个POST请求,下面带着大家看一下action里的代码如何在服务端执行的。
思路是找到启动服务的地方,然后看它如何处理请求的就行了。
因为我们启动服务使用的是next dev
命令,在源码中找到next-dev.ts
文件,然后一步步找到处理请求的地方,这里流程特别长,我省略了一部分。直接到最后一步base -serve文件里的doRender
方法,doRender
方法里有个地方判断是否是action请求,然后执行module.render
方法,这里执行的module.render
方法实际上就是action对应的方法,这一步也很复杂,带着大家看一看。
![](https://file.jishuzhan.net/article/1764898493660401666/2509ead88f2c04be5149b14d55c5aa3b.webp)
module
实际是components.routeModule
,components是findPageComponents
方法的返回值,顺着这个方向找requirePage
方法,这个方法实际上是加载page.tsx编译过后的.next/server/app/page.js
服务端文件,这个文件里有routeModule属性。
![](https://file.jishuzhan.net/article/1764898493660401666/1d8d2bba00e7317c374e0ec471180eea.webp)
这里编译代码的时候也注入了一部分代码,源码对应的是module.ts文件。
![](https://file.jishuzhan.net/article/1764898493660401666/0efa3ff00f20471e2012d39a68593719.webp)
顺着renderToHTMLOrFlight
方法找,最终在renderToHTMLOrFlightImpl
方法里发现调用了handleAction
方法,这个方法内部会调用我们写的getUserName方法。
![](https://file.jishuzhan.net/article/1764898493660401666/081887758ccba7b0eef0abb3cdb06099.webp)
actionModId是从编译文件server-reference-manifest.json
里获取到的,对应值是下面这个。
![](https://file.jishuzhan.net/article/1764898493660401666/4dbd0870b9a23e8e24791c30802f2eb4.webp)
ComponentMod.__next_app__.require
是page.js文件里的__webpack_require__方法,用他可以加载模块代码。
所以ComponentMod.__next_app__.require(actionModId)
代码表示加载(action-browser)/./node_modules/.pnpm/next@14.1.0_react-dom@18.2.0_react@18.2.0/node_modules/next/dist/build/webpack/loaders/next-flight-action-entry-loader.js?...
模块代码,这个模块里存放着getUserName对应的代码。
![](https://file.jishuzhan.net/article/1764898493660401666/2947e4d64bc9260c874f9943cc474153.webp)
这个方法的返回值,可以简化为
json
{
"24dd4cefe5d4e4f989e807eb53119f2a59ddce5b": () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve((
<div className='text-red-500'>hello</div>
));
}, 1000);
});
}
}
![](https://file.jishuzhan.net/article/1764898493660401666/c6a2cafc4b4ce3afae8da52ca0014404.webp)
actionId从请求头上可以拿到,这段代码执行了getUserName方法并且拿到了返回值。因为这个方法返回的是个组件,不能直接返回给前端,所以后面还有一段代码使用了服务端渲染把组件转成了RSC数据格式返回。
封装useAction
前言
在做实战demo的时候,使用action的地方比较多,每次都要处理loading和错误,我觉得很麻烦,所以按照useRequest封装了一个useAction hook,并且为了规范化限制了action方法的返回值, 下面给大家分享一下。
代码实现
返回值工具类
上面说了,为了规范化,给action的返回值做了限制,为了少写一点代码,所以给封装一个工具方法,这个思路来源于我以前做后端开发的时候。
ts
import type { ErrorResponse, SuccessResponse } from '../types';
const SUCCESS_NUMBER = 10000;
const ERROR_NUMBER = 100001;
export class R {
public static error(message: string): ErrorResponse {
return {
code: ERROR_NUMBER,
message,
};
}
public static success<T>(data: T, message?: string): SuccessResponse<T> {
return {
code: SUCCESS_NUMBER,
message,
data,
};
}
}
正常返回使用success方法,如果出现一些校验错误,可以使用error方法。
ts
export type ErrorResponse = {
message: string,
code: 100001,
data?: null,
}
export type SuccessResponse<T> = {
message?: string,
code: 10000,
data: T,
}
这个是返回值的类型定义
useAction实现
tsx
'use client'
import { useCallback, useState } from 'react';
import type { ErrorResponse, SuccessResponse } from './types';
type ErrorResult = {
error: true
message: string
data?: null
}
type SuccessResult<T> = {
error: false
message?: string
data: T
}
export default function useAction<T extends (...args: any) => Promise<ErrorResponse | SuccessResponse<unknown>>>(handler: T) {
const [data, setData] = useState<Awaited<ReturnType<T>>['data'] | undefined>();
const [error, setError] = useState<boolean>(false);
const [message, setMessage] = useState<string | undefined>();
const [loading, setLoading] = useState<boolean>(false);
const action = useCallback(async (...args: Parameters<T>): Promise<ErrorResult | SuccessResult<Awaited<ReturnType<T>>['data']>> => {
try {
setLoading(true);
setError(false);
setData(void 0);
setMessage(void 0);
const data = await handler(...args);
setMessage(data.message);
setData(data.data);
if (data.code === 10000) {
setError(false);
return {
data: data.data,
error: false,
message: data.message,
};
} else {
setError(true);
return {
error: true,
message: data.message,
};
}
} catch (error: any) {
setMessage(error?.message);
setError(true);
return {
error: true,
message: '服务器出现错误,请联系管理员',
};
} finally {
setLoading(false);
}
}, [handler]);
return {
data,
error,
message,
run: action,
loading,
}
}
useAction代码比较简单,思路来自于ahooks库里的useRequest方法,代码比较简单我就不给大家一一介绍了。
有两个地方需要注意一下,因为组件使用了useState,所以这个hook需要加上'use client'表示是客户端组件,也只能在客户端组件中使用。
如果服务端出现没有捕获的异常,请求会变成500,会进入catch方法,这里的message不能直接使用error里的message,防止暴露服务端敏感信息。
使用例子
下面看一下如何在业务代码中使用
改造action里的getUserName方法
ts
'use server';
import { R } from './utils/response';
export const getUserName = async () => {
// 如果想返回报错,可以使用 R.error('error') 方法
// 也可以使用 throw new Error('error') 方法,不过这里的error文本,不会在前端显示出来,上面这种方法可以自定义error消息
return R.success('tom');
};
改造page.tsx文件
tsx
'use client'
import { getUserName } from './action';
import useAction from './useAction';
export default function Home() {
const { run, data, error, loading, message } = useAction(getUserName);
if (error) {
return (
<div>{message}</div>
)
}
if (loading) {
return (
<div>loading...</div>
)
}
if (!data) {
return (
<button
onClick={async () => {
// 这里也可以拿到函数的返回值
const { data, error, message } = await run();
console.log(data, error, message);
}}
>
获取用户信息
</button>
)
}
return (
<div>{data}</div>
)
}
getUserName的返回值类型推断还能正常使用
![](https://file.jishuzhan.net/article/1764898493660401666/3e96ebe036f8b2da4ff2ccde58516ed2.webp)
![](https://file.jishuzhan.net/article/1764898493660401666/3a136ea30439566b3ed7e4c2be63faa0.webp)
通过form调用action
上面给大家分享了在事件中调用action的场景,还有一种通过form的action属性调用,原理和手动调用差不多。
![](https://file.jishuzhan.net/article/1764898493660401666/99cd531e3dbe6957e61837e6b8bad828.webp)
![](https://file.jishuzhan.net/article/1764898493660401666/c63bbf22610d3d9ec486331522e14595.webp)
通过form发送请求给后端,把actionId当参数传过去,代码还是执行在服务端。
总结
刚接触next的时候,感觉server action很神奇,看完源码后才知道这一块的实现确实很复杂,从编译到前端然后再到后端都有大量的代码。
大家在使用action的时候不用担心安全问题,因为它是跑在服务端的,别人改不了。
后续计划
这篇文章给大家分享了action的执行的大致流程,很多细节我都省略了,后面会出个单篇讲解具体的源码实现。