学习路径 中级

表单高级处理

深入学习 React 表单处理,包括受控组件、表单验证、以及 React 19 的 Actions

表单处理概述

表单是 Web 应用中最重要的交互元素之一。React 提供了多种处理表单的方式,从基础的受控组件到 React 19 的 Actions。本章将深入探讨:

  • 受控组件 vs 非受控组件
  • 表单状态管理
  • 表单验证和错误处理
  • React 19 的 Actions 和 useActionState
  • 表单性能优化
  • 复杂表单场景

受控组件

在 React 中,表单元素通常使用受控组件来处理。受控组件的值由 React 状态控制,并通过事件处理函数更新。

基础受控组件

TypeScript
import { useState } from 'react';

function ContactForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log({ name, email, message });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">姓名:</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>

      <div>
        <label htmlFor="email">邮箱:</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>

      <div>
        <label htmlFor="message">留言:</label>
        <textarea
          id="message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
        />
      </div>

      <button type="submit">提交</button>
    </form>
  );
}
为什么使用受控组件?
  • 即时验证: 可以在用户输入时验证数据
  • 条件禁用: 根据输入值动态启用/禁用按钮
  • 格式化输入: 自动格式化用户输入(如电话号码)
  • 单数据源: 表单状态由 React 管理,易于调试

使用单个状态对象

TypeScript
function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <form>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
      />
    </form>
  );
}

表单验证

表单验证是确保用户输入数据正确性的关键。我们可以实现实时验证和提交时验证。

实时验证

TypeScript
import { useState, useEffect } from 'react';

interface ValidationResult {
  isValid: boolean;
  errors: {
    name?: string;
    email?: string;
    message?: string;
  };
}

function validateForm(data: { name: string; email: string; message: string }): ValidationResult {
  const errors: ValidationResult['errors'] = {};

  try {
    if (!data.name.trim()) {
      errors.name = '姓名不能为空';
    } else if (data.name.length < 2) {
      errors.name = '姓名至少2个字符';
    }

    if (!data.email.trim()) {
      errors.email = '邮箱不能为空';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
      errors.email = '邮箱格式不正确';
    }

    if (!data.message.trim()) {
      errors.message = '留言不能为空';
    } else if (data.message.length < 10) {
      errors.message = '留言至少10个字符';
    }
  } catch (error) {
    // 验证函数不应该抛出异常,但为了健壮性,捕获并处理
    console.error('验证过程出错:', error);
    errors.name = '验证出错,请重试';
  }

  return {
    isValid: Object.keys(errors).length === 0,
    errors
  };
}

function ValidatedForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });

  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [validation, setValidation] = useState<ValidationResult>({
    isValid: false,
    errors: {}
  });

  // 实时验证(带防抖优化)
  useEffect(() => {
    const timer = setTimeout(() => {
      const result = validateForm(formData);
      setValidation(result);
    }, 300); // 300ms 防抖延迟

    return () => clearTimeout(timer);
  }, [formData]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    const { name } = e.target;
    setTouched(prev => ({
      ...prev,
      [name]: true
    }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // 标记所有字段为已触摸
    setTouched({
      name: true,
      email: true,
      message: true
    });

    if (validation.isValid) {
      console.log('提交数据:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="name">姓名:</label>
        <input
          id="name"
          name="name"
          type="text"
          value={formData.name}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.name && validation.errors.name ? 'border-red-500' : ''}
        />
        {touched.name && validation.errors.name && (
          <span className="text-red-500 text-sm">
            {validation.errors.name}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="email">邮箱:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.email && validation.errors.email ? 'border-red-500' : ''}
        />
        {touched.email && validation.errors.email && (
          <span className="text-red-500 text-sm">
            {validation.errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="message">留言:</label>
        <textarea
          id="message"
          name="message"
          value={formData.message}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.message && validation.errors.message ? 'border-red-500' : ''}
        />
        {touched.message && validation.errors.message && (
          <span className="text-red-500 text-sm">
            {validation.errors.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        disabled={!validation.isValid}
        className={!validation.isValid ? 'opacity-50 cursor-not-allowed' : ''}
      >
        提交
      </button>
    </form>
  );
}
用户体验最佳实践
  • 不要在用户开始输入前显示错误 - 使用 touched 状态
  • 提供明确的错误信息 - 告诉用户如何修复
  • 禁用提交按钮 - 直到表单有效
  • 显示加载状态 - 提交时禁用表单并显示 spinner
  • 使用防抖 - 避免频繁触发验证导致性能问题
错误处理的健壮性

表单验证的常见问题

  • 验证函数可能抛出异常:使用 try-catch 包裹验证逻辑
  • 频繁验证影响性能:使用防抖(debounce)延迟验证
  • 异步验证的竞态条件:使用 AbortController 取消未完成的验证
  • 边界情况处理:空值、null、undefined 等情况要妥善处理

性能建议:对于实时验证,至少使用 300ms 的防抖延迟。 如果验证涉及网络请求(如检查用户名是否存在),考虑使用更长的延迟(500ms)。

异步验证(带错误处理)

TypeScript
import { useState, useEffect } from 'react';

function UsernameForm() {
  const [username, setUsername] = useState('');
  const [validationStatus, setValidationStatus] = useState<{
    isValid: boolean;
    message: string;
    checking: boolean;
  }>({
    isValid: true,
    message: '',
    checking: false
  });

  useEffect(() => {
    // 防抖 + AbortController
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      if (username.length < 3) return;

      setValidationStatus(prev => ({ ...prev, checking: true }));

      try {
        const response = await fetch(`/api/check-username?username=${username}`, {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error('验证失败');
        }

        const data = await response.json();
        setValidationStatus({
          isValid: data.available,
          message: data.available ? '用户名可用' : '用户名已被占用',
          checking: false
        });
      } catch (error) {
        if (error.name !== 'AbortError') {
          setValidationStatus({
            isValid: false,
            message: '验证出错,请重试',
            checking: false
          });
        }
      }
    }, 500); // 500ms 防抖

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [username]);

  return (
    <div>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      {username.length >= 3 && (
        <div>
          {validationStatus.checking && <span>检查中...</span>}
          {!validationStatus.checking && validationStatus.message && (
            <span className={validationStatus.isValid ? 'text-green-500' : 'text-red-500'}>
              {validationStatus.message}
            </span>
          )}
        </div>
      )}
    </div>
  );
}

React 19 Actions

React 19 引入了 Actions,简化了表单提交和数据变更的处理。Actions 可以是函数,自动处理加载状态、错误和乐观更新。

基础 Action

TypeScript
// 定义 Action
async function updateName(formData: FormData) {
  const name = formData.get('name');
  await updateUser({ name });
  return { success: true };
}

// 在表单中使用
function NameForm() {
  return (
    <form action={updateName}>
      <input type="text" name="name" />
      <button type="submit">更新</button>
    </form>
  );
}

使用 useActionState

TypeScript
import { useActionState } from 'react';

interface FormState {
  success: boolean;
  error: string | null;
  data: null | { name: string };
}

async function submitForm(prevState: FormState, formData: FormData) {
  const name = formData.get('name') as string;

  if (!name || name.length < 2) {
    return {
      success: false,
      error: '姓名至少2个字符',
      data: null
    };
  }

  try {
    await updateUser({ name });
    return {
      success: true,
      error: null,
      data: { name }
    };
  } catch (error) {
    return {
      success: false,
      error: '更新失败,请重试',
      data: null
    };
  }
}

function UserForm() {
  const [state, formAction, isPending] = useActionState(submitForm, {
    success: false,
    error: null,
    data: null
  });

  return (
    <form action={formAction}>
      <input type="text" name="name" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? '提交中...' : '更新'}
      </button>

      {state.error && (
        <div className="text-red-500">{state.error}</div>
      )}

      {state.success && (
        <div className="text-green-500">更新成功!</div>
      )}
    </form>
  );
}

使用 useFormStatus

TypeScript
import { useFormStatus } from 'react';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function Form() {
  async function submitAction(formData: FormData) {
    await new Promise(resolve => setTimeout(resolve, 2000));
  }

  return (
    <form action={submitAction}>
      <input type="text" name="field" />
      <SubmitButton />
    </form>
  );
}
Actions 的优势
  • 自动管理 pending 状态
  • 自动处理错误
  • 支持渐进式增强(无 JS 也能工作)
  • 简化的代码,无需手动 useState

复杂表单场景

动态字段

TypeScript
function DynamicFieldsForm() {
  const [fields, setFields] = useState([
    { id: 1, value: '' }
  ]);

  const addField = () => {
    setFields([...fields, {
      id: Date.now(),
      value: ''
    }]);
  };

  const removeField = (id: number) => {
    setFields(fields.filter(field => field.id !== id));
  };

  const updateField = (id: number, value: string) => {
    setFields(fields.map(field =>
      field.id === id ? { ...field, value } : field
    ));
  };

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            value={field.value}
            onChange={(e) => updateField(field.id, e.target.value)}
            placeholder={`字段 ${index + 1}`}
          />
          {fields.length > 1 && (
            <button
              type="button"
              onClick={() => removeField(field.id)}
            >
              删除
            </button>
          )}
        </div>
      ))}

      <button
        type="button"
        onClick={addField}
      >
        添加字段
      </button>
    </form>
  );
}

嵌套数据结构

TypeScript
interface Address {
  street: string;
  city: string;
  zipCode: string;
}

interface User {
  name: string;
  email: string;
  address: Address;
}

function NestedForm() {
  const [user, setUser] = useState<User>({
    name: '',
    email: '',
    address: {
      street: '',
      city: '',
      zipCode: ''
    }
  });

  const updateAddress = (field: keyof Address, value: string) => {
    setUser(prev => ({
      ...prev,
      address: {
        ...prev.address,
        [field]: value
      }
    }));
  };

  return (
    <form>
      <input
        value={user.name}
        onChange={(e) => setUser({ ...user, name: e.target.value })}
        placeholder="姓名"
      />

      <input
        value={user.address.street}
        onChange={(e) => updateAddress('street', e.target.value)}
        placeholder="街道"
      />

      <input
        value={user.address.city}
        onChange={(e) => updateAddress('city', e.target.value)}
        placeholder="城市"
      />

      <input
        value={user.address.zipCode}
        onChange={(e) => updateAddress('zipCode', e.target.value)}
        placeholder="邮编"
      />
    </form>
  );
}

表单性能优化

防抖输入

TypeScript
import { useState, useEffect } from 'react';

function useDebounce(value: string, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function SearchForm() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      // 执行搜索
      performSearch(debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="搜索..."
    />
  );
}

使用 useCallback 缓存处理函数

TypeScript
import { useState, useCallback } from 'react';

function OptimizedForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  }, []);

  return (
    <form>
      <input
        name="username"
        value={formData.username}
        onChange={handleChange}
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
      />
      <input
        name="password"
        type="password"
        value={formData.password}
        onChange={handleChange}
      />
    </form>
  );
}

使用 useReducer 管理复杂表单

TypeScript
import { useReducer } from 'react';

type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'CLEAR_ERRORS' }
  | { type: 'RESET' };

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

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        values: {
          ...state.values,
          [action.field]: action.value
        }
      };

    case 'SET_ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: action.error
        }
      };

    case 'CLEAR_ERRORS':
      return {
        ...state,
        errors: {}
      };

    case 'RESET':
      return {
        values: {},
        errors: {},
        touched: {}
      };

    default:
      return state;
  }
}

function ComplexForm() {
  const [state, dispatch] = useReducer(formReducer, {
    values: {},
    errors: {},
    touched: {}
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    dispatch({ type: 'SET_FIELD', field: name, value });
  };

  return (
    <form>
      <input
        name="username"
        value={state.values.username || ''}
        onChange={handleChange}
      />
      {state.errors.username && (
        <span className="text-red-500">{state.errors.username}</span>
      )}
    </form>
  );
}

表单最佳实践

  1. 使用语义化 HTML

    • 正确使用 label、input type、required 等属性,提升可访问性。
  2. 提供清晰的错误信息

    • 错误信息应该具体、可操作,并出现在相关字段附近。
  3. 优化提交体验

    • 禁用提交按钮、显示加载状态、防止重复提交。
  4. 保存草稿

    • 对于长表单,定期保存草稿,避免用户数据丢失。
  5. 移动端优化

    • 使用正确的 input type,启用自动完成,优化键盘类型。

相关资源

这篇文章有帮助吗?