Remix表单验证 #
一、验证概述 #
Remix 推荐在服务端进行表单验证,这样可以确保数据安全,同时支持无 JavaScript 的渐进增强。
二、基本验证 #
2.1 手动验证 #
tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
interface Errors {
title?: string;
content?: string;
email?: string;
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const email = formData.get("email") as string;
const errors: Errors = {};
if (!title || title.trim().length < 3) {
errors.title = "标题至少需要3个字符";
}
if (!content || content.trim().length < 10) {
errors.content = "内容至少需要10个字符";
}
if (!email || !email.includes("@")) {
errors.email = "请输入有效的邮箱地址";
}
if (Object.keys(errors).length > 0) {
return json({ errors, values: { title, content, email } }, { status: 400 });
}
await createPost({ title, content, email });
return redirect("/posts");
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<label htmlFor="title">标题</label>
<input
id="title"
name="title"
defaultValue={actionData?.values?.title}
aria-invalid={!!actionData?.errors?.title}
aria-describedby="title-error"
/>
{actionData?.errors?.title && (
<p id="title-error" className="error" role="alert">
{actionData.errors.title}
</p>
)}
</div>
<div>
<label htmlFor="content">内容</label>
<textarea
id="content"
name="content"
defaultValue={actionData?.values?.content}
aria-invalid={!!actionData?.errors?.content}
aria-describedby="content-error"
/>
{actionData?.errors?.content && (
<p id="content-error" className="error" role="alert">
{actionData.errors.content}
</p>
)}
</div>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
type="email"
defaultValue={actionData?.values?.email}
aria-invalid={!!actionData?.errors?.email}
aria-describedby="email-error"
/>
{actionData?.errors?.email && (
<p id="email-error" className="error" role="alert">
{actionData.errors.email}
</p>
)}
</div>
<button type="submit">提交</button>
</Form>
);
}
三、Zod验证 #
3.1 安装Zod #
bash
npm install zod
3.2 定义Schema #
tsx
import { z } from "zod";
const postSchema = z.object({
title: z.string()
.min(3, "标题至少需要3个字符")
.max(100, "标题不能超过100个字符"),
content: z.string()
.min(10, "内容至少需要10个字符"),
email: z.string()
.email("请输入有效的邮箱地址"),
tags: z.array(z.string()).optional(),
});
3.3 使用Schema验证 #
tsx
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = postSchema.safeParse(data);
if (!result.success) {
return json(
{
errors: result.error.flatten().fieldErrors,
values: data
},
{ status: 400 }
);
}
const post = await createPost(result.data);
return redirect(`/posts/${post.id}`);
}
3.4 显示错误 #
tsx
import { Form, useActionData } from "@remix-run/react";
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<label htmlFor="title">标题</label>
<input
id="title"
name="title"
defaultValue={actionData?.values?.title}
/>
{actionData?.errors?.title?.map((error, i) => (
<p key={i} className="error">{error}</p>
))}
</div>
<div>
<label htmlFor="content">内容</label>
<textarea
id="content"
name="content"
defaultValue={actionData?.values?.content}
/>
{actionData?.errors?.content?.map((error, i) => (
<p key={i} className="error">{error}</p>
))}
</div>
<button type="submit">提交</button>
</Form>
);
}
四、复杂验证 #
4.1 条件验证 #
tsx
const schema = z.object({
type: z.enum(["personal", "business"]),
name: z.string().min(1, "请输入姓名"),
company: z.string().optional(),
}).refine(
(data) => {
if (data.type === "business" && !data.company) {
return false;
}
return true;
},
{
message: "企业用户需要填写公司名称",
path: ["company"],
}
);
4.2 异步验证 #
tsx
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = postSchema.safeParse(data);
if (!result.success) {
return json({ errors: result.error.flatten().fieldErrors }, { status: 400 });
}
// 异步验证:检查唯一性
const existingPost = await getPostByTitle(result.data.title);
if (existingPost) {
return json(
{
errors: { title: ["该标题已存在"] },
values: data
},
{ status: 400 }
);
}
const post = await createPost(result.data);
return redirect(`/posts/${post.id}`);
}
4.3 自定义验证规则 #
tsx
import { z } from "zod";
const passwordSchema = z.string()
.min(8, "密码至少8个字符")
.regex(/[A-Z]/, "密码需要包含大写字母")
.regex(/[a-z]/, "密码需要包含小写字母")
.regex(/[0-9]/, "密码需要包含数字");
const registerSchema = z.object({
username: z.string()
.min(3, "用户名至少3个字符")
.regex(/^[a-zA-Z0-9_]+$/, "用户名只能包含字母、数字和下划线"),
email: z.string().email("请输入有效的邮箱"),
password: passwordSchema,
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "两次密码输入不一致",
path: ["confirmPassword"],
}
);
五、验证工具函数 #
5.1 封装验证逻辑 #
tsx
import { z } from "zod";
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function parseFormData<T extends z.ZodTypeAny>(
request: Request,
schema: T
): Promise<
| { success: true; data: z.infer<T> }
| { success: false; errors: Record<string, string[]> }
> {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
return {
success: false,
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
};
}
export async function action({ request }: ActionFunctionArgs) {
const result = await parseFormData(request, postSchema);
if (!result.success) {
return json({ errors: result.errors }, { status: 400 });
}
const post = await createPost(result.data);
return redirect(`/posts/${post.id}`);
}
5.2 表单错误组件 #
tsx
interface FieldErrorProps {
errors?: string[];
}
export function FieldError({ errors }: FieldErrorProps) {
if (!errors || errors.length === 0) return null;
return (
<div className="text-red-500 text-sm mt-1">
{errors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
);
}
// 使用
<FieldError errors={actionData?.errors?.title} />
5.3 表单字段组件 #
tsx
interface FormFieldProps {
label: string;
name: string;
type?: string;
errors?: string[];
defaultValue?: string;
}
export function FormField({
label,
name,
type = "text",
errors,
defaultValue
}: FormFieldProps) {
const id = `field-${name}`;
const hasError = errors && errors.length > 0;
return (
<div className="mb-4">
<label
htmlFor={id}
className="block text-sm font-medium mb-1"
>
{label}
</label>
<input
id={id}
name={name}
type={type}
defaultValue={defaultValue}
aria-invalid={hasError}
aria-describedby={hasError ? `${id}-error` : undefined}
className={`w-full px-3 py-2 border rounded ${
hasError ? "border-red-500" : "border-gray-300"
}`}
/>
{hasError && (
<div id={`${id}-error`} className="text-red-500 text-sm mt-1">
{errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
</div>
);
}
六、表单级错误 #
6.1 显示表单级错误 #
tsx
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = postSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
const formErrors = result.error.flatten().formErrors;
const fieldErrors = result.error.flatten().fieldErrors;
return json(
{ formErrors, fieldErrors },
{ status: 400 }
);
}
// ...
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
{actionData?.formErrors?.map((error, i) => (
<div key={i} className="error-banner">
{error}
</div>
))}
{/* 表单字段 */}
</Form>
);
}
七、实时验证 #
7.1 客户端预验证 #
tsx
import { z } from "zod";
import { useState } from "react";
export default function Form() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
const validate = (data: Record<string, string>) => {
const result = postSchema.safeParse(data);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors as Record<string, string[]>);
return false;
}
setErrors({});
return true;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const form = e.currentTarget;
const formData = new FormData(form);
const data = Object.fromEntries(formData);
if (!validate(data as Record<string, string>)) {
e.preventDefault();
}
};
return (
<Form method="post" onSubmit={handleSubmit}>
{/* 表单字段 */}
</Form>
);
}
八、最佳实践 #
8.1 错误消息友好 #
tsx
const schema = z.object({
email: z.string().email("请输入有效的邮箱地址,例如:example@email.com"),
password: z.string()
.min(8, "密码太短,请至少输入8个字符")
.regex(/[A-Z]/, "请添加至少一个大写字母"),
});
8.2 保留用户输入 #
tsx
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (!result.success) {
return json(
{
errors: result.error.flatten().fieldErrors,
values: data, // 保留用户输入
},
{ status: 400 }
);
}
// ...
}
8.3 焦点管理 #
tsx
import { useEffect, useRef } from "react";
export default function Form() {
const firstErrorRef = useRef<HTMLInputElement>(null);
const actionData = useActionData<typeof action>();
useEffect(() => {
if (actionData?.errors && firstErrorRef.current) {
firstErrorRef.current.focus();
}
}, [actionData]);
return (
<Form method="post">
<input
ref={actionData?.errors?.title ? firstErrorRef : null}
name="title"
/>
{/* 其他字段 */}
</Form>
);
}
九、总结 #
本章我们学习了:
- 基本验证:手动验证表单数据
- Zod验证:使用Schema定义验证规则
- 复杂验证:条件验证和异步验证
- 工具函数:封装验证逻辑
- 用户体验:错误显示和焦点管理
核心要点:
- 在服务端进行验证确保数据安全
- 使用Zod简化验证逻辑
- 提供清晰的错误消息
- 保留用户输入数据
最后更新:2026-03-28