前言
最近在学习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);
});
};
效果展示
server action实现原理
前言
下面就以上面例子给大家讲一下这一块的流程
编译
当我们运行npm run dev
的时候,项目根目录下会生成一个.next文件夹,其中static文件夹存放静态资源,server文件夹存放服务端代码。
page.tsx编译后的客户端代码文件是.next/static/chunks/app/page.js
,编译后的服务端代码文件是.next/server/app/page.js
。
编译阶段action.ts和page.tsx文件注入了一些代码,这一块的源码使用rust写的,我不懂这一块的代码,所以就不给大家分享它是如何注入的了。
客户端
发送请求
下面我们来分析一下page.tsx编译后的客户端代码,这一块存放的就是page.tsx编译过后的代码。
在这段代码中找一个点击事件,和点击事件调用的方法,可以看到getUserName方法被存放到一个对象里了。
找到_action__WEBPACK_IMPORTED_MODULE_2__
变量
找到"(app-pages-browser)/./src/app/action.ts"
内容,getUserName方法被赋值成另外一个方法,记住这个1f99829abbd5e7e2feeb060777b2ee89d369b381
id。
js
(0, private_next_rsc_action_client_wrapper__WEBPACK_IMPORTED_MODULE_1__.createServerReference)("1f99829abbd5e7e2feeb060777b2ee89d369b381")
大家可以看到,action.ts
文件的代码不应该被编译成这样,那是因为使用在编译的过程中,注入和修改了一部分代码。这一部分源码是用rust写的,我对rust不熟,所以没办法给大家分享,截一段代码rust给大家看一下,我猜测是这里注入的createServerReference
方法。
从上面代码中,可以知道createServerReference
方法action-client-wrapper文件里导出的,所以从next源码中找到这个文件。
这里的代码,可以理解为createServerReference
返回值为callServer
。
app-call-server.ts文件里导出的callServer
后面还有很多步骤,这里省略一下,最终会执行server-action-reducer
文件里的fetchServerAction
方法
这个方法中会使用fetch方法一个POST请求,并且请求头上会注入Next-Action属性值是前面说的actionId,如果有参数的话,会调用encodeReply
方法格式化参数。
根据下图可以看到,发送的请求头上确实有Next-Action属性。
请求响应
在fetch成功后,下面会调用createFromFetch
方法。
react-server-dom-webpack-client.browser.development
文件中的createFromFetch
方法内部会对fetch请求结果进行格式化。格式化值主要使用了当前文件里的createFromJSONCallback
方法,解析返回值内容主要使用了parseModelString
方法。
会把下面返回值格式化,最终返回给前端调用的地方,也就是tom
。
为啥返回的结果是一行一行的,而不是一个json,这是因为返回的结果也是流式响应的,以每行为边界,可以逐行读取并渲染,如果是json的话,只返回一部分是没办法读取的。
小结
调用server action相当于发送了一次http请求,不是直接在前端调用的action里的方法,所以这里可以回答大家上面问题了,不存在安全问题。
看完源码,我惊醒的发现,server action竟然支持返回组件,因为我看到了parseModelTuple
这个方法。
把action.ts改成action.tsx
确实可以返回组件
看下响应值,我发现很像一些低代码平台的配置数据,我在想以后低代码平台是不是直接用React Server Component
方案就行了。
服务端
上面把前端调用action方法的整个流程说完了,下面该说服务端了。根据上面可以知道,调用action方法,实际上是给当前页面url发送了一个POST请求,下面带着大家看一下action里的代码如何在服务端执行的。
思路是找到启动服务的地方,然后看它如何处理请求的就行了。
因为我们启动服务使用的是next dev
命令,在源码中找到next-dev.ts
文件,然后一步步找到处理请求的地方,这里流程特别长,我省略了一部分。直接到最后一步base -serve文件里的doRender
方法,doRender
方法里有个地方判断是否是action请求,然后执行module.render
方法,这里执行的module.render
方法实际上就是action对应的方法,这一步也很复杂,带着大家看一看。
module
实际是components.routeModule
,components是findPageComponents
方法的返回值,顺着这个方向找requirePage
方法,这个方法实际上是加载page.tsx编译过后的.next/server/app/page.js
服务端文件,这个文件里有routeModule属性。
这里编译代码的时候也注入了一部分代码,源码对应的是module.ts文件。
顺着renderToHTMLOrFlight
方法找,最终在renderToHTMLOrFlightImpl
方法里发现调用了handleAction
方法,这个方法内部会调用我们写的getUserName方法。
actionModId是从编译文件server-reference-manifest.json
里获取到的,对应值是下面这个。
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对应的代码。
这个方法的返回值,可以简化为
json
{
"24dd4cefe5d4e4f989e807eb53119f2a59ddce5b": () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve((
<div className='text-red-500'>hello</div>
));
}, 1000);
});
}
}
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的返回值类型推断还能正常使用
通过form调用action
上面给大家分享了在事件中调用action的场景,还有一种通过form的action属性调用,原理和手动调用差不多。
通过form发送请求给后端,把actionId当参数传过去,代码还是执行在服务端。
总结
刚接触next的时候,感觉server action很神奇,看完源码后才知道这一块的实现确实很复杂,从编译到前端然后再到后端都有大量的代码。
大家在使用action的时候不用担心安全问题,因为它是跑在服务端的,别人改不了。
后续计划
这篇文章给大家分享了action的执行的大致流程,很多细节我都省略了,后面会出个单篇讲解具体的源码实现。