Next.js 写 Server Actions 的利器 —— next-safe-action使用 next-safe
前言
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:
传统实现
新建 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: "登录成功",
};
}
浏览器效果如下:
这是一个 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 个可选属性:
data
:成功执行时返回的结果,也就是在 action 中返回的信息,包括自定义的成功和失败消息validationErrors
:输入数据未通过校验时返回的错误bindArgsValidationErrors
:绑定参数输入数据未通过校验时返回的错误,参考 bindArgsSchemasserverError
:服务端代码执行时出现的错误,为了防止敏感信息泄露,一般是 “Something went wrong while executing the operation.”,可在创建客户端的时候修改
为了方便展示,我们创建了一个 ServerActionResponse 组件用于展示。
浏览器效果如下:
中间件
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,此时浏览器效果如下:
useAction
hooks
next-safe-action 提供了 hooks 用于处理数据:useAction
, useOptimisticAction
和 useStateAction
。
我们以 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-safe-actions 的基础用法,以及展示了中间件和 hooks 用法。next-safe-actions 算是优化 Server Actions 代码的常用库,可以视自己的项目选择使用。
当然 next-safe-actions 的功能远不止这些,具体查看 next-safe-actions 的官网: next-safe-action.dev/
转载自:https://juejin.cn/post/7405542470946652214