likes
comments
collection
share

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

作者站长头像
站长
· 阅读数 23

前言

Server Actions 是指在服务端执行的异步函数,它们可以在服务端和客户端组件中使用,以处理 Next.js 应用中的数据提交和更改。

在实际项目开发中,Server Actions 往往搭配 Zod 实现数据校验。虽然很好用,但 Server Actions 写多了,你会发现很多“样板代码”比如输入校验、错误处理等等。

使用 next-safe-action 不仅可以定义类型安全的 Server Actions 还可以处理输入校验、错误处理等,让代码更加规范整洁。根据官方的介绍,它具有的特性如下:

  • ✅ 使用简单
  • ✅ 端到端类型安全
  • ✅ 支持 Form Actions
  • ✅ 强大的中间件系统
  • ✅ 输入校验
  • ✅ 高级服务端错误处理
  • ✅ 乐观更新

那就让我们来看看 next-safe-action 是怎么使用的吧!

项目准备

为了方便演示,创建一个空的 Next.js 项目:

npx create-next-app@latest

注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

传统实现

新建 src/app/login/page.tsx,代码如下:

"use client";

import { useState } from "react";
import { signIn } from "./actions";

export default function Page() {
  const [message, setMessage] = useState("");
  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        <div className="sm:mx-auto sm:w-full sm:max-w-sm">
          <form
            action={async (formData) => {
              const result = await signIn(formData);
              setMessage(result.message);
              console.log(JSON.stringify(result));
            }}
            className="space-y-6"
          >
            <div>
              <label
                htmlFor="username"
                className="block text-sm font-medium leading-6 text-gray-900"
              >
                Username
              </label>
              <div className="mt-2">
                <input
                  id="username"
                  name="username"
                  type="text"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                />
              </div>
            </div>

            <div>
              <div className="flex items-center justify-between">
                <label
                  htmlFor="password"
                  className="block text-sm font-medium leading-6 text-gray-900"
                >
                  Password
                </label>
              </div>
              <div className="mt-2">
                <input
                  id="password"
                  name="password"
                  type="password"
                  autoComplete="current-password"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                />
              </div>
            </div>

            <div>
              <button
                type="submit"
                className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
              >
                Sign in
              </button>
            </div>
          </form>
          <p className="mt-2">{message}</p>
        </div>
      </div>
    </>
  );
}

新建src/app/login/actions.ts,代码如下

"use server";

import { z } from "zod";

const schema = z.object({
  username: z.string().min(1, { message: "请输入用户名" }),
  password: z.string().min(1, { message: "请输入密码" }),
});

export async function signIn(formData: FormData) {
  const validatedFields = schema.safeParse(
    Object.fromEntries(formData.entries())
  );

  if (!validatedFields.success) {
    return {
      success: false,
      message: validatedFields.error.issues[0].message,
    };
  }

  return {
    success: true,
    message: "登录成功",
  };
}

浏览器效果如下:

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

这是一个 Server Actions + Zod 的基本示例,当表单数据提交到 Server Actions 的时候,首先解析表单数据,然后使用 Zod 进行校验,如果校验失败,返回一个自定义的错误信息,然后进行正常的逻辑逻辑,处理后返回成功信息。大多数的 Server Actions 都遵循这个实现逻辑。

next-safe-action 实现

首先安装依赖项:

npm i next-safe-action zod

next-safe-action 也支持其他的数据校验库,默认是 Zod,目前 Zod GitHub 31.5k Star,Npm 周均下载量 784W,几乎是前端做数据校验的第一选择。Next.js 官方文档推荐的正是 Zod。如果使用其他的数据校验库,参考 《Validation libraries support | next-safe-action》

然后实例化客户端,新建 src/lib/safe-action.ts,代码如下:

import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient();

现在我们重写 actions 的代码。修改 src/app/login/actions.ts,代码如下:

"use server";

import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
import { flattenValidationErrors } from "next-safe-action";

const schema = z.object({
  username: z.string().min(1, { message: "请输入用户名" }),
  password: z.string().min(1, { message: "请输入密码" }),
});

export const signIn = actionClient
  .schema(schema, {
    handleValidationErrorsShape: (ve) =>
      flattenValidationErrors(ve).fieldErrors,
  })
  .action(async ({ parsedInput: { username, password } }) => {
    if (username === "yayu" && password === "123") {
      return {
        success: true,
        message: "登录成功",
      };
    }

    return { success: false, message: "用户名密码错误" };
  });

通过 schema()函数提供输入校验 Schema,next-safe-action 会确保数据是类型安全且经过校验的。action()会在客户端调用的时候运行,接受传入一个异步函数,可以获取输入上下文。

最后让我们看下客户端该如何使用,修改 src/app/login/page.tsx,代码如下:

"use client";

import { useState } from "react";
import { signIn } from "./actions";
import { ServerActionResponse } from "./ServerActionResponse";

export default function Page() {
  const [result, setResult] = useState<any>({});
  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        <div className="sm:mx-auto sm:w-full sm:max-w-sm">
          <form
            className="space-y-6"
            onSubmit={async (e) => {
              e.preventDefault();
              const formData = new FormData(e.currentTarget);
              const input = Object.fromEntries(formData) as {
                username: string;
                password: string;
              };
              const result = await signIn(input);
              setResult(result);
            }}
            >
            <div>
              <label
                htmlFor="username"
                className="block text-sm font-medium leading-6 text-gray-900"
                >
                Username
              </label>
              <div className="mt-2">
                <input
                  id="username"
                  name="username"
                  type="text"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                  />
              </div>
            </div>

            <div>
              <div className="flex items-center justify-between">
                <label
                  htmlFor="password"
                  className="block text-sm font-medium leading-6 text-gray-900"
                  >
                  Password
                </label>
              </div>
              <div className="mt-2">
                <input
                  id="password"
                  name="password"
                  type="password"
                  autoComplete="current-password"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                  />
              </div>
            </div>

            <div>
              <button
                type="submit"
                className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
                >
                Sign in
              </button>
            </div>
          </form>
          <ServerActionResponse result={result} />
        </div>
      </div>
    </>
  );
}

action 返回的结果是一个对象,有 4 个可选属性:

  1. data:成功执行时返回的结果,也就是在 action 中返回的信息,包括自定义的成功和失败消息
  2. validationErrors:输入数据未通过校验时返回的错误
  3. bindArgsValidationErrors:绑定参数输入数据未通过校验时返回的错误,参考 bindArgsSchemas
  4. serverError:服务端代码执行时出现的错误,为了防止敏感信息泄露,一般是 “Something went wrong while executing the operation.”,可在创建客户端的时候修改

为了方便展示,我们创建了一个 ServerActionResponse 组件用于展示。

浏览器效果如下:

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

中间件

next-safe-action 一个强大的功能是中间件,我们在创建客户端的时候可定义中间件,这样使用该客户端的所有 actions 都会走中间件的逻辑,我们可以用来做日志记录、身份认证等功能。让我们举个例子:

修改 next-safe-actions/src/lib/safe-action.ts,代码如下:

import { createSafeActionClient } from "next-safe-action";
import { cookies } from "next/headers";

export const actionClient = createSafeActionClient().use(
  async ({ next, clientInput, metadata }) => {
    console.log("LOGGING MIDDLEWARE");

    // Here we await the action execution.
    const result = await next({ ctx: null });

    console.log("Result ->", result);
    console.log("Client input ->", clientInput);

    // And then return the result of the awaited action.
    return result;
  }
);

function getUserIdFromSessionId(session) {
  return session;
}

export const authActionClient = actionClient
  // Define authorization middleware.
  .use(async ({ next }) => {
    const session = cookies().get("session")?.value;

    if (!session) {
      throw new Error("Session not found!");
    }

    const userId = await getUserIdFromSessionId(session);

    if (!userId) {
      throw new Error("Session is not valid!");
    }

    // Return the next middleware with `userId` value in the context
    return next({ ctx: { userId } });
  });

在这个例子中,我们定义了两个客户端,一个是 actionClient,一个是 authActionClient。客户端通过 use()来定义中间件,我们给 actionClient 添加了一个日志打印的中间件,然后基于 actionClient 又添加了一个身份校验的中间件。这样当我们使用 authActionClient 的时候,必须先进行身份验证,从而保证用户在登录的状态下调用接口。

那怎么用呢?修改 src/app/login/actions.ts,代码如下:

"use server";

import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { flattenValidationErrors } from "next-safe-action";

const schema = z.object({
  username: z.string().min(1, { message: "请输入用户名" }),
  password: z.string().min(1, { message: "请输入密码" }),
});

export const signIn = authActionClient
  .schema(schema, {
    handleValidationErrorsShape: (ve) =>
      flattenValidationErrors(ve).fieldErrors,
  })
  .action(async ({ parsedInput: { username, password }, ctx: { userId } }) => {
    if (username === "yayu" && password === "123") {
      return {
        success: true,
        message: "登录成功" + userId,
      };
    }

    return { success: false, message: "用户名密码错误" };
  });

我们从中间件返回的值,可以在 action 接收的函数的第二个参数中(上下文)获得。

手动修改 cookies,写入一个名为 session的 cookie,此时浏览器效果如下:

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

useAction hooks

next-safe-action 提供了 hooks 用于处理数据:useAction, useOptimisticActionuseStateAction

我们以 useAction 举个例子。修改 src/app/login/page.tsx,完整代码如下:

"use client";

import { useState } from "react";
import { signIn } from "./actions";
import { ServerActionResponse } from "./ServerActionResponse";
import { useAction } from "next-safe-action/hooks";

export default function Page() {
  const { execute, result } = useAction(signIn);
  return (
    <>
      <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
        <div className="sm:mx-auto sm:w-full sm:max-w-sm">
          <form
            className="space-y-6"
            onSubmit={async (e) => {
              e.preventDefault();
              const formData = new FormData(e.currentTarget);
              const input = Object.fromEntries(formData) as {
                username: string;
                password: string;
              };
              execute(input);
            }}
          >
            <div>
              <label
                htmlFor="username"
                className="block text-sm font-medium leading-6 text-gray-900"
              >
                Username
              </label>
              <div className="mt-2">
                <input
                  id="username"
                  name="username"
                  type="text"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                />
              </div>
            </div>

            <div>
              <div className="flex items-center justify-between">
                <label
                  htmlFor="password"
                  className="block text-sm font-medium leading-6 text-gray-900"
                >
                  Password
                </label>
              </div>
              <div className="mt-2">
                <input
                  id="password"
                  name="password"
                  type="password"
                  autoComplete="current-password"
                  className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-2"
                />
              </div>
            </div>

            <div>
              <button
                type="submit"
                className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
              >
                Sign in
              </button>
            </div>
          </form>
          <ServerActionResponse result={result} />
        </div>
      </div>
    </>
  );
}

浏览器效果不变。

使用 useAction 与不使用的差别就在于读取 action 的结果只能通过 useAction(signIn)返回的 result 中获取,而不能像之前通过 const result = await signIn(input)获取

useOptimisticAction hooks

新建 src/app/todo/actions.ts,代码如下:

"use server";

import { actionClient } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const schema = z.object({
  id: z.string().uuid(),
  body: z.string().min(1),
  completed: z.boolean(),
});

export type Todo = z.infer<typeof schema>;

let todos: Todo[] = [];

export const getTodos = async () => todos;

export const addTodo = actionClient
  .schema(schema)
  .action(async ({ parsedInput }) => {
    await new Promise((res) => setTimeout(res, 1500));

    todos.push(parsedInput);

    revalidatePath("/todo");

    return {
      createdTodo: parsedInput,
    };
  });

新建 src/app/todo/page.tsx,代码如下:

import { getTodos } from "./actions";
import TodosBox from "./todo";

export default async function Home() {
  return (
    <main>
      <TodosBox todos={await getTodos()} />
    </main>
  );
}

新建 src/app/todo/todo.tsx,代码如下:

"use client";

import { useOptimisticAction } from "next-safe-action/hooks";
import { addTodo, type Todo } from "./actions";

type Props = {
  todos: Todo[];
};

export default function TodosBox({ todos }: Props) {
  const { execute, result, optimisticState } = useOptimisticAction(addTodo, {
    currentState: { todos },
    updateFn: (state, newTodo) => {
      return {
        todos: [...state.todos, newTodo],
      };
    },
  });

  return (
    <div className="p-5">
      <button
        onClick={() => {
          execute({
            id: crypto.randomUUID(),
            body: "New Todo",
            completed: false,
          });
        }}
        className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
        >
        Add todo
      </button>
      <pre className="mt-2">
        Optimistic state: {JSON.stringify(optimisticState, null, 2)}
      </pre>
    </div>
  );
}

为了模拟接口数据延迟,我们在 Server Actions 中添加了一个 1500ms 的延时。浏览器效果如下:

Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe

当点击添加按钮后,列表立刻就有了数据,但其实接口还没有返回,这就是乐观更新。对结果保持乐观,因为大部分时候,接口数据都会正常返回,那就先添加数据,然后等接口返回。接口正确返回,什么也不用做,接口返回错误,再根据相应做处理。

总结

本篇我们介绍了 next-safe-actions 的基础用法,以及展示了中间件和 hooks 用法。next-safe-actions 算是优化 Server Actions 代码的常用库,可以视自己的项目选择使用。

当然 next-safe-actions 的功能远不止这些,具体查看 next-safe-actions 的官网: next-safe-action.dev/

转载自:https://juejin.cn/post/7405542470946652214
评论
请登录