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>
  );
}

九、总结 #

本章我们学习了:

  1. 基本验证:手动验证表单数据
  2. Zod验证:使用Schema定义验证规则
  3. 复杂验证:条件验证和异步验证
  4. 工具函数:封装验证逻辑
  5. 用户体验:错误显示和焦点管理

核心要点:

  • 在服务端进行验证确保数据安全
  • 使用Zod简化验证逻辑
  • 提供清晰的错误消息
  • 保留用户输入数据
最后更新:2026-03-28