react动态表单

来个例子

比如有两种登录方式

// 导入表单验证库
import * as z from 'zod';
// 导入消息提示组件
import { toast } from 'sonner';
import { useState } from 'react';
// 导入 UI 组件
import { Input } from '@repo/shadcn-comps/input';
import { Button } from '@repo/shadcn-comps/button';
import { Switch } from '@repo/shadcn-comps/switch';
// 导入表单处理相关
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { Field, FieldError, FieldGroup, FieldLabel } from '@repo/shadcn-comps/field';

// 使用 discriminatedUnion 定义不同登录方式的验证规则
const loginSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('account'),
    account: z.string().min(5, '账号至少需要5个字符').max(32, '账号最多32个字符'),
    password: z.string().min(6, '密码至少需要6个字符').max(30, '密码最多30个字符'),
  }),
  z.object({
    type: z.literal('email'),
    email: z.string().email('请输入有效的邮箱地址'),
    code: z.string().length(6, '验证码必须为6位'),
  }),
]);

type LoginFormData = z.infer<typeof loginSchema>;

/**
 * 登录表单组件
 * 支持账号密码登录和邮箱验证码登录两种方式
 */
export default function LoginForm() {
  const [loginType, setLoginType] = useState<'account' | 'email'>('account');

  // 初始化表单,配置验证器和默认值
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      type: 'account',
      account: '',
      password: '',
    },
  });

  // 切换登录方式时重置表单
  const handleLoginTypeChange = () => {
    const newType = loginType === 'account' ? 'email' : 'account';
    setLoginType(newType);

    // 重置表单并设置新的默认值
    if (newType === 'account') {
      form.reset({
        type: 'account',
        account: '',
        password: '',
      } );
    } else {
      form.reset({
        type: 'email',
        email: '',
        code: '',
      } );
    }
  };

  // 表单提交处理函数
  function onSubmit(data: LoginFormData) {
    toast.info(JSON.stringify(data, null, 2));
  }

  return (
    <div className="p-2 w-100">
      <div>
        <Switch checked={loginType === 'email'} onCheckedChange={handleLoginTypeChange} />
      </div>
      {/* 表单主体 */}
      <form id="login-form" onSubmit={form.handleSubmit(onSubmit)}>
        {loginType === 'email' ? (
          <FieldGroup>
            {/* 邮箱字段 */}
            <Controller
              name="email"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="login-email">邮箱</FieldLabel>
                  <Input {...field} id="login-email" aria-invalid={fieldState.invalid} placeholder="请输入邮箱" />
                  {/* 显示验证错误信息 */}
                  {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                </Field>
              )}
            />

            {/* 验证码字段 */}
            <Controller
              name="code"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="login-code">验证码</FieldLabel>
                  <Input {...field} id="login-code" aria-invalid={fieldState.invalid} placeholder="请输入6位验证码" />
                  {/* 显示验证错误信息 */}
                  {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                </Field>
              )}
            />
          </FieldGroup>
        ) : (
          <FieldGroup>
            {/* 账号字段 */}
            <Controller
              name="account"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="login-account">账号</FieldLabel>
                  <Input {...field} id="login-account" aria-invalid={fieldState.invalid} placeholder="请输入账号" />
                  {/* 显示验证错误信息 */}
                  {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                </Field>
              )}
            />

            {/* 密码字段 */}
            <Controller
              name="password"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="login-password">密码</FieldLabel>
                  <Input
                    {...field}
                    id="login-password"
                    type="password"
                    aria-invalid={fieldState.invalid}
                    placeholder="请输入密码"
                  />
                  {/* 显示验证错误信息 */}
                  {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                </Field>
              )}
            />
          </FieldGroup>
        )}
      </form>

      {/* 操作按钮组 */}
      <Field orientation="horizontal" className="mt-2">
        <Button type="button" variant="outline" onClick={() => form.reset()}>
          重置
        </Button>
        <Button type="submit" form="login-form">
          提交
        </Button>
      </Field>
    </div>
  );
}

假设说无论哪种登录方式,我们都有一个字段 叫做登录感言,字符串类型 20 个字以内,我们可以这样使用 Zod 的 .and() 方法来合并公共字段和特定字段:

// 导入表单验证库
import * as z from 'zod';
// 导入消息提示组件
import { toast } from 'sonner';
import { useState } from 'react';
// 导入 UI 组件
import { Input } from '@repo/shadcn-comps/input';
import { Button } from '@repo/shadcn-comps/button';
import { Switch } from '@repo/shadcn-comps/switch';
// 导入表单处理相关
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { Field, FieldError, FieldGroup, FieldLabel } from '@repo/shadcn-comps/field';

// 定义公共字段
const commonFields = z.object({
  loginMessage: z.string().max(20, '登录感言最多20个字').optional(),
});

// 使用 discriminatedUnion 定义不同登录方式的验证规则,并与公共字段合并
const loginSchema = commonFields.and(
  z.discriminatedUnion('type', [
    z.object({
      type: z.literal('account'),
      account: z.string().min(5, '账号至少需要5个字符').max(32, '账号最多32个字符'),
      password: z.string().min(6, '密码至少需要6个字符').max(30, '密码最多30个字符'),
    }),
    z.object({
      type: z.literal('email'),
      email: z.string().email('请输入有效的邮箱地址'),
      code: z.string().length(6, '验证码必须为6位'),
    }),
  ])
);

type LoginFormData = z.infer<typeof loginSchema>;

/**
 * 登录表单组件
 * 支持账号密码登录和邮箱验证码登录两种方式
 */
export default function LoginForm() {
  const [loginType, setLoginType] = useState<'account' | 'email'>('account');

  // 初始化表单,配置验证器和默认值
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      type: 'account',
      account: '',
      password: '',
      loginMessage: '',
    } 
  });

  // 切换登录方式时重置表单
  const handleLoginTypeChange = () => {
    const newType = loginType === 'account' ? 'email' : 'account';
    setLoginType(newType);

    // 重置表单并设置新的默认值
    if (newType === 'account') {
      form.reset({
        type: 'account',
        account: '',
        password: '',
        loginMessage: '',
      } );
    } else {
      form.reset({
        type: 'email',
        email: '',
        code: '',
        loginMessage: '',
      } );
    }
  };

  // 表单提交处理函数
  function onSubmit(data: LoginFormData) {
    toast.info(JSON.stringify(data, null, 2));
  }

  return (
    <div className="p-2 w-100">
      <div>
        <Switch checked={loginType === 'email'} onCheckedChange={handleLoginTypeChange} />
      </div>
      {/* 表单主体 */}
      <form id="login-form" onSubmit={form.handleSubmit(onSubmit)}>
        <FieldGroup>
          {loginType === 'email' ? (
            <>
              {/* 邮箱字段 */}
              <Controller
                name="email"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="login-email">邮箱</FieldLabel>
                    <Input {...field} id="login-email" aria-invalid={fieldState.invalid} placeholder="请输入邮箱" />
                    {/* 显示验证错误信息 */}
                    {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                  </Field>
                )}
              />

              {/* 验证码字段 */}
              <Controller
                name="code"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="login-code">验证码</FieldLabel>
                    <Input {...field} id="login-code" aria-invalid={fieldState.invalid} placeholder="请输入6位验证码" />
                    {/* 显示验证错误信息 */}
                    {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                  </Field>
                )}
              />
            </>
          ) : (
            <>
              {/* 账号字段 */}
              <Controller
                name="account"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="login-account">账号</FieldLabel>
                    <Input {...field} id="login-account" aria-invalid={fieldState.invalid} placeholder="请输入账号" />
                    {/* 显示验证错误信息 */}
                    {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                  </Field>
                )}
              />

              {/* 密码字段 */}
              <Controller
                name="password"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="login-password">密码</FieldLabel>
                    <Input
                      {...field}
                      id="login-password"
                      type="password"
                      aria-invalid={fieldState.invalid}
                      placeholder="请输入密码"
                    />
                    {/* 显示验证错误信息 */}
                    {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
                  </Field>
                )}
              />
            </>
          )}

          {/* 公共字段:登录感言 */}
          <Controller
            name="loginMessage"
            control={form.control}
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid}>
                <FieldLabel htmlFor="login-message">登录感言(选填)</FieldLabel>
                <Input {...field} id="login-message" aria-invalid={fieldState.invalid} placeholder="最多20个字" />
                {/* 显示验证错误信息 */}
                {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
              </Field>
            )}
          />
        </FieldGroup>
      </form>

      {/* 操作按钮组 */}
      <Field orientation="horizontal" className="mt-2">
        <Button type="button" variant="outline" onClick={() => form.reset()}>
          重置
        </Button>
        <Button type="submit" form="login-form">
          提交
        </Button>
      </Field>
    </div>
  );
}

总结

Zod条件验证

不拆分组件但又想分离验证规则,有几个优雅的解决方案:

方案 1:使用 Zod 的条件验证(最优雅)

// 根据登录类型动态生成 schema
const getLoginSchema = (loginType: 'account' | 'email') => {
  if (loginType === 'account') {
    return z.object({
      account: z.string().min(5, '账号至少5个字符').max(32, '账号最多32个字符'),
      password: z.string().min(6, '密码至少6个字符').max(30, '密码最多30个字符'),
    });
  } else {
    return z.object({
      email: z.string().email('请输入有效的邮箱地址'),
      code: z.string().length(6, '验证码为6位数字'),
    });
  }
};

// 在组件中使用
const [loginType, setLoginType] = useState<'account' | 'email'>('account');
const schema = getLoginSchema(loginType);

const form = useForm({
  resolver: zodResolver(schema),
  defaultValues: loginType === 'account' 
    ? { account: '', password: '' }
    : { email: '', code: '' },
});

方案 2:使用 Zod 的 discriminatedUnion(类型安全)

const loginSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('account'),
    account: z.string().min(5, '账号至少5个字符'),
    password: z.string().min(6, '密码至少6个字符'),
  }),
  z.object({
    type: z.literal('email'),
    email: z.string().email('请输入有效的邮箱'),
    code: z.string().length(6, '验证码为6位'),
  }),
]);
posted @ 2025-11-14 20:23  丁少华  阅读(8)  评论(0)    收藏  举报