表单
表单的难点通常不是输入框本身,而是状态同步、校验、可访问性与提交体验。
受控 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),读屏不可用。