原文:https://www.robinwieruch.de/react-form-validation/
作者:Robin Wieruch
译者:ChatGPT 4 Turbo
编者注:这篇文章探讨了在 React 中处理表单验证的简单方法,作者认为很多时候我们并不需要引入额外的表单库。文章主要介绍了两种验证方式:1) 服务端验证:使用 Zod 进行模式验证,通过 Server Action 处理表单提交,并优雅地处理错误状态。2) 客户端验证:可以使用 HTML 原生验证属性,或者在提交前用 JavaScript 进行验证。作者的核心观点是,在引入复杂的表单库之前,应该先尝试使用这些简单的解决方案,因为它们往往已经足够应付大多数场景。
当我写到明年的 React 技术栈时,许多开发者 [0, 1] 对我提出的"你可能不需要 React 表单库"这个建议感到困惑。
根据我的经验,开发者们经常在 React 中过度复杂化表单处理)。许多人在初始设置 React 时就立即安装了表单库。然而,我认为大多数表单在达到一定复杂度之前,都可以在 React 中有效地管理,而无需使用表单库。
让我们深入探讨这个问题。我们将从一个 React 表单和 React Server Action 的基础开始。首先是 React 表单组件:
"use client";
import { createInvoice } from "../actions/create-invoice";
const InvoiceCreateForm = () => {
return (
<form action={createInvoice}>
<label htmlFor="title">Title:</label>
<input type="text" name="title" id="name" />
<label htmlFor="amount">Amount:</label>
<input type="number" name="amount" id="amount" />
<label htmlFor="draft">Draft:</label>
<input type="checkbox" name="draft" id="draft" />
<label htmlFor="feature1">Feature 1:</label>
<input type="checkbox" name="features" value="feature1" id="feature1" />
<label htmlFor="feature2">Feature 2:</label>
<input type="checkbox" name="features" value="feature2" id="feature2" />
<button type="submit">Send</button>
</form>
);
};
export default InvoiceCreateForm;
接下来是处理表单请求及其 FormData 的 React Server Action:
"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
const createInvoiceSchema = zfd.formData({
title: zfd.text(z.string().min(3).max(191)),
amount: zfd.numeric(z.number().positive()),
draft: zfd.checkbox(),
features: zfd.repeatable(),
});
export const createInvoice = async (formData: FormData) => {
const { title, amount, draft, features } =
createInvoiceSchema.parse(formData);
console.log(title, amount, draft, features);
};
我们已经从表单中提取了表单数据,并在服务器端的 server action 中使用 schema 进行验证。如果 schema 验证失败,应用程序在当前状态下会崩溃。
继续阅读:了解更多关于 React 中的表单数据
让我们从服务器端验证开始,然后是可选的 React 客户端验证。
在 React 中进行服务器端表单验证
首先,我们需要在 schema 验证失败时在 server action 中处理解析错误。这样,应用程序就不会崩溃,而是会记录错误。
export const createInvoice = async (formData: FormData) => {
try {
const { title, amount, draft, features } =
createInvoiceSchema.parse(formData);
console.log(title, amount, draft, features);
// TODO: create invoice
} catch (error) {
console.error(error);
}
console.log("Success");
};
当对 schema 使用 parse
时,验证失败会抛出错误。作为替代方案,如果你不想使用 try-catch 语句,你也可以使用 safeParse
,它会返回一个带有可选错误属性的对象。
server action 中的错误处理需要区分不同类型的错误。例如,当 schema 验证失败时,它应该为每个验证失败的字段返回一个字段错误。如果错误不是 schema 验证错误,它应该返回一个通用错误消息:
export const createInvoice = async (formData: FormData) => {
try {
const { title, amount, draft, features } =
createInvoiceSchema.parse(formData);
console.log(title, amount, draft, features);
// TODO: create invoice
} catch (error) {
if (error instanceof ZodError) {
return {
message: "",
fieldErrors: error.flatten().fieldErrors,
};
} else if (error instanceof Error) {
return {
message: error.message,
fieldErrors: {},
};
} else {
return {
message: "An unknown error occurred",
fieldErrors: {},
};
}
}
return {
message: "Invoice created",
fieldErrors: {},
};
};
无论我们走成功还是失败的路径,我们都会返回一个包含 message 和 fieldErrors 属性的对象。
message
属性是给用户的通用消息(可以显示为 toast 消息),而 fieldErrors
属性是表单字段名称及其各自错误消息的字典(可以显示在表单字段旁边)。
因为这个(错误)处理应该在 server actions 之间共享,我们可以将其提取到一个带有更多实用类型(这里是:ActionState
)和常量(这里是:EMPTY_ACTION_STATE
)的辅助函数中:
import { ZodError } from "zod";
export type ActionState = {
message: string;
fieldErrors: Record<string, string[] | undefined>;
};
export const EMPTY_ACTION_STATE: ActionState = {
message: "",
fieldErrors: {},
};
export const fromErrorToActionState = (error: unknown): ActionState => {
if (error instanceof ZodError) {
return {
message: "",
fieldErrors: error.flatten().fieldErrors,
};
} else if (error instanceof Error) {
return {
message: error.message,
fieldErrors: {},
};
} else {
return {
message: "An unknown error occurred",
fieldErrors: {},
};
}
};
export const toActionState = (message: string): ActionState => ({
message,
fieldErrors: {},
});
现在我们可以在 server action(s) 中重用这些新的实用函数来返回错误或成功状态。这里我们只在一个 server action 中重用它,但是你可以在所有的 server actions 中使用它来实现一致的错误处理:
export const createInvoice = async (formData: FormData) => {
try {
const { title, amount, draft, features } =
createInvoiceSchema.parse(formData);
console.log(title, amount, draft, features);
// TODO: create invoice
} catch (error) {
return fromErrorToActionState(error);
}
return toActionState("Invoice created");
};
接下来,我们想在表单组件中获取返回的成功或错误状态。因此,我们使用 React 的 useActionState Hook,它需要 server action 和一个初始状态,并返回增强的 server action 和来自 server action 的状态:
import { useActionState } from "react";
const InvoiceCreateForm = () => {
const [actionState, formAction] = useActionState(
createInvoice,
EMPTY_ACTION_STATE
);
return (
<form action={formAction}>
...
<button type="submit">Send</button>
{actionState.message}
</form>
);
};
我们已经可以在表单下方显示通用的成功或错误消息。这是最简单的解决方案,但你可能想稍后用 toast 消息显示它。
因为我们通过使用 React 的 useActionState Hook 改变了 server action 在表单中的使用,所以我们需要用新的函数名调整 server action:
export const createInvoice = async (
_actionState: ActionState,
formData: FormData
) => {
try {
...
} catch (error) {
return fromErrorToActionState(error);
}
return toActionState("Invoice created");
};
最后,我们可以在表单组件中使用 server action 状态返回的 fieldErrors
来在表单字段旁边显示错误消息。由于字段错误的字典是一个嵌套对象,我们需要使用字段名称作为键来访问错误消息。然后我们只显示每个字段的第一个错误消息:
return (
<form action={formAction}>
<label htmlFor="title">Title:</label>
<input type="text" name="title" id="name" />
<span>{actionState.fieldErrors.title?.[0]}</span>
<label htmlFor="amount">Amount:</label>
<input type="number" name="amount" id="amount" />
<span>{actionState.fieldErrors.amount?.[0]}</span>
<label htmlFor="draft">Draft:</label>
<input type="checkbox" name="draft" id="draft" />
<span>{actionState.fieldErrors.draft?.[0]}</span>
<label htmlFor="feature1">Feature 1:</label>
<input type="checkbox" name="features" value="feature1" id="feature1" />
<label htmlFor="feature2">Feature 2:</label>
<input type="checkbox" name="features" value="feature2" id="feature2" />
<span>{actionState.fieldErrors.features?.[0]}</span>
<button type="submit">Send</button>
{actionState.message}
</form>
);
继续阅读:如何(不)在 Server Action 后重置 React 表单
在这里,我们可以提取一个 FieldError 组件以在每个表单字段中重用它。
import { ActionState } from "./helper";
type FieldErrorProps = {
actionState: ActionState;
name: string;
};
const FieldError = ({ actionState, name }: FieldErrorProps) => {
const message = actionState.fieldErrors[name]?.[0];
if (!message) return null;
return <span className="text-xs text-red-500">{message}</span>;
};
export { FieldError };
并在表单组件中使用它,使表单更简洁,API 更简单:
return (
<form action={formAction}>
<label htmlFor="title">Title:</label>
<input type="text" name="title" id="name" />
<FieldError actionState={actionState} name="title" />
<label htmlFor="amount">Amount:</label>
<input type="number" name="amount" id="amount" />
<FieldError actionState={actionState} name="amount" />
<label htmlFor="draft">Draft:</label>
<input type="checkbox" name="draft" id="draft" />
<FieldError actionState={actionState} name="draft" />
<label htmlFor="feature1">Feature 1:</label>
<input type="checkbox" name="features" value="feature1" id="feature1" />
<label htmlFor="feature2">Feature 2:</label>
<input type="checkbox" name="features" value="feature2" id="feature2" />
<FieldError actionState={actionState} name="features" />
<button type="submit">Send</button>
{actionState.message}
</form>
);
从这里开始,你可以选择安装你喜欢的 UI 库,并用你的 UI 库组件替换 Label、Input 和 Button 组件。
本质上,你已经在没有表单库的情况下在 React 中构建了一个带有服务器端验证的表单。如果你的服务器负载不大,并且你不介意用户进行服务器验证往返,这是服务器驱动的 React 应用程序中表单验证的最小设置。
你可以通过客户端验证来扩展它,以改善用户体验并减少服务器负载,我们接下来会介绍这一点。
在 React 中进行客户端表单验证
我们将为表单组件添加客户端验证。最简单的形式是,我们可以使用原生 HTML 验证属性,如 required
、min
、max
、pattern
和 maxLength
:
<label htmlFor="title">Title:</label>
<input type="text" name="title" id="name" required maxLength={10} />
<FieldError actionState={actionState} name="title" />
<label htmlFor="amount">Amount:</label>
<input
type="number"
name="amount"
id="amount"
required
min={0}
max={999}
/>
不幸的是,原生 HTML 验证对自定义功能的控制不多。如果你需要通过客户端 JavaScript 进行更多控制,请移除原生 HTML 验证。
现在我们将从服务器端验证中提取 schema 到一个单独的文件中,远离 server action,因为它不能从那里导出。这样我们以后可以重用这个 schema 进行客户端验证:
import { z } from "zod";
import { zfd } from "zod-form-data";
export const createInvoiceSchema = zfd.formData({
title: zfd.text(z.string().min(3).max(191)),
amount: zfd.numeric(z.number().positive()),
draft: zfd.checkbox(),
features: zfd.repeatable(),
});
接下来,我们将在表单组件上引入一个事件处理程序,在将表单数据发送到服务器操作之前在客户端使用 schema 验证表单数据:
<form action={formAction} onSubmit={handleSubmit}>
...
</form>
在事件处理程序中,从表单元素中提取表单数据并使用 schema 验证它。如果验证失败,阻止表单提交。否则,将表单数据发送到服务器操作。这里同样,如果你不想使用 try-catch 语句,你可以使用 safeParse
而不是 parse
来获取一个带有可选错误属性的对象:
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget);
try {
createInvoiceSchema.parse(formData);
} catch (error) {
event.preventDefault();
}
};
为了在表单字段旁边显示客户端验证错误,我们需要用验证错误来通知表单组件。因此,我们使用 React 的 useState Hook 引入本地组件状态。不要忘记在每次表单提交前重置验证状态:
const [validation, setValidation] = useState<ActionState | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget);
setValidation(null);
try {
createInvoiceSchema.parse(formData);
} catch (error) {
setValidation(fromErrorToActionState(error));
event.preventDefault();
}
};
现在我们可以在表单字段旁边显示客户端验证错误(如果有的话)。否则,我们检查服务器端验证错误:
<label htmlFor="title">Title:</label>
<input type="text" name="title" id="name" />
<FieldError actionState={validation ?? actionState} name="title" />
<label htmlFor="amount">Amount:</label>
<input type="number" name="amount" id="amount" />
<FieldError actionState={validation ?? actionState} name="amount" />
...
对于可以作为 toast 反馈显示或显示在表单下方的通用消息,我们也会首先检查客户端验证错误:
<button type="submit">Send</button>
{validation ? validation.message : actionState.message}
这就是不使用表单库进行客户端表单验证的基本内容。你可以通过更复杂的验证规则、自定义错误消息和更复杂的错误处理来扩展它。但对于大多数表单来说,这已经是一个很好的起点。有了这个基础,你也可以将提交时的表单验证替换为更改时或失焦时的表单验证。
最后,我并不是说你不应该使用表单库进行客户端(或服务器端)表单验证,我只是想指出反对那些在许多 React 应用程序中不需要的过早抽象。从简单开始,并始终重新评估你的 React 应用程序是否需要表单库。