Skip to content
第 153 / 250 章前端⏱ 10 分钟阅读

第 153 章:表单与受控组件

学习目标

  • 掌握受控组件与非受控组件
  • 学会表单验证
  • 理解 React Hook Form 优势
  • 处理复杂表单场景

一、受控组件

表单值由 React state 控制。

tsx
'use client';
import { useState } from 'react';

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    console.log({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        required
      />
      <button type="submit">登录</button>
    </form>
  );
}

二、统一处理多个字段

2.1 用对象

tsx
interface FormData {
  name: string;
  email: string;
  age: number;
}

function Form() {
  const [form, setForm] = useState<FormData>({
    name: '',
    email: '',
    age: 18,
  });

  function update<K extends keyof FormData>(field: K, value: FormData[K]) {
    setForm(f => ({ ...f, [field]: value }));
  }

  return (
    <form>
      <input
        value={form.name}
        onChange={e => update('name', e.target.value)}
      />
      <input
        type="email"
        value={form.email}
        onChange={e => update('email', e.target.value)}
      />
      <input
        type="number"
        value={form.age}
        onChange={e => update('age', Number(e.target.value))}
      />
    </form>
  );
}

三、验证

3.1 简单验证

tsx
function Form() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  function validate() {
    if (!email) return '邮箱必填';
    if (!/^.+@.+\..+$/.test(email)) return '邮箱格式错';
    return '';
  }

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const err = validate();
    if (err) {
      setError(err);
      return;
    }
    setError('');
    // 提交...
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => {
          setEmail(e.target.value);
          if (error) setError('');   // 输入时清错
        }}
      />
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit">提交</button>
    </form>
  );
}

3.2 字段级错误

tsx
interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
}

function useFormValidation<T extends Record<string, any>>(
  initial: T,
  validate: (values: T) => Record<keyof T, string>
) {
  const [values, setValues] = useState<T>(initial);
  const [errors, setErrors] = useState<Record<string, string>>({});

  function update<K extends keyof T>(field: K, value: T[K]) {
    setValues(v => ({ ...v, [field]: value }));
  }

  function handleSubmit(onValid: (v: T) => void) {
    return (e: FormEvent) => {
      e.preventDefault();
      const errs = validate(values);
      setErrors(errs);
      if (Object.keys(errs).length === 0) onValid(values);
    };
  }

  return { values, errors, update, handleSubmit };
}

四、非受控组件(ref)

只在需要时取值,性能更好。

tsx
import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef<HTMLInputElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const name = nameRef.current?.value;
    const file = fileRef.current?.files?.[0];
    console.log({ name, file });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} type="text" />
      <input ref={fileRef} type="file" />
      <button type="submit">提交</button>
    </form>
  );
}

五、React Hook Form(推荐)

bash
pnpm add react-hook-form
tsx
'use client';
import { useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  password: string;
}

function Signup() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    defaultValues: { name: '', email: '', password: '' },
  });

  async function onSubmit(data: FormData) {
    await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) });
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('name', { required: '姓名必填', minLength: 2 })}
      />
      {errors.name && <span>{errors.name.message}</span>}

      <input
        type="email"
        {...register('email', {
          required: '邮箱必填',
          pattern: { value: /^.+@.+\..+$/, message: '格式错' },
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      <input
        type="password"
        {...register('password', {
          required: '密码必填',
          minLength: { value: 6, message: '至少 6 位' },
        })}
      />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '注册'}
      </button>
    </form>
  );
}

5.1 优势

六、Zod 集成校验

bash
pnpm add zod @hookform/resolvers
tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2, '至少 2 个字'),
  email: z.string().email('邮箱格式错'),
  age: z.number().min(0).max(150),
});

type FormData = z.infer<typeof schema>;

function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(d => console.log(d))}>
      <input {...register('name')} />
      {errors.name?.message && <span>{errors.name.message}</span>}
      <input {...register('email')} />
      {errors.email?.message && <span>{errors.email.message}</span>}
      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age?.message && <span>{errors.age.message}</span>}
    </form>
  );
}

七、本章小结

概念关键
受控组件value + onChange
非受控组件ref + defaultValue
验证同步或异步
React Hook Form推荐,无重渲染
Zodschema 校验,类型推断

动手练习

  1. 写一个受控登录表单
  2. 写一个字段级错误提示
  3. 用 React Hook Form 重构上面的表单
  4. 用 Zod 实现注册表单校验

推荐阅读


下一章:第 154 章:React Router(Vite 版)

本站基于 VitePress 构建 · 由 Codebook 团队维护