React 表单处理最佳实践
React 表单处理最佳实践
表单是用户与应用交互的重要方式,表单处理涉及状态管理、验证、错误处理、提交等多个方面。React 提供了受控组件和非受控组件两种表单处理方式,同时社区也提供了丰富的表单处理库,开发者可以根据需求选择合适的方案。
可以把表单想象成一次"对话":用户填写、应用即时反馈、双方反复确认,直到信息完整且合法才最终"成交"(提交)。做得好的表单让用户几乎察觉不到它的存在;做得差的表单则会因为"填完才报错""填一半清空""重复提交"等问题赶走用户。据行业经验,注册/结账表单每减少一个不必要的字段、每优化一处报错时机,转化率都可能提升数个百分点,表单质量直接关系到业务收入。
为什么表单值得单独讲
表单实现方式详解
受控组件:
受控组件是指表单值由 React 状态控制的组件。每个表单元素都有一个对应的状态,通过 onChange 事件更新状态,通过 value 属性设置表单值。可以把它理解为"React 状态是唯一真相来源(single source of truth),DOM 只是状态的镜像"。受控组件的优势在于:可以实时验证用户输入、可以动态修改输入值(如自动转大写、格式化手机号)、可以禁用提交按钮直到表单有效、可以实现复杂的联动逻辑。受控组件的缺点是对于大型表单,需要管理大量状态,每次输入都会重渲染,代码可能变得冗长且有性能压力。
非受控组件:
非受控组件是指表单值由 DOM 自己管理的组件,通过 ref 在需要时(通常是提交时)读取表单值。非受控组件更接近传统 HTML 表单的处理方式,"平时不管、用时再取"。非受控组件的优势在于:代码简洁、不需要为每个字段维护 state、输入时不触发 React 重渲染因而性能好、天然适合文件上传(file input 只能非受控)。非受控组件的缺点是难以实现实时验证和动态修改输入值。
第三方表单库:
React Hook Form 是高性能的表单库,采用以非受控为主、按需订阅的方式,最大限度地减少不必要的重新渲染。Formik 是老牌功能丰富的表单库,提供了完整的受控式表单状态管理、验证、错误提示解决方案,但在大型表单下重渲染较多。Zod 是 TypeScript 优先的验证库(schema validation),可以与 React Hook Form 配合使用,提供从表单到类型的端到端类型安全。
| 方案 | 心智模型 | 重渲染成本 | 适用场景 |
| --- | --- | --- | --- |
| 受控组件 | 手动管理每个 state | 高(每次输入全表单渲染) | 字段少、需强联动/实时格式化 |
| 非受控组件 | ref 提交时读取 | 极低 | 简单表单、文件上传 |
| React Hook Form | 非受控 + 订阅 | 低(字段级隔离) | 中大型表单,性能敏感 |
| Formik | 受控集中管理 | 中到高 | 中小型表单,生态成熟 |
代码示例
// 受控组件:状态即真相来源,支持实时验证
function ControlledForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const validateField = (name, value) => {
let error = '';
switch (name) {
case 'username':
if (!value) error = 'Username is required';
else if (value.length < 3) error = 'Username must be at least 3 characters';
break;
case 'email':
if (!value) error = 'Email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) error = 'Invalid email';
break;
case 'password':
if (!value) error = 'Password is required';
else if (value.length < 8) error = 'Password must be at least 8 characters';
break;
}
return error;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 只有被触碰过的字段才在输入时报错,避免一上来满屏红字
if (touched[name]) {
setErrors(prev => ({ ...prev, [name]: validateField(name, value) }));
}
};
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
setErrors(prev => ({ ...prev, [name]: validateField(name, value) }));
};
const handleSubmit = (e) => {
e.preventDefault();
// 提交时校验所有字段
const nextErrors = {};
Object.keys(formData).forEach(key => {
nextErrors[key] = validateField(key, formData[key]);
});
setErrors(nextErrors);
setTouched({ username: true, email: true, password: true });
if (Object.values(nextErrors).every(msg => !msg)) {
console.log('Form submitted:', formData);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Username</label>
<input name="username" value={formData.username}
onChange={handleChange} onBlur={handleBlur} />
{touched.username && errors.username && <span>{errors.username}</span>}
</div>
<div>
<label>Email</label>
<input name="email" type="email" value={formData.email}
onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <span>{errors.email}</span>}
</div>
<div>
<label>Password</label>
<input name="password" type="password" value={formData.password}
onChange={handleChange} onBlur={handleBlur} />
{touched.password && errors.password && <span>{errors.password}</span>}
</div>
<button type="submit">Submit</button>
</form>
);
}// 非受控组件:ref 提交时读取,天然支持文件上传
function UncontrolledForm() {
const usernameRef = useRef(null);
const emailRef = useRef(null);
const fileRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = {
username: usernameRef.current.value,
email: emailRef.current.value,
file: fileRef.current.files[0] // file input 只能非受控
};
console.log('Form submitted:', formData);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Username</label>
<input ref={usernameRef} defaultValue="" />
</div>
<div>
<label>Email</label>
<input ref={emailRef} type="email" defaultValue="" />
</div>
<div>
<label>File</label>
<input ref={fileRef} type="file" />
</div>
<button type="submit">Submit</button>
</form>
);
}// React 19 的表单 Action + useActionState(服务端/客户端一体化)
// action 返回的对象会成为下一次的 state,pending 由框架托管
function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const email = formData.get('email');
if (!email) return { error: 'Email is required' };
try {
await fetch('/api/subscribe', { method: 'POST', body: formData });
return { success: true };
} catch {
return { error: 'Subscription failed, try again' };
}
},
{ }
);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="you@example.com" />
<button type="submit" disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
{state.error && <span>{state.error}</span>}
{state.success && <span>Subscribed!</span>}
</form>
);
}// React Hook Form 基础用法:非受控 + 内置校验,重渲染极少
import { useForm } from 'react-hook-form';
function ReactHookForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset
} = useForm({
mode: 'onBlur', // 校验时机:失焦校验,兼顾体验与性能
defaultValues: { username: '', email: '', password: '' }
});
const onSubmit = async (data) => {
try {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data)
});
reset();
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Username</label>
<input {...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Username must be at least 3 characters' }
})} />
{errors.username && <span>{errors.username.message}</span>}
</div>
<div>
<label>Email</label>
<input type="email" {...register('email', {
required: 'Email is required',
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email address' }
})} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label>Password</label>
<input type="password" {...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Password must be at least 8 characters' }
})} />
{errors.password && <span>{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}// React Hook Form + Zod:一份 schema 同时提供校验和 TS 类型
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
});
// 从 schema 直接推导出表单类型,改 schema 类型自动同步
type FormValues = z.infer<typeof schema>;
function FormWithZod() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Submit</button>
</form>
);
}// 异步校验:用户名是否已被占用(配合防抖)
import { useForm } from 'react-hook-form';
function SignupWithAsyncCheck() {
const { register, handleSubmit, formState: { errors, isValidating } } = useForm({
mode: 'onBlur'
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('username', {
required: 'Username is required',
validate: async (value) => {
const res = await fetch(`/api/check-username?u=${value}`);
const { available } = await res.json();
return available || 'Username already taken';
}
})} />
{isValidating && <span>Checking...</span>}
{errors.username && <span>{errors.username.message}</span>}
<button type="submit">Submit</button>
</form>
);
}// 复杂表单:动态字段(可增删的明细行),如订单、简历、问卷
import { useForm, useFieldArray } from 'react-hook-form';
function DynamicForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: '', quantity: 1 }] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} placeholder="Item name" />
<input type="number" {...register(`items.${index}.quantity`)} placeholder="Quantity" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}真实案例:电商结账表单
设想一个电商结账页,包含收货信息、优惠券、支付方式共约 15 个字段。最初团队用受控组件实现,结果发现用户在填写地址时,每敲一个字符整个结账页(包括价格明细、推荐商品)都重渲染,低端安卓机上输入明显掉字,结账转化率偏低。
改造分三步:第一,切换到 React Hook Form,字段级订阅让输入只更新对应字段,重渲染次数从"每次输入全页渲染"降到"几乎为零";第二,用 Zod 定义 schema,把手机号、邮编、优惠券格式等规则集中管理并推导出 TS 类型;第三,加入防抖异步校验优惠券、提交时禁用按钮防重复下单、失败时自动聚焦第一个错误字段。改造后移动端输入卡顿消失,表单填写完成率明显提升。
// 提交防重复 + 错误自动聚焦
function CheckoutForm() {
const { register, handleSubmit, setFocus, formState: { errors, isSubmitting } } = useForm();
const onValid = async (data) => {
await placeOrder(data); // isSubmitting 期间按钮禁用,天然防重复提交
};
const onInvalid = (formErrors) => {
// 聚焦到第一个出错的字段,提升可用性
const firstError = Object.keys(formErrors)[0];
if (firstError) setFocus(firstError);
};
return (
<form onSubmit={handleSubmit(onValid, onInvalid)}>
<input {...register('phone', { required: '请填写手机号' })} />
{errors.phone && <span>{errors.phone.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交订单'}
</button>
</form>
);
}表单验证策略
客户端验证:
客户端验证可以提供即时反馈,减少服务器负担。常见的验证时机包括:即时验证(onChange)、失焦验证(onBlur)、提交验证(onSubmit)。经验法则是"错误晚报,正确早报"——首次填写用 onBlur 校验(别在用户还没写完就报错),一旦某字段出过错就改为 onChange 实时校验(让用户看到修正即时生效),这也是 React Hook Form 的默认策略(reValidateMode: 'onChange')。
| 校验时机 | 触发点 | 优点 | 缺点 |
| --- | --- | --- | --- |
| onChange | 每次输入 | 反馈最即时 | 过早报错、性能压力大 |
| onBlur | 失焦时 | 体验好,不打扰 | 反馈略滞后 |
| onSubmit | 提交时 | 实现简单 | 错误集中、体验差 |
服务器端验证:
服务器端验证是最终验证保障,防止恶意提交和处理复杂的验证逻辑(如唯一性约束、库存校验、风控)。永远不要信任客户端校验——它只是体验优化,安全底线必须由服务端守。服务器端验证应该返回结构化的错误信息(哪个字段、什么原因),前端将其映射回对应字段展示。
// 把服务端返回的字段级错误回填到 React Hook Form
async function onSubmit(data) {
const res = await fetch('/api/register', {
method: 'POST', body: JSON.stringify(data)
});
if (!res.ok) {
const { fieldErrors } = await res.json();
// fieldErrors 形如 { email: 'already registered' }
Object.entries(fieldErrors).forEach(([name, message]) => {
setError(name, { type: 'server', message });
});
}
}性能优化
减少重新渲染:
使用 React Hook Form 的 Controller 组件包裹受控的第三方 UI 组件(如 antd Select、MUI DatePicker),可将其状态隔离在局部,避免整个表单重渲染。对于超大型表单,可将表单按业务拆分为多个子组件或分步(stepper),每步只渲染当前部分。
// 用 Controller 桥接受控的第三方组件,隔离重渲染
import { useForm, Controller } from 'react-hook-form';
import { Select } from 'antd';
function ProfileForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="country"
control={control}
rules={{ required: '请选择国家' }}
render={({ field }) => (
<Select {...field} options={[{ value: 'cn', label: '中国' }]} />
)}
/>
<button type="submit">Submit</button>
</form>
);
}防抖和节流:
对于搜索输入、异步校验等需要频繁触发的场景,使用防抖(debounce)延迟触发;对于滚动加载等场景,使用节流(throttle)限制频率。
// 防抖搜索:输入停止 300ms 后才真正查询
function SearchInput({ onSearch }) {
const debounced = useMemo(
() => debounce((value) => onSearch(value), 300),
[onSearch]
);
// 卸载时取消未执行的防抖任务,防止内存泄漏
useEffect(() => () => debounced.cancel(), [debounced]);
return <input onChange={(e) => debounced(e.target.value)} />;
}批量更新与集中管理:
React 18 的自动批处理会将同一事件内的多次 setState 合并为一次渲染。对于状态间强联动的复杂表单,可用 useReducer 集中管理,把"如何更新"收敛到 reducer 里,让组件更清晰、更易测试。
// useReducer 集中管理表单状态
function formReducer(state, action) {
switch (action.type) {
case 'change':
return { ...state, values: { ...state.values, [action.name]: action.value } };
case 'setError':
return { ...state, errors: { ...state.errors, [action.name]: action.error } };
case 'reset':
return action.initial;
default:
return state;
}
}
function ReducerForm({ initial }) {
const [state, dispatch] = useReducer(formReducer, initial);
const onChange = (e) =>
dispatch({ type: 'change', name: e.target.name, value: e.target.value });
return <form>{/* 字段绑定 onChange */}</form>;
}常见坑
最佳实践
受控与非受控深度对比
受控与非受控不是"二选一"的宗教之争,而是一个"控制力 vs 渲染成本"的连续光谱。理解两者的底层差异,才能在具体场景下做出正确取舍。
底层数据流差异:
一张更细的对比表:
| 维度 | 受控组件 | 非受控组件 |
| --- | --- | --- |
| 真相来源 | React state | DOM |
| 输入时重渲染 | 每次都渲染 | 完全不渲染 |
| 实时校验 | 天然支持 | 需要手动 addEventListener |
| 动态格式化输入 | 容易(改 state 即可) | 困难(要操作 DOM) |
| 初始值设置 | value + 空字符串 | defaultValue |
| 重置表单 | setState 回初值 | form.reset() |
| 文件上传 | 不支持 | 唯一选择 |
| 20 字段表单敲 200 次键的重渲染 | 约 200 次 | 0 次 |
| 代码量(单字段) | 约 6 行 | 约 2 行 |
混合模式:局部受控、整体非受控。 React Hook Form 正是这一思想的集大成者——绝大多数字段走非受控(register 内部用 ref),只有需要"受控行为"的字段(如需要实时格式化的金额输入、第三方 Select)才通过 Controller 局部受控。这样既保留了必要的控制力,又把渲染成本压到最低。
// 混合模式示例:金额字段实时格式化(受控),其余字段非受控
import { useForm, Controller } from 'react-hook-form';
function InvoiceForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { title: '', amount: '' }
});
const formatMoney = (raw: string) => {
// 去掉非数字,每三位加逗号
const digits = raw.replace(/\D/g, '');
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
return (
<form onSubmit={handleSubmit(console.log)}>
{/* 普通字段:非受控,输入零重渲染 */}
<input {...register('title')} placeholder="发票标题" />
{/* 金额字段:受控,输入时实时格式化 */}
<Controller
name="amount"
control={control}
render={({ field }) => (
<input
value={field.value}
onChange={(e) => field.onChange(formatMoney(e.target.value))}
placeholder="0"
inputMode="numeric"
/>
)}
/>
<button type="submit">开票</button>
</form>
);
}原生 FormData 与渐进增强
在引入任何第三方库之前,值得先掌握浏览器原生的 FormData API。它是零依赖、包体积 0 KB 的方案,尤其适合简单表单和追求渐进增强(progressive enhancement,即 JS 未加载时表单仍可用)的场景。
// 纯原生 FormData:无需为每个字段维护 state
function NativeFormDataForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// 读取单个字段
const email = formData.get('email');
// 读取多值字段(如多选框、多文件)
const hobbies = formData.getAll('hobby');
// 一次性转成普通对象
const data = Object.fromEntries(formData.entries());
console.log({ email, hobbies, data });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<label><input type="checkbox" name="hobby" value="reading" /> 阅读</label>
<label><input type="checkbox" name="hobby" value="coding" /> 编程</label>
<label><input type="checkbox" name="hobby" value="sports" /> 运动</label>
<button type="submit">提交</button>
</form>
);
}FormData 的关键优势:
它的局限也很明显:实时校验、错误展示、字段联动都要手写,不适合复杂交互表单。经验法则是:字段 ≤ 5 且无复杂交互,用 FormData;否则上 React Hook Form。
React 19 表单能力全景
React 19 把"表单"提升为一等公民,引入了 Actions、useActionState、useFormStatus、useOptimistic 一整套原语,目标是让表单的 pending/error/optimistic 状态由框架托管,而不是开发者手写一堆 useState。
useFormStatus:读取父级 form 的提交状态。 它必须用在 form 的子组件里,让"提交按钮"这类组件无需 props 透传就能知道表单是否正在提交。
// 独立的提交按钮组件,自动感知 form 的 pending 状态
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending, data } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? '提交中...' : children}
</button>
);
}
// 使用时无需任何 props,按钮自己知道该禁用
function ContactForm({ action }: { action: (fd: FormData) => Promise<void> }) {
return (
<form action={action}>
<input name="name" required />
<textarea name="message" required />
<SubmitButton>发送消息</SubmitButton>
</form>
);
}useOptimistic:乐观更新。 在请求还没返回时,先把 UI 更新成"预期成功的样子",请求失败再回滚。这能让评论、点赞、待办这类操作的感知延迟从"网络往返的 300ms~1s"降到"0ms"。
// 评论区乐观更新:提交瞬间就显示新评论
import { useOptimistic, useRef } from 'react';
type Comment = { id: string; text: string; sending?: boolean };
function CommentList({ comments, addComment }: {
comments: Comment[];
addComment: (text: string) => Promise<void>;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(state, newText: string) => [
...state,
{ id: 'temp-' + Date.now(), text: newText, sending: true }
]
);
const action = async (formData: FormData) => {
const text = formData.get('text') as string;
addOptimistic(text); // 立即在 UI 上加一条(灰色"发送中")
formRef.current?.reset();
await addComment(text); // 真正落库,成功后 comments 更新,临时项被替换
};
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>
{c.text} {c.sending && '(发送中...)'}
</li>
))}
</ul>
<form action={action} ref={formRef}>
<input name="text" required />
<button type="submit">评论</button>
</form>
</>
);
}Server Actions 与 useActionState 结合校验。 在 Next.js App Router 中,可以把 Server Action 与 Zod 校验结合,返回结构化错误交给客户端展示,实现"一份逻辑既跑在服务端又驱动 UI"。
// app/actions.ts —— 服务端 Action('use server')
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('邮箱格式不正确'),
age: z.coerce.number().min(18, '需年满 18 岁')
});
export async function register(prevState: unknown, formData: FormData) {
const parsed = schema.safeParse({
email: formData.get('email'),
age: formData.get('age')
});
if (!parsed.success) {
// 把 Zod 错误拍平成 { field: message }
return { errors: parsed.error.flatten().fieldErrors };
}
await saveUser(parsed.data);
return { success: true };
}
// app/register-form.tsx —— 客户端组件
'use client';
import { useActionState } from 'react';
import { register } from './actions';
function RegisterForm() {
const [state, formAction, isPending] = useActionState(register, {});
return (
<form action={formAction}>
<input name="email" type="email" />
{state?.errors?.email && <span role="alert">{state.errors.email[0]}</span>}
<input name="age" type="number" />
{state?.errors?.age && <span role="alert">{state.errors.age[0]}</span>}
<button disabled={isPending}>{isPending ? '注册中...' : '注册'}</button>
{state?.success && <p>注册成功!</p>}
</form>
);
}React Hook Form 核心 API 逐个击破
React Hook Form(下称 RHF)之所以能在约 60KB 未压缩、约 9KB gzip 的体积下成为最流行的 React 表单库,靠的是"非受控 + 精准订阅"的架构。下面逐个拆解它的核心 API。
register:把字段登记为非受控。 register 返回 name、ref、onChange、onBlur 四个属性,展开到 input 上后,RHF 就通过 ref 直接读 DOM 值,输入时不触发组件重渲染。
// register 的第二个参数是校验规则对象
<input {...register('email', {
required: '邮箱必填',
maxLength: { value: 100, message: '不超过 100 字符' },
pattern: { value: /^\S+@\S+$/, message: '格式不正确' },
validate: {
notGmail: (v) => !v.endsWith('@gmail.com') || '不接受 gmail 邮箱',
notBlacklisted: async (v) => {
const ok = await checkBlacklist(v);
return ok || '该邮箱在黑名单中';
}
},
setValueAs: (v) => v.trim().toLowerCase() // 存入前统一格式化
})} />watch vs useWatch:订阅字段变化。 watch 在组件顶层调用会导致该组件在被订阅字段变化时重渲染;useWatch 是独立 Hook,把订阅隔离到一个小组件里,避免带着整个表单一起重渲染。做"依赖字段联动"时优先用 useWatch。
// 依赖字段联动:省份变化时清空并重载城市列表
import { useForm, useWatch } from 'react-hook-form';
function CityField({ control, register, setValue }: any) {
// 只有这个小组件在 province 变化时重渲染,表单其余部分不动
const province = useWatch({ control, name: 'province' });
const cities = useMemo(() => getCitiesByProvince(province), [province]);
useEffect(() => {
setValue('city', ''); // 省份变了,城市要清空
}, [province, setValue]);
return (
<select {...register('city')}>
{cities.map((c: any) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
);
}setValue / getValues / reset:命令式操作表单。
const { setValue, getValues, reset, trigger } = useForm();
// setValue:程序化赋值,第三参可控制是否触发校验和标脏
setValue('phone', '13800138000', { shouldValidate: true, shouldDirty: true });
// getValues:读取当前值(不触发订阅/重渲染),适合在事件回调里"顺手取一下"
const all = getValues(); // 全部
const email = getValues('email'); // 单个
const [a, b] = getValues(['a', 'b']); // 多个
// reset:重置到给定值(常用于"编辑表单加载完远程数据后回填")
useEffect(() => {
if (userData) reset(userData); // 把接口返回的数据一次性填入并清除 dirty 状态
}, [userData, reset]);
// trigger:手动触发校验(多步表单里"下一步前校验当前步"必用)
const valid = await trigger(['name', 'email']);formState:表单的全部元状态。 formState 是一个用 Proxy 实现的对象,你解构了哪个字段,RHF 才订阅哪个,没用到的状态不会引起重渲染。
| formState 字段 | 含义 | 典型用途 |
| --- | --- | --- |
| errors | 各字段错误对象 | 展示错误信息 |
| isDirty | 表单是否被修改过 | 离开页面前提示有未保存改动 |
| dirtyFields | 具体哪些字段脏了 | 只提交变更过的字段 |
| touchedFields | 哪些字段被触碰过 | 控制错误展示时机 |
| isValid | 整体是否通过校验 | 禁用提交按钮 |
| isSubmitting | 是否正在提交 | 禁用按钮、显示 loading |
| isSubmitSuccessful | 上次提交是否成功 | 成功后自动 reset |
| submitCount | 提交次数 | 多次失败后换更醒目的提示 |
mode 与 reValidateMode:校验时机的两个开关。 mode 控制首次校验时机,reValidateMode 控制字段已出错后的重新校验时机。
| mode | 首次校验触发点 | 体验特点 |
| --- | --- | --- |
| onSubmit(默认) | 点提交时 | 最不打扰,但错误集中 |
| onBlur | 字段失焦 | 推荐,填完一个校一个 |
| onChange | 每次输入 | 反馈最快,重渲染最多 |
| onTouched | 首次失焦后转 onChange | 体验与性能的平衡点 |
| all | onBlur 与 onChange 都触发 | 最严格 |
推荐组合是 mode 设为 onTouched,首次失焦才报错、之后实时纠正,与前文"错误晚报、正确早报"的原则一致。
useFieldArray 动态字段实战
useFieldArray 专门处理"可增删的字段数组",如订单明细、教育经历、多个联系人。它提供 append、prepend、insert、remove、move、swap、replace、update 等操作方法。
// 简历教育经历:可增删、可上移排序、每行独立校验
import { useForm, useFieldArray } from 'react-hook-form';
type EduForm = {
educations: { school: string; degree: string; year: number }[];
};
function ResumeForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<EduForm>({
defaultValues: { educations: [{ school: '', degree: '', year: 2020 }] }
});
const { fields, append, remove, move } = useFieldArray({
control,
name: 'educations'
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
// 注意:key 必须用 field.id,不能用 index(否则删除中间行会错乱)
<div key={field.id}>
<input
{...register(`educations.${index}.school`, { required: '学校必填' })}
placeholder="学校"
/>
{errors.educations?.[index]?.school && (
<span>{errors.educations[index]?.school?.message}</span>
)}
<input
{...register(`educations.${index}.degree`)}
placeholder="学位"
/>
<input
type="number"
{...register(`educations.${index}.year`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>删除</button>
{index > 0 && (
<button type="button" onClick={() => move(index, index - 1)}>上移</button>
)}
</div>
))}
<button type="button" onClick={() => append({ school: '', degree: '', year: 2024 })}>
添加教育经历
</button>
<button type="submit">保存简历</button>
</form>
);
}useFieldArray 三大常见坑:
Zod schema 与类型推导进阶
Zod 是 TypeScript 优先的校验库(约 13KB gzip),核心价值是"一份 schema 同时产出运行时校验和编译期类型",彻底消灭"校验规则和 TS 类型两处手写、容易不同步"的问题。
import { z } from 'zod';
// 丰富的 schema 定义
const userSchema = z.object({
// 字符串约束
username: z.string()
.min(3, '至少 3 个字符')
.max(20, '最多 20 个字符')
.regex(/^[a-zA-Z0-9_]+$/, '只能包含字母数字下划线'),
// 邮箱、URL 等内置校验
email: z.string().email('邮箱格式不正确'),
website: z.string().url('网址格式不正确').optional(),
// 数字:coerce 会把表单里的字符串 "18" 自动转成数字 18
age: z.coerce.number().int('必须是整数').min(18, '需年满 18').max(120),
// 枚举
role: z.enum(['admin', 'editor', 'viewer'], {
errorMap: () => ({ message: '角色不合法' })
}),
// 嵌套对象
address: z.object({
province: z.string().min(1, '请选择省份'),
city: z.string().min(1, '请选择城市'),
detail: z.string().min(5, '详细地址至少 5 字')
}),
// 数组 + 元素约束
tags: z.array(z.string()).min(1, '至少一个标签').max(5, '最多 5 个标签'),
// 可选 + 默认值
newsletter: z.boolean().default(false)
});
// 关键:从 schema 推导类型,schema 一改类型自动同步
type User = z.infer<typeof userSchema>;跨字段校验用 refine / superRefine。 单字段校验用链式方法,涉及多字段关系(如两次密码一致、结束日期晚于开始日期)用 refine 或更灵活的 superRefine。
const passwordSchema = z.object({
password: z.string().min(8, '密码至少 8 位'),
confirmPassword: z.string(),
startDate: z.string(),
endDate: z.string()
})
// refine:单条跨字段规则,失败信息挂到指定 path
.refine((data) => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword']
})
// superRefine:可添加多条错误,逻辑更复杂时用
.superRefine((data, ctx) => {
if (new Date(data.endDate) <= new Date(data.startDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '结束日期必须晚于开始日期',
path: ['endDate']
});
}
});输入输出类型不一致时用 z.input / z.output。 当 schema 里用了 transform 或 coerce,输入类型和输出类型会不同,此时用 z.input 给表单,用 z.output 给提交后的数据。
const s = z.object({
price: z.string().transform((v) => parseFloat(v)) // 输入 string,输出 number
});
type FormInput = z.input<typeof s>; // { price: string }
type Parsed = z.output<typeof s>; // { price: number }RHF + Zod 集成完整实战
把 RHF 和 Zod 用 zodResolver 粘合,就得到了"端到端类型安全 + 声明式校验 + 高性能"的黄金组合。下面是一个包含嵌套对象、数组、跨字段校验的完整例子。
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const orderSchema = z.object({
customer: z.object({
name: z.string().min(2, '姓名至少 2 字'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确')
}),
items: z.array(z.object({
product: z.string().min(1, '请选择商品'),
quantity: z.coerce.number().int().min(1, '数量至少 1')
})).min(1, '至少一件商品'),
couponCode: z.string().optional(),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: '需同意服务条款' })
})
});
type OrderForm = z.infer<typeof orderSchema>;
function OrderCheckout() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid }
} = useForm<OrderForm>({
resolver: zodResolver(orderSchema),
mode: 'onBlur',
defaultValues: {
customer: { name: '', phone: '' },
items: [{ product: '', quantity: 1 }],
agreeTerms: false as unknown as true
}
});
const onSubmit = async (data: OrderForm) => {
// data 已通过 Zod 校验且带完整类型,可直接发给后端
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customer.name')} placeholder="姓名" />
{errors.customer?.name && <span>{errors.customer.name.message}</span>}
<input {...register('customer.phone')} placeholder="手机号" />
{errors.customer?.phone && <span>{errors.customer.phone.message}</span>}
<input {...register('items.0.product')} placeholder="商品" />
<input type="number" {...register('items.0.quantity')} />
<label>
<input type="checkbox" {...register('agreeTerms')} />
我已阅读并同意服务条款
</label>
{errors.agreeTerms && <span>{errors.agreeTerms.message}</span>}
<button type="submit" disabled={isSubmitting || !isValid}>
{isSubmitting ? '提交中...' : '提交订单'}
</button>
</form>
);
}Formik 对比与迁移
Formik 是 RHF 之前的老牌表单库(约 45KB gzip,明显大于 RHF),采用集中式受控状态管理。它 API 直观、生态成熟,但在大型表单下每次输入都会触发整棵表单树重渲染,性能不及 RHF。
// Formik 基础写法:受控、集中管理
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';
const schema = Yup.object({
email: Yup.string().email('邮箱格式不正确').required('必填'),
password: Yup.string().min(8, '至少 8 位').required('必填')
});
function FormikLogin() {
return (
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={schema}
onSubmit={async (values, { setSubmitting }) => {
await login(values);
setSubmitting(false);
}}
>
{({ isSubmitting }) => (
<Form>
<Field name="email" type="email" />
<ErrorMessage name="email" component="span" />
<Field name="password" type="password" />
<ErrorMessage name="password" component="span" />
<button type="submit" disabled={isSubmitting}>登录</button>
</Form>
)}
</Formik>
);
}Formik vs RHF 关键差异:
| 维度 | Formik | React Hook Form |
| --- | --- | --- |
| 架构 | 受控、集中 state | 非受控、订阅 |
| 包体积 gzip | 约 45KB | 约 9KB |
| 20 字段输入一次的重渲染 | 整表单重渲染 | 仅当前字段 |
| 校验库 | 通常配 Yup | 通常配 Zod |
| 心智负担 | 低,直观 | 中,需理解订阅 |
| 大型表单性能 | 一般 | 优秀 |
| 维护活跃度 | 放缓 | 活跃 |
迁移要点: Formik 的 Field 对应 RHF 的 register;values 对象订阅改为 watch/useWatch;validationSchema(Yup)迁到 zodResolver(Zod);setFieldValue 对应 setValue;FieldArray 对应 useFieldArray。迁移收益主要在性能与体积——某中后台把 30 字段的 Formik 表单迁到 RHF 后,输入帧率从卡顿的约 40fps 回到稳定 60fps,首屏 JS 减少约 36KB。
复杂场景一:多步表单 / 向导
多步表单(wizard/stepper)把长表单拆成若干步,每步只校验并展示当前部分,能显著降低用户的填写压力——研究显示把 15 字段拆成 3 步后,完成率通常能提升 10% 以上。关键是"跨步保留状态"和"下一步前只校验当前步"。
// 三步注册向导:单个 useForm 贯穿全程,trigger 分步校验
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const wizardSchema = z.object({
// 第一步
username: z.string().min(3, '至少 3 字'),
email: z.string().email('邮箱不正确'),
// 第二步
company: z.string().min(1, '请填写公司'),
role: z.string().min(1, '请填写职位'),
// 第三步
plan: z.enum(['free', 'pro', 'team'])
});
type Wizard = z.infer<typeof wizardSchema>;
// 每一步管辖哪些字段
const stepFields: Record<number, (keyof Wizard)[]> = {
0: ['username', 'email'],
1: ['company', 'role'],
2: ['plan']
};
function RegisterWizard() {
const [step, setStep] = useState(0);
const { register, handleSubmit, trigger, formState: { errors } } = useForm<Wizard>({
resolver: zodResolver(wizardSchema),
mode: 'onBlur'
});
const next = async () => {
// 只校验当前步的字段,全通过才允许进入下一步
const ok = await trigger(stepFields[step]);
if (ok) setStep((s) => Math.min(s + 1, 2));
};
const prev = () => setStep((s) => Math.max(s - 1, 0));
const onSubmit = (data: Wizard) => console.log('最终提交', data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 进度指示 */}
<p>第 {step + 1} / 3 步</p>
{step === 0 && (
<>
<input {...register('username')} placeholder="用户名" />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('email')} placeholder="邮箱" />
{errors.email && <span>{errors.email.message}</span>}
</>
)}
{step === 1 && (
<>
<input {...register('company')} placeholder="公司" />
{errors.company && <span>{errors.company.message}</span>}
<input {...register('role')} placeholder="职位" />
{errors.role && <span>{errors.role.message}</span>}
</>
)}
{step === 2 && (
<select {...register('plan')}>
<option value="free">免费版</option>
<option value="pro">专业版</option>
<option value="team">团队版</option>
</select>
)}
<div>
{step > 0 && <button type="button" onClick={prev}>上一步</button>}
{step < 2 && <button type="button" onClick={next}>下一步</button>}
{step === 2 && <button type="submit">完成注册</button>}
</div>
</form>
);
}为什么用单个 useForm 而非每步一个? 单个 useForm 让所有字段的值天然跨步保留,切回上一步数据还在;若每步独立 useForm,则需要额外把每步的值提升到父级 state 手动合并,复杂且易错。
复杂场景二:文件上传含进度
文件上传是非受控的唯一必然场景(file input 无法受控)。要展示上传进度,fetch 目前对上传进度支持有限,实践中仍常用 XMLHttpRequest 的 upload.onprogress。
// 带进度条 + 大小/类型校验 + 可取消的文件上传
function FileUpload() {
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const xhrRef = useRef<XMLHttpRequest | null>(null);
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPT = ['image/png', 'image/jpeg', 'application/pdf'];
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 前端预校验:类型 + 大小
if (!ACCEPT.includes(file.type)) {
setError('仅支持 PNG / JPG / PDF');
return;
}
if (file.size > MAX_SIZE) {
setError('文件不能超过 5MB');
return;
}
setError('');
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.open('POST', '/api/upload');
// 关键:监听上传进度
xhr.upload.onprogress = (evt) => {
if (evt.lengthComputable) {
setProgress(Math.round((evt.loaded / evt.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status === 200) setProgress(100);
else setError('上传失败');
};
xhr.onerror = () => setError('网络错误');
xhr.send(formData);
};
const cancel = () => {
xhrRef.current?.abort();
setProgress(0);
};
return (
<div>
<input type="file" accept={ACCEPT.join(',')} onChange={handleFile} />
{progress > 0 && progress < 100 && (
<>
<progress value={progress} max={100} /> {progress}%
<button type="button" onClick={cancel}>取消</button>
</>
)}
{progress === 100 && <span>上传完成</span>}
{error && <span role="alert">{error}</span>}
</div>
);
}复杂场景三:条件字段与跨字段联动
条件字段指"某字段是否显示/是否必填取决于另一个字段的值"。例如"是否开发票"选是时才显示发票抬头。用 watch 监听控制字段,条件渲染并配合动态校验。
// 条件字段:勾选开发票才显示并要求填写抬头
import { useForm } from 'react-hook-form';
function InvoiceOption() {
const { register, watch, handleSubmit, formState: { errors } } = useForm({
defaultValues: { needInvoice: false, invoiceTitle: '' }
});
const needInvoice = watch('needInvoice');
return (
<form onSubmit={handleSubmit(console.log)}>
<label>
<input type="checkbox" {...register('needInvoice')} /> 需要发票
</label>
{needInvoice && (
<div>
<input
{...register('invoiceTitle', {
// 仅在需要发票时必填,用 validate 动态判断
validate: (v) => !needInvoice || v.length > 0 || '请填写发票抬头'
})}
placeholder="发票抬头"
/>
{errors.invoiceTitle && <span>{errors.invoiceTitle.message}</span>}
</div>
)}
<button type="submit">提交</button>
</form>
);
}复杂场景四:草稿自动保存
对长表单(如文章编辑器、申请表),"自动保存草稿"能极大降低意外丢失的风险。做法是 watch 全表单 + 防抖后写入 localStorage 或后端。
// 自动保存草稿:变更后防抖 1000ms 落盘,进入时恢复
import { useForm } from 'react-hook-form';
const DRAFT_KEY = 'article-draft';
function ArticleEditor() {
const { register, watch, reset, handleSubmit } = useForm({
defaultValues: { title: '', body: '' }
});
// 进入时恢复草稿
useEffect(() => {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) reset(JSON.parse(saved));
}, [reset]);
// 监听全表单变化,防抖后保存
useEffect(() => {
const debounced = debounce((value: unknown) => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(value));
}, 1000);
const subscription = watch((value) => debounced(value));
return () => {
subscription.unsubscribe();
debounced.cancel();
};
}, [watch]);
const onSubmit = (data: unknown) => {
localStorage.removeItem(DRAFT_KEY); // 正式提交后清草稿
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} placeholder="标题" />
<textarea {...register('body')} placeholder="正文" rows={10} />
<button type="submit">发布</button>
</form>
);
}草稿保存的注意点:防抖间隔不宜太短(推荐 800~1500ms,太短会频繁写盘);localStorage 有约 5MB 上限,超大草稿应写后端;恢复草稿时最好给用户"发现未完成草稿,是否恢复"的选择,而非静默覆盖。
复杂场景五:i18n 错误信息
多语言应用里,错误信息不能写死中文。Zod 支持全局 errorMap,也可在 schema 里传入翻译函数,让同一 schema 输出不同语言的错误。
// 用翻译函数生成 schema,切换语言即切换错误文案
import { z } from 'zod';
type TFunc = (key: string, params?: Record<string, unknown>) => string;
function makeSchema(t: TFunc) {
return z.object({
email: z.string().email(t('errors.email')),
password: z.string().min(8, t('errors.passwordMin', { min: 8 }))
});
}
// 组件里根据当前语言生成 schema
function LoginForm() {
const { t, locale } = useI18n();
const schema = useMemo(() => makeSchema(t), [t, locale]);
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
});
// locale 变化时 schema 重建,错误文案随之切换
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">{t('login.submit')}</button>
</form>
);
}无障碍(a11y)最佳实践
表单是 a11y 的重灾区。做好无障碍不仅服务残障用户,也提升所有人的可用性和 SEO。核心要点:
// 无障碍表单字段的完整写法
function A11yField({ register, name, label, error, required }: any) {
const id = `field-${name}`;
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>
{label}{required && <span aria-hidden="true"> *</span>}
</label>
<input
id={id}
{...register(name)}
aria-required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} role="alert">
{error.message}
</span>
)}
</div>
);
}性能优化:字段级订阅与虚拟化
RHF 的性能优势来自"订阅",但用错了照样卡。几条关键法则:
1. 优先 useWatch 而非 watch。 顶层 watch 会让整个组件在被监听字段变化时重渲染,把 watch 下沉到小组件用 useWatch 可把重渲染范围缩到最小。
2. 用 Controller 隔离受控的第三方组件。 每个 Controller 内部维护自己的订阅,一个 Select 变化不会波及其它字段。
3. 长表单虚拟化。 当字段数达到数百(如批量编辑表格、动态问卷),一次性渲染所有 DOM 会拖慢首屏。用 react-window 只渲染可视区域的行。
// 虚拟化长表单:500 行只渲染可视的约 15 行
import { FixedSizeList } from 'react-window';
import { useFormContext } from 'react-hook-form';
function VirtualizedRows({ count }: { count: number }) {
const { register } = useFormContext();
return (
<FixedSizeList height={400} itemCount={count} itemSize={48} width="100%">
{({ index, style }) => (
<div style={style}>
<input {...register(`rows.${index}.value`)} placeholder={`第 ${index + 1} 行`} />
</div>
)}
</FixedSizeList>
);
}500 字段的表单不做虚拟化,首次渲染可能耗时数百毫秒并产生上千个 DOM 节点;虚拟化后 DOM 节点常驻仅约 15 个,首屏渲染回到毫秒级。
4. 防抖高频校验。 异步校验(如用户名查重)务必防抖,300ms 是常用值;不防抖会在每次按键都打一次接口,既浪费又可能触发限流。
服务端校验兜底
再次强调:客户端校验只是体验优化,绝不能作为安全边界。攻击者可以绕过前端直接打接口。服务端必须独立完成完整校验,理想做法是"前后端共用同一份 Zod schema",既保证一致又避免重复维护。
// shared/schema.ts —— 前后端共享
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
// server route —— 服务端用同一 schema 校验
export async function POST(req: Request) {
const body = await req.json();
const parsed = signupSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ fieldErrors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
// 唯一性等只有服务端能做的校验
if (await emailExists(parsed.data.email)) {
return Response.json(
{ fieldErrors: { email: ['该邮箱已注册'] } },
{ status: 409 }
);
}
await createUser(parsed.data);
return Response.json({ ok: true });
}表单库选型对比表
| 库 | gzip 体积 | 架构 | 校验搭配 | TS 友好 | 适用规模 |
| --- | --- | --- | --- | --- | --- |
| 原生 FormData | 0KB | DOM | 手写 | 一般 | 极简表单 |
| React 19 Actions | 0KB(内置) | 非受控 + Action | Zod | 好 | 简单到中等、SSR |
| React Hook Form | 约 9KB | 非受控 + 订阅 | Zod | 极好 | 中大型、性能敏感 |
| Formik | 约 45KB | 受控集中 | Yup | 好 | 中小型、遗留项目 |
| TanStack Form | 约 12KB | 订阅、框架无关 | 内置/Zod | 极好 | 跨框架、类型极致 |
一句话选型:极简用 FormData 或 React 19 Actions;绝大多数项目用 React Hook Form + Zod;遗留 Formik 项目可保留但新表单建议 RHF;追求跨框架一致或极致类型可看 TanStack Form。
常见坑清单(补充)
测试表单(RTL + user-event)
表单测试用 React Testing Library + user-event,理念是"像用户一样操作"——查找可访问的元素(byLabelText、byRole),模拟真实输入,断言可见结果,而非测实现细节。
// 测试登录表单:校验错误 + 成功提交
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('空提交时展示校验错误', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /登录/ }));
// 错误用 role="alert",可被 findByRole 捕获
expect(await screen.findByText('邮箱不正确')).toBeInTheDocument();
expect(screen.getByText('密码至少 8 位')).toBeInTheDocument();
});
test('填写正确并提交,回调收到数据', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// 用 label 文本定位输入框,保证 a11y 同时测到
await user.type(screen.getByLabelText('邮箱'), 'a@b.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: /登录/ }));
expect(handleSubmit).toHaveBeenCalledWith(
{ email: 'a@b.com', password: 'password123' },
expect.anything()
);
});
test('异步用户名查重失败时报错', async () => {
const user = userEvent.setup();
// mock 接口返回不可用
jest.spyOn(global, 'fetch').mockResolvedValue({
json: async () => ({ available: false })
} as Response);
render(<SignupForm />);
await user.type(screen.getByLabelText('用户名'), 'taken');
await user.tab(); // 触发 blur
expect(await screen.findByText('用户名已被占用')).toBeInTheDocument();
});测试要点:优先用 byRole / byLabelText 而非 byTestId(顺带验证 a11y);异步错误用 findBy 而非 getBy(等待出现);避免测内部 state,只断言用户可见的结果;user-event 比 fireEvent 更接近真实交互(会触发 focus、blur、键盘序列)。
总结
React 表单的核心矛盾是"实时反馈"与"渲染性能"的平衡。受控组件给你最强的控制力但代价是渲染成本,非受控组件性能好但控制弱,React Hook Form 通过非受控 + 订阅取得了两者的最佳平衡,再配合 Zod 实现一份 schema 打通校验与类型。把校验时机、防重复提交、错误聚焦、服务端兜底这些细节做到位,表单体验和业务转化都会明显受益。
| 主题 | 推荐做法 | 反模式 |
| --- | --- | --- |
| 方案选型 | 中大型用 RHF + Zod | 大表单硬用全受控 |
| 校验时机 | 首次 onBlur、出错后 onChange | 一上来 onChange 满屏红 |
| 安全 | 前端体验校验 + 服务端强制校验 | 只信任前端校验 |
| 性能 | 字段级订阅、Controller 隔离、防抖 | 每次输入全表单渲染 |
| 提交 | isSubmitting 禁用、失败聚焦 | 按钮不禁用、无反馈 |
| 类型 | z.infer 从 schema 推导类型 | 手写重复的类型定义 |