本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
本篇我们使用 Next.js + TypeScript + Tailwind CSS + Shadcn UI + React Hook Form + Zod + Server Actions + useOptimistic 实现一个简单又复杂的 TodoList。
效果如下:
简单的地方在于效果看起来简单:当用户输入内容后,点击提交按钮或者按回车键即可提交表单数据,首先会进行数据校验,当数据校验通过后,提交按钮置灰表示不可操作,然后列表进行乐观更新,添加成功后,列表刷新,提交按钮恢复可用状态,表单重置,同时会有 Toast 提示。
复杂的地方在于实现的细节并不少,且为了日后应对更复杂的表单处理,提高可维护性,背后涉及的技术选型众多,需要相互协同,共同为开发者提供一个良好的开发体验
所使用的这些基本都是主流的技术选型,相信以后会是 Next.js 项目涉及表单处理的常态选择。其中:
- Next.js:React 首推的生产框架,支持 React 的最新特性
- TypeScript:如大家所见,现在越来越多的项目使用 TypeScript
- Tailwind CSS:目前 Tailwind CSS 在 GitHub 有 80k Stars、Npm 周下载量 733W,已经成为前端主流的 CSS 框架
- Shadcn UI:React UI 库,2023 年 JavaScript 领域 GitHub Stars 增长最多的开源项目。2023 年 1 月创建的项目,一年便涨了 39.5K Star,足以看出其火爆程度
- React Hook Form::老牌的用于 React 应用程序的表单验证和状态管理库。它提供了一组钩子,可以轻松创建和管理表单,而无需编写大量样板代码。GitHub 40.1k Star
- Zod::数据校验库,目前 Zod GitHub 31.5k Star,Npm 周均下载量 784W,几乎是前端做数据校验的第一选择。Next.js 官方文档里用的也正是 Zod
- Server Actions:Next.js 13 提出的新特性,对于 Next.js 项目,除非要开发的 API 用于三方调用,否则都可以使用 Server Actions 替代,Next.js 项目必学必用知识
- useOptimistic:React 18 新 hook,用于实现乐观更新
那就让我们开始吧!
本篇已收录到掘金专栏《Next.js 开发指北》
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。
1. 初始化项目
为了方便演示,创建一个空的 Next.js 项目:
npx create-next-app@latest
注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:
安装 Shadcn UI 并进行初始化:
npx shadcn-ui@latest init
这里怎么选都行:
添加用到的 Shadcn UI 组件:
npx shadcn-ui@latest add form button input toast
Shadcn UI 会将组件代码写入 src/components/ui
中。
运行:
npm i zod zod-i18n-map i18next
其中 zod-i18n-map
、i18next
是为了实现 Zod 的中文错误提示
运行:
npm i react-hook-form @hookform/resolvers
其中 @hookform/resolvers
是为了让 React Hook Form 使用 Zod Schema 作为校验器
2. 完整代码实现
这个例子涉及的代码逻辑比较简单,我直接给大家最终的代码,相信大家看到完整的代码即可很快领悟。
新建 src/app/todo/page.tsx
,代码如下:
import { findTodos } from "@/actions/todo";
import Todo from "./todo";
export default async function Page() {
const todos = await findTodos();
return <Todo todos={todos} />;
}
在这里代码中,因为使用 useOptimistic 和 React Hook Form 需要在客户端组件中,所以我们将主要的代码逻辑抽离到了 <Todo>
组件中,然后在服务端组件中请求列表数据,将请求到的列表数据传给 <Todo>
组件。
新建 src/lib/zod.ts
,代码如下:
import i18next from "i18next";
import { z } from "zod";
import { zodI18nMap } from "zod-i18n-map";
// Import your language translation files
import translation from "zod-i18n-map/locales/zh-CN/zod.json";
// lng and resources key depend on your locale.
i18next.init({
lng: "es",
resources: {
es: { zod: translation },
},
});
z.setErrorMap(zodI18nMap);
// export configured zod instance
export { z };
这是为了实现 Zod 的汉化,因为当出现一些开发者未定义的错误时,Zod 默认返回的是英文,现在将其改为中文。组件导出 z
,当我们定义 Zod Schema 的时候,需要使用该组件导出的 z
。
注:上图中的错误是通过手动修改提交给 Server Actions 的值来实现的,触发服务端的 Zod 校验后返回的错误信息,一般不会出现这种错误
新建 src/schema/todo.ts
,代码如下:
import { z } from "@/lib/zod";
export const createTodoZodSchema = z.object({
todo: z.string().min(1, { message: "请填写任务" }),
});
export type createTodoZodSchemaType = z.infer<typeof createTodoZodSchema>;
这是为了定义创建任务时的数据校验逻辑。之所以不只使用 React Hook Form 是因为 React Hook Form 的校验逻辑只能用在客户端,服务端不能相信客户端的数据,所以服务端也要做一遍校验,但前后端往往需要相同的校验逻辑。通过定义统一的 Schema,前后端可共享校验逻辑。
修改 src/app/layout.tsx
,代码如下:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
{children}
<Toaster />
</body>
</html>
);
}
修改 layout.tsx
主要是为了在布局中添加 <Toaster>
组件,这样组件可以使用 toast()
方法。
新建 src/app/todo/todo.tsx
,代码如下:
"use client";
import { useOptimistic, useRef, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { createTodo } from "@/actions/todo";
import {
createTodoZodSchema,
type createTodoZodSchemaType,
} from "@/schema/todo";
interface Props {
todos: string[];
}
type optimisticTodoItem = {
text: string;
sending?: boolean;
};
export default function Page({ todos }: Props) {
const [optimisticToDoList, addOptimisticTodo] = useOptimistic(
todos.map((i) => ({ text: i })),
(currentState: optimisticTodoItem[], { todo }: createTodoZodSchemaType) => {
return [
...currentState,
{
text: todo,
sending: true,
},
];
}
);
const [isPending, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(createTodoZodSchema),
defaultValues: {
todo: "",
},
});
const { handleSubmit, reset, control } = form;
const formRef = useRef<HTMLFormElement>(null);
const onSubmit = handleSubmit(async (data: createTodoZodSchemaType) => {
startTransition(async () => {
addOptimisticTodo(data);
const response = await createTodo(data);
if (response?.error) {
toast({
title: "哎呦,出错了",
description: response.error,
variant: "destructive",
});
} else {
toast({
title: "恭喜您",
description: "任务创建成功!",
});
reset();
}
});
});
return (
<div className="p-4">
<Form {...form}>
<form ref={formRef} onSubmit={onSubmit} className="space-y-8 p-4">
<FormField
control={control}
name="todo"
render={({ field }) => (
<FormItem>
<FormLabel>快速改变人生的五件事情是:</FormLabel>
<FormControl>
<Input placeholder="任务名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
添加任务
</Button>
</form>
</Form>
<ul className="list-decimal list-inside p-4">
{optimisticToDoList.map(({ text, sending }, i) => (
<li key={i}>
{text} {!!sending && <small> (添加中...)</small>}
</li>
))}
</ul>
</div>
);
}
这是最核心的代码逻辑。
我们首先通过服务端组件的列表数据,构建了一个乐观更新列表(optimisticToDoList),为了能够展示出“添加中...”的效果,原本的数据应该是 ["运动"]
,我们改成了 [{"text": "运动", "sending": true}]
结构。
然后是 React Hook Form 用法,通过 zodResolver(createTodoZodSchema)
使 React Hook Form 引入 Zod Schema 作为数据校验,从而实现 React Hook Form 搭配 Zod 一起使用。
当提交表单数据的时候,首先会进行数据校验,数据校验通过后,会调用 onSubmit
函数。而在 onSubmit 函数中,我们将所有的操作都放到了 React 的 transition 中。
通常我们看到的乐观更新例子中,并不需要用到 transition,这是因为官方示例中都是放到 form 的 action 中,默认内置了 transition。如果你要在其他场景调用乐观更新,React 会出报错提示:
所以我们才使用 startTransition
包裹了乐观更新和服务端请求处理的代码。但是因为 startTransition 并不返回 Promise,所以 React Hook Form 无法通过 formState.isSubmiting
判断表单的提交状态,所以我们改为使用 useTransition
返回的 isPending
来判断提交状态,提交按钮的 disabled
状态正是根据 isPending
来判断的。
新建 src/actions/todo.ts
,代码如下:
"use server";
import { revalidatePath } from "next/cache";
import {
createTodoZodSchema,
type createTodoZodSchemaType,
} from "@/schema/todo";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let data = ["阅读", "写作", "冥想"];
export async function findTodos() {
return data;
}
export async function createTodo(todoData: createTodoZodSchemaType) {
await sleep(1000);
const result = createTodoZodSchema.safeParse(todoData);
if (!result.success) {
const errorMessages = result.error.issues.reduce((prev, issue) => {
return (prev += issue.message);
}, "");
return {
error: errorMessages,
};
}
try {
const todo = todoData.todo;
data.push(todo);
} catch (error) {
return {
error: "服务端创建错误",
};
}
revalidatePath("/todo");
}
这是创建任务的服务端逻辑,如果服务端数据校验错误,返回校验的错误信息,如果数据库操作失败,返回服务端错误。当然这里我们为了避免引入数据库以及 ORM,使用数组操作模拟了查询数据库操作。当数据校验成功,使用 Next.js 的 revalidatePath
更新/todo
页面,页面数据得以刷新。
此时页面最终效果如下:
3. 其他实现方式
数据提交我们也可以改为使用 Form 的 action 属性来实现。修改 src/app/todo2/todo.tsx
,代码如下:
"use client";
import { useOptimistic, useRef, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { createTodo } from "@/actions/todo";
import {
createTodoZodSchema,
type createTodoZodSchemaType,
} from "@/schema/todo";
interface Props {
todos: string[];
}
type optimisticTodoItem = {
text: string;
sending?: boolean;
};
export default function Page({ todos }: Props) {
const [optimisticToDoList, addOptimisticTodo] = useOptimistic(
todos.map((i) => ({ text: i })),
(currentState: optimisticTodoItem[], { todo }: createTodoZodSchemaType) => {
return [
...currentState,
{
text: todo,
sending: true,
},
];
}
);
const form = useForm({
resolver: zodResolver(createTodoZodSchema),
defaultValues: {
todo: "",
},
});
const { trigger, reset, control } = form;
const handleFormAction = async (data: any) => {
const res = await trigger();
if (!res) return;
const newTask = {
todo: data.get("todo"),
};
addOptimisticTodo(newTask);
const response = await createTodo(newTask);
if (response?.error) {
toast({
title: "哎呦,出错了",
description: response.error,
variant: "destructive",
});
} else {
toast({
title: "恭喜您",
description: "任务创建成功!",
});
reset();
}
};
return (
<div className="p-4">
<Form {...form}>
<form action={handleFormAction} className="space-y-8 p-4">
<FormField
control={control}
name="todo"
render={({ field }) => (
<FormItem>
<FormLabel>快速改变人生的五件事情是:</FormLabel>
<FormControl>
<Input placeholder="任务名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton />
</form>
</Form>
<ul className="list-decimal list-inside p-4">
{optimisticToDoList.map(({ text, sending }, i) => (
<li key={i}>
{text} {!!sending && <small> (添加中...)</small>}
</li>
))}
</ul>
</div>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
添加任务
</Button>
);
}
在这个例子中,我们构建了一个 handleFormAction 异步函数用作表单提交时的处理函数,然后通过 useForm 返回的 trigger 函数手动触发验证,数据校验通过后,手动从 FormData 中获取表单数据,构建提交数据,触发乐观更新和 Server Actions。
与之前不同的是,之前我们通过 useTransition
返回的 isPending
来判断提交状态,现在没有使用 transition,我们改为使用了 React 18 的 hook —— useFormStatus 来判断提交状态。为了使用 useFormStatus,我们需要将提交按钮声明到一个单独的组件中。这样才能正确获取表单状态。
虽然实现略有不同,但效果都是一样的:
最后
本篇我们讲解了如何快速使用这些技术选型构建一个表单处理项目。从这个项目出发,可以学习这些技术选型的基本使用方式,以及如何搭配使用,为处理更为复杂的表单处理场景打下技术基础。
Next.js 系列
本篇已收录在掘金专栏 《Next.js 开发指北》,该系列一共 24 篇。
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
此外我还写过 JavaScript 系列、TypeScript 系列、React 系列、Next.js 系列、冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…
通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。