表单校验与反馈:让用户知道下一步怎么做
表单问题常发生在反馈上
上个月我在排查一个注册转化的数据。注册表单一共六个字段,从页面加载到点击提交的平均耗时只有 22 秒,但表单提交后的「回到注册页」回访率高达 17%。用户填完了、点了提交,又回来了——说明提交之后出了某种问题,用户不知道该怎么办。
打开表单的监控一看,服务端返回的校验错误(邮箱已注册、密码不符合策略)直接用后端原始 JSON 的 error.message 渲染到了页面上,诸如 "UserAlreadyExists" 和 "PasswordPolicyViolation"。这些字符串对用户来说跟乱码没有区别。
表单校验逻辑本身并没有缺失——后端做了唯一性检查、密码强度检查,前端做了格式校验和必填校验。问题出在反馈层:错误出现的时机不合理、提示文案不可执行、服务端失败后没有恢复路径。
好表单不只是校验字段,还要让用户知道下一步该怎么做。这篇文章从校验时机、错误文案、异步校验、数据保护和可访问性五个角度展开,每个部分都会给出具体的翻车案例和修复方案。
校验时机要分层
表单校验有三个天然触发点:onChange(每次输入)、onBlur(失去焦点)、onSubmit(提交时)。这三种时机各有适用场景,混用或选错时机是最常见的体验问题来源。
| 校验时机 | 触发频率 | 适用场景 | 典型风险 |
|---|---|---|---|
onChange | 每次按键 | 密码强度指示器、字符计数器 | 用户刚开始输入就报错,产生挫败感 |
onBlur | 离开字段时 | 格式校验(邮箱、手机号)、必填检查 | 几乎无风险,推荐作为默认时机 |
onSubmit | 点击提交时 | 服务端唯一性、权限、业务规则 | 用户填完所有字段后才发现前半部分有错 |
Nielsen Norman Group 的表单错误设计指南明确指出:错误提示应该尽可能靠近对应字段,并在用户有足够信息修正时才出现[1]。在用户刚输入第一个字符时就触发错误,属于时机过早;在所有字段填完后才一次性抛出十个错误,属于时机过晚。
我在实际项目里验证出来的经验是:
- 格式类校验(邮箱格式、手机号、URL、长度)用
onBlur,用户离开字段时立即反馈。 - 内容类校验(密码强度、用户名格式)用
onChange但只在字段已经被 touch 过之后才显示错误。 - 服务端校验(唯一性、权限、库存)用
onSubmit,或者用带防抖的异步校验。
React Hook Form 的 mode 参数提供了这些选项。推荐配置是 mode: "onTouched"(v7.43+ 新增),它在字段被触碰后切换到 onChange 模式,兼顾了实时性和体验[2]。
案例一:注册表单的「第一字符报错」
这是一个常见的反面模式。用户刚在邮箱字段输入第一个字母 a,下方就弹出「请输入有效的邮箱地址」。
// ❌ 坏做法:onChange 触发校验,每按一个键都报错
function EmailField() {
const [value, setValue] = useState('')
const [error, setError] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
// 每次按键都校验,第一个字符就报错
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.target.value)) {
setError('请输入有效的邮箱地址')
}
}
return (
<div>
<input value={value} onChange={handleChange} />
{error && <span role="alert">{error}</span>}
</div>
)
}// ✅ 好做法:onBlur 触发校验 + 已触碰后才显示
function EmailField() {
const [value, setValue] = useState('')
const [error, setError] = useState('')
const [touched, setTouched] = useState(false)
const validate = (v: string) => {
if (!v) return '邮箱不能为空'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return '请输入有效的邮箱地址,例如 [email protected]'
return ''
}
const handleBlur = () => {
setTouched(true)
setError(validate(value))
}
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
aria-invalid={touched && !!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{/* 只在触碰过且有错误时显示 */}
{touched && error && (
<span id="email-error" role="alert">{error}</span>
)}
</div>
)
}差异不只是校验时机。好做法里加了 aria-invalid 和 aria-describedby,屏幕阅读器可以正确关联错误信息和输入框;错误文案给出了具体示例 [email protected],用户知道该怎么改。
校验时机的分层策略
用一张表总结推荐的校验分层:
| 校验类型 | 推荐时机 | 是否立即显示 | 实现要点 |
|---|---|---|---|
| 必填检查 | onBlur | 触碰后显示 | touched 标记 |
| 格式检查(邮箱/手机) | onBlur | 触碰后显示 | 正则校验 + 示例文案 |
| 长度限制 | onChange | 触碰后显示 | 实时字符计数,不需要错误提示 |
| 密码强度 | onChange | 始终显示强度条 | 不用错误提示,用渐进式指示器 |
| 唯一性(用户名/邮箱) | 防抖异步 | 离开字段后 | debounce 300-500ms + loading 状态 |
| 业务规则(库存/权限) | onSubmit | 提交后 | 服务端返回后渲染 |
| 跨字段一致性 | onSubmit | 提交后 | 两次密码一致、日期范围合法 |
错误文案要可执行
「输入错误」是最没有用的错误提示。用户看到这四个字,唯一能获取的信息是「我刚才做错了什么」,但完全不知道错在哪里、怎么修正。
Smashing Magazine 关于无障碍表单验证的指南中引用了 WCAG 2.1 的 Success Criterion 3.3.1(Error Identification)和 3.3.3(Error Suggestion)[3]。前者要求错误必须被清晰识别和描述,后者要求当输入错误被自动检测时,建议必须提供给用户。也就是说,可执行的错误文案不只是 UX 偏好,而是无障碍合规要求。
| 错误文案类型 | 示例 | 问题 |
|---|---|---|
| 纯技术描述 | Validation failed: E001 | 用户不知道什么错了 |
| 模糊否定 | 输入错误 / 格式不对 | 用户不知道怎么改 |
| 可执行描述 | 密码至少 8 位,需包含大写字母和数字 | 用户知道具体要求和修正方式 |
| 带示例的描述 | 请输入有效的手机号,例如 138xxxx0000 | 用户有参照物 |
服务端返回的错误也需要映射。后端通常返回错误码或英文标识符(UserAlreadyExists、EmailAlreadyVerified),前端需要做一层映射表,转成用户能理解、知道怎么办的中文文案。
// ❌ 坏做法:直接展示后端原始错误
async function handleSubmit(data: FormData) {
try {
await api.submit(data)
} catch (err: any) {
setGlobalError(err.response.data.message)
// 可能显示: "UserAlreadyExists" 或 '{"code":409,"msg":"conflict"}'
}
}// ✅ 好做法:服务端错误码 → 用户可执行的文案
const ERROR_MESSAGE_MAP: Record<string, string> = {
UserAlreadyExists: '该邮箱已注册,是否直接登录?',
PasswordPolicyViolation: '密码至少 8 位,需包含大写字母和数字',
EmailFormatInvalid: '请输入有效的邮箱地址,例如 [email protected]',
AccountLocked: '账户已锁定,请 30 分钟后重试或联系客服',
Default: '提交失败,请稍后重试。如果问题持续,请联系客服',
}
function mapServerError(code: string): string {
return ERROR_MESSAGE_MAP[code] ?? ERROR_MESSAGE_MAP.Default
}
async function handleSubmit(data: FormData) {
try {
await api.submit(data)
} catch (err: any) {
const code = err.response?.data?.code ?? 'Default'
setGlobalError(mapServerError(code))
}
}服务端错误映射表应该集中维护。我在项目里通常放在 constants/error-messages.ts 或者 utils/error-map.ts 里,避免散落在各个组件中。如果后端新增了错误码但前端没有对应映射,会 fallback 到 Default 文案,至少不会出现裸技术错误。
异步校验需要防抖和竞态控制
用户名唯一性、邮箱是否已注册这类校验必须走服务端。用户每输入一个字符就发一次请求会造成大量无效请求,也容易触发后端的频率限制。防抖(debounce)是标准做法,但仅做防抖还不够——还需要处理竞态。
假设用户输入了 abc,防抖后发出请求 A;然后继续输入变成 abcd,发出请求 B。如果 B 先返回「可用」,A 后返回「已被占用」,用户会看到错误的结果。
// ❌ 坏做法:只有防抖,没有竞态控制
function useUsernameCheck() {
const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle')
const check = useCallback(
debounce(async (username: string) => {
setStatus('checking')
const res = await api.checkUsername(username)
// 如果请求返回顺序颠倒,这里会覆盖正确结果
setStatus(res.available ? 'available' : 'taken')
}, 500),
[]
)
return { status, check }
}// ✅ 好做法:防抖 + AbortController 取消过期请求
function useUsernameCheck() {
const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle')
const abortRef = useRef<AbortController | null>(null)
const check = useCallback(
debounce(async (username: string) => {
// 取消上一次未完成的请求
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setStatus('checking')
try {
const res = await api.checkUsername(username, { signal: controller.signal })
// 只有当前请求没被取消时才更新状态
if (!controller.signal.aborted) {
setStatus(res.available ? 'available' : 'taken')
}
} catch (err) {
if (!controller.signal.aborted) {
setStatus('idle')
}
}
}, 500),
[]
)
return { status, check }
}React Hook Form 也支持异步校验,可以直接在 Zod schema 里用 refine 配合 async 函数[4]。但在 mode: "onChange" 下仍然需要自己加防抖。TanStack Form 的 GitHub discussion 里有一个被广泛引用的方案:onChange 时静默执行校验,但只在字段 blurred 之后才把错误显示出来[5]。这样即使用户输入过程中异步校验在后台跑,也不会在用户打字时弹出干扰性的错误提示。
异步校验的状态机可以这样表示:
| 异步校验要素 | 说明 |
|---|---|
| 防抖间隔 | 300-500ms,太短起不到减少请求的效果,太长用户等待感明显 |
| 竞态控制 | AbortController 或请求版本号,确保只展示最新结果 |
| Loading 状态 | 校验中显示 spinner 或「检查中...」文案,让用户知道在干什么 |
| 失败降级 | 网络失败时不清空校验结果,显示可重试的提示而非错误 |
| 缓存 | 同一用户名短时间内不重复请求,用 Map 或 WeakMap 做简单缓存 |
保留用户劳动
提交失败后,表单内容不应该丢失。这听起来是显而易见的道理,但在实际项目中,因为 router.push() 跳转、状态重置、或者组件卸载导致表单数据丢失的情况非常常见。
数据保护至少要做到三个层次:
| 保护层次 | 触发时机 | 实现方式 | 适用场景 |
|---|---|---|---|
| 提交失败保留 | 服务端返回错误后 | 不重置 form state,保持用户输入 | 所有表单 |
| 意外离开提醒 | 用户点击导航/关闭页面 | beforeunload + 路由拦截 | 长表单、多步骤表单 |
| 草稿自动保存 | 定时或内容变化时 | localStorage / sessionStorage | 长表单、内容创作 |
第一个层次是底线。提交失败后,表单的值必须保持原样。在 React Hook Form 里,这意味着提交失败的 handler 里不要调 reset()。
// ❌ 坏做法:提交失败后重置表单
async function onSubmit(data: FormData) {
try {
await api.submit(data)
router.push('/success')
} catch {
reset() // 用户填的所有内容全部丢失
setError('root', { message: '提交失败,请重试' })
}
}// ✅ 好做法:失败时保留表单值 + 定向错误
async function onSubmit(data: FormData) {
try {
await api.submit(data)
// 成功后才清除草稿
localStorage.removeItem(DRAFT_KEY)
router.push('/success')
} catch (err: any) {
const code = err.response?.data?.code
// 如果是字段级错误,定位到具体字段
if (code === 'EmailAlreadyUsed') {
setError('email', { message: '该邮箱已注册,是否直接登录?' })
} else {
setError('root', { message: mapServerError(code) })
}
// 不调用 reset(),用户输入的内容保留
}
}第二个层次是离开提醒。用户在填了 15 分钟的长表单之后不小心点了浏览器后退,如果没有任何提示,体验是灾难性的。beforeunload 事件可以拦截页面关闭/刷新,路由跳转则需要在前端路由层做拦截。
第三个层次是草稿保存。对于耗时较长的表单(比如内容发布、信息填报),用 localStorage 或 sessionStorage 定时保存草稿,用户再次打开时自动恢复。两者的区别在于:localStorage 跨会话持久保存,sessionStorage 在关闭标签页后自动清除[6]。草稿通常用 sessionStorage 更合适——用户关闭页面意味着放弃当前编辑,不需要再恢复。
// 草稿自动保存 hook
function useFormDraft<T extends Record<string, unknown>>(key: string, defaultValues: T) {
const storageKey = `draft:${key}`
// 初始化时尝试恢复草稿
const getInitialValues = (): T => {
try {
const saved = sessionStorage.getItem(storageKey)
return saved ? { ...defaultValues, ...JSON.parse(saved) } : defaultValues
} catch {
return defaultValues
}
}
const form = useForm<T>({ defaultValues: getInitialValues() })
// 监听值变化,自动保存草稿
useEffect(() => {
const subscription = form.watch((values) => {
sessionStorage.setItem(storageKey, JSON.stringify(values))
})
return () => subscription.unsubscribe()
}, [form.watch])
const submitAndClear = async (onSubmit: (data: T) => Promise<void>) => {
await onSubmit(form.getValues())
sessionStorage.removeItem(storageKey) // 成功后清除草稿
}
return { ...form, submitAndClear }
}可访问性不是加分项
表单的可访问性(a11y)经常被排在功能需求之后,但它的缺失直接影响一部分用户的可用性。W3C 的 WCAG 2.2 技术规范里,ARIA21 定义了使用 aria-invalid 和 aria-describedby 组合来标记错误字段的标准做法[7]。
核心要求有三条:
- 错误字段标记:用
aria-invalid="true"标记有错误的输入框,屏幕阅读器会朗读「无效」状态。 - 错误关联:用
aria-describedby指向错误消息的id,屏幕阅读器在聚焦输入框时会朗读错误内容。 - 实时播报:对动态出现的错误消息区域使用
role="alert"或aria-live="polite",让屏幕阅读器主动播报。
| ARIA 属性 | 作用 | 挂载位置 | 示例 |
|---|---|---|---|
aria-invalid | 标记字段当前有错误 | <input> 元素 | aria-invalid={!!error} |
aria-describedby | 关联输入框与错误消息 | <input> 元素 | aria-describedby="email-error" |
aria-errormessage | 显式指定错误消息元素 | <input> 元素 | aria-errormessage="email-error" |
role="alert" | 实时播报错误出现 | 错误消息容器 | <span role="alert">...</span> |
aria-live="polite" | 等待当前朗读完毕再播报 | 错误消息容器 | 比 role="alert" 更不打断 |
// ❌ 坏做法:没有 ARIA 属性,屏幕阅读器无法感知错误
<div className="field">
<label>邮箱</label>
<input value={value} onChange={handleChange} />
{error && <span className="error-text">{error}</span>}
</div>// ✅ 好做法:完整的 ARIA 关联
<div className="field">
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
value={value}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={touched && !!error}
aria-describedby={touched && error ? 'email-error' : undefined}
/>
{touched && error && (
<span id="email-error" role="alert">
{error}
</span>
)}
</div>React Hook Form 配合 react-hook-form-aria 或者直接使用 Controller 组件时,可以自动管理这些属性。但即使不用库,手动加上去也不复杂——关键是 aria-invalid + aria-describedby + role="alert" 这三件套。
还有一个容易被忽略的点:不要禁用提交按钮。Smashing Magazine 的无障碍表单验证指南明确建议,让用户在任何时候都能点击提交,通过错误消息来引导修正[3]。禁用按钮对键盘用户和屏幕阅读器用户来说是一个不可理解的死胡同。
一个完整的校验流程
把上面这些点串起来,一个完整的表单提交流程应该是这样的:
这个流程里有几个关键决策点:
- 前端先做全量校验,不是为了替代服务端校验,而是拦截明显的格式和必填错误,减少无效请求。
- 失败后聚焦第一个错误字段,这是 NNG 推荐的「让用户最少操作就能开始修正」原则[1]。
- 区分字段级错误和全局错误,字段级错误(某个邮箱已被注册)定位到输入框,全局错误(服务暂时不可用)显示在页面顶部。
- 保留表单值,用户只需要修改有问题的字段,而不是重新填写整个表单。
上线前检查清单
以下是我在项目里实际用的表单反馈检查清单,按阶段分组。每次新表单上线或者旧表单改造,我会逐条过一遍。
校验时机
- 格式校验(邮箱/手机/URL)使用
onBlur触发,不在用户输入过程中报错 - 必填校验在字段触碰过(touched)之后才显示错误
- 密码强度等渐进式反馈使用
onChange,但用指示器而非错误文案 - 异步校验(唯一性等)加了 300-500ms 防抖
- 异步校验有竞态控制(AbortController 或请求版本号)
错误文案
- 每条错误文案说明了「哪里错了」和「怎么修正」
- 错误文案包含具体示例(如
例如 138xxxx0000) - 服务端错误码有前端映射表,不会直接暴露技术标识符
- 未知错误有兜底文案,不会出现空白或 JSON 原文
数据保护
- 提交失败后表单值保留,不调用
reset() - 长表单有离开提醒(
beforeunload+ 路由拦截) - 长表单有草稿自动保存(
sessionStorage或localStorage) - 提交成功后清除草稿
可访问性
- 错误字段有
aria-invalid="true" - 错误消息通过
aria-describedby关联到输入框 - 动态错误消息有
role="alert"或aria-live="polite" - 提交按钮未被禁用,用户任何时候都能提交触发校验
- 键盘用户可以正常操作所有表单元素
视觉反馈
- 错误状态有明显的视觉区分(不只是颜色变化,还有图标或边框样式)
- 异步校验中有 loading 指示器
- 提交中有 loading 状态,防止重复提交
- 提交成功后有明确的成功反馈
参考资料
[1] Nielsen Norman Group. 10 Design Guidelines for Reporting Errors in Forms. https://www.nngroup.com/articles/errors-forms-design-guidelines/
[2] React Hook Form. useForm - mode. https://react-hook-form.com/api/useform/
[3] Carie Fisher. A Guide To Accessible Form Validation. Smashing Magazine, 2023. https://www.smashingmagazine.com/2023/02/guide-accessible-form-validation/
[4] Contentful. Learn Zod Validation with React Hook Form. 2025. https://www.contentful.com/blog/react-hook-form-validation-zod/
[5] TanStack Form. How to use onChange and onBlur at the same time per field. GitHub Discussion #1784. https://github.com/TanStack/form/discussions/1784
[6] OpenReplay. How to Persist Form State in the Browser. 2026. https://blog.openreplay.com/persist-form-state-browser/
[7] W3C. ARIA21: Using aria-invalid to Indicate An Error Field. WCAG 2.2 Techniques. https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21
[8] TetraLogical. Foundations: Form Validation and Error Messages. 2024. https://tetralogical.com/blog/2024/10/21/foundations-form-validation-and-error-messages/