nextjs中server action实现原理以及封装useAction优雅的调用action方法

前言

最近在学习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方法被赋值成另外一个方法,记住这个1f99829abbd5e7e2feeb060777b2ee89d369b381id。

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的执行的大致流程,很多细节我都省略了,后面会出个单篇讲解具体的源码实现。

相关推荐
zhangjr05751 小时前
【HarmonyOS Next】鸿蒙实用装饰器一览(一)
前端·harmonyos·arkts
不爱学习的YY酱1 小时前
【操作系统不挂科】<CPU调度(13)>选择题(带答案与解析)
java·linux·前端·算法·操作系统
木子七1 小时前
vue2-vuex
前端·vue
麻辣_水煮鱼1 小时前
vue数据变化但页面不变
前端·javascript·vue.js
BY—-组态1 小时前
web组态软件
前端·物联网·工业互联网·web组态·组态
一条晒干的咸魚1 小时前
【Web前端】实现基于 Promise 的 API:alarm API
开发语言·前端·javascript·api·promise
WilliamLuo2 小时前
MP4结构初识-第一篇
前端·javascript·音视频开发
Beekeeper&&P...2 小时前
web钩子什么意思
前端·网络
啵咿傲2 小时前
重绘&重排、CSS树&DOM树&渲染树、动画加速 ✅
前端·css
前端Hardy2 小时前
HTML&CSS:数据卡片可以这样设计
前端·javascript·css·3d·html