跳转到主要内容

表单

表单的难点通常不是输入框本身,而是状态同步、校验、可访问性与提交体验。

受控 vs 非受控

受控组件更可预测;非受控更接近原生,性能更好。选择取决于校验/联动/序列化需求。

一个更实用的判断方式是:如果 UI 需要对每次输入即时响应,你大概率需要受控;如果只在提交时读取值,非受控通常更简单、更快。

  • 简单“提交一次”的表单,优先非受控(减少状态同步成本)。
  • 需要实时校验、条件渲染或字段联动时,使用受控更易维护。

基线:先用原生语义

即便在 React 里,表单也应尽可能依赖平台能力:正确的 label/name、输入类型、自动填充与原生提交语义。

把语义打牢后,键盘支持、自动填充、读屏支持很多时候几乎是“免费赠送”的。

  • 一定要把 `<label>` 与 `<input>` 关联起来(`htmlFor`/`id`),扩大可点击区域并支持读屏。
  • 正确使用 input 的 `type`(`email`/`password`/`number`)与 `autoComplete`(`email`/`current-password`)提升体验。
  • 优先使用真正的 `<form>` + `type="submit"` 按钮,这样 `Enter` 回车提交与原生校验才能工作。

非受控示例(FormData)

对“提交一次”的表单,非受控可以避免每次输入都同步状态。提交时用 `FormData` 读取即可。

这种方式在很多基础 CRUD 页面里非常好用:React state 只负责 UI,不用承载每一个输入字符。

TypeScript
export function SignupForm() {
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    const fd = new FormData(form);
    const email = String(fd.get('email') ?? '');
    const password = String(fd.get('password') ?? '');

    // 这里做最小校验:真实校验仍应在服务端再次进行
    if (!email || !password) return;

    // await api.signup({ email, password })
    form.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" autoComplete="new-password" required />

      <button type="submit">Create account</button>
    </form>
  );
}

校验与错误呈现

让“哪里错了、怎么修”变得可见且可操作,并把错误信息与输入控件关联起来。

一个好的错误提示要回答两件事:“哪里错了”和“我现在该怎么修”。尽量具体,并且离字段足够近。

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

export function EmailField() {
  const id = useId();
  const errorId = `${id}-error`;
  const [value, setValue] = useState('');
  const [touched, setTouched] = useState(false);

  const isValid = value === '' || /^[^@]+@[^@]+\.[^@]+$/.test(value);
  const showError = touched && !isValid;

  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        name="email"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={() => setTouched(true)}
        aria-invalid={showError}
        aria-describedby={showError ? errorId : undefined}
        autoComplete="email"
      />
      {showError ? (
        <p id={errorId} role="alert">
          Please enter a valid email.
        </p>
      ) : null}
    </div>
  );
}

提交体验(Submission UX)

提交阶段往往是表单“翻车”的地方:重复提交、进度不明确、错误不可操作。

  • 防止重复提交:`pending` 期间禁用提交按钮并展示进度。
  • 错误要可操作:同时给出“错误汇总”与“字段级提示”。
  • 成功要明确:toast/提示 + UI 状态更新;仅在合适时 `reset`。

聚焦到第一个错误字段

可访问性的快速收益:提交后把焦点移动到第一个错误字段,键盘/读屏流程会好很多。

在大表单里,用户很容易错过第一个错误然后反复点提交。聚焦到第一个错误字段能直接消除这类挫败感。

TypeScript
function focusFirstInvalid(form: HTMLFormElement) {
  const el = form.querySelector<HTMLElement>('[aria-invalid="true"], :invalid');
  el?.focus();
}

export function Example() {
  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    // 如果你自己做校验:先把 aria-invalid 设置好,然后再 focus
    focusFirstInvalid(form);
  }

  return (
    <form onSubmit={onSubmit}>
      {/* ... */}
      <button type="submit">Submit</button>
    </form>
  );
}

性能建议

表单性能问题大多来自“大范围重渲染”。修复思路通常是缩小更新影响面,而不是对 input 组件做微优化。

  • 避免把所有字段塞进一个顶层对象并在每次输入都整体 setState,容易放大重渲染。
  • 昂贵校验可以异步/防抖;能在 `blur`/`submit` 做的,就别每个 keypress 都做。

上线检查清单

  • 字段具备 `label`/`name`/`type`/`autoComplete`;纯键盘可完成填写与提交。
  • 服务端二次校验:权限/价格/安全相关数据绝不只靠前端校验。
  • 错误可宣读且可操作(`role="alert"`、`aria-describedby`、焦点管理)。
  • 提交具备幂等或保护(`pending` 禁用、服务端请求去重)。

常见坑

  • 把每个字段都做成受控但没有分片更新,导致输入卡顿。
  • 错误提示不关联到输入(缺少 aria-describedby/role),读屏不可用。

延伸阅读

表单 - Guides - React 文档 - React 文档