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

相关推荐
我要洋人死2 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人14 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人15 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR20 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香22 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969325 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai30 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_91539 分钟前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
小牛itbull3 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress