复杂前端表单设计:不要把所有字段都塞进一个 useState
表单是前端里最容易被低估的模块。
登录表单、搜索表单看起来很简单,几个输入框、一个提交按钮就够了。但一旦进入真实业务,表单会迅速变复杂:几十个字段、动态增删行、字段联动、远程校验、草稿保存、权限控制、提交失败回填、离开页面提醒。最后很多页面不是被 UI 难倒,而是被表单状态难倒。
我见过最典型的写法,是一个页面顶层维护一个巨大的 form:
1const [form, setForm] = useState({ 2 name: '', 3 type: '', 4 rules: [], 5 owner: '', 6 enabled: true, 7})
刚开始很顺手,字段越来越多以后就开始难受。每个输入框都要手写 setForm,每次更新都创建大对象,校验散在各个事件里,联动逻辑互相覆盖,最后谁也不敢改。
我后来对复杂表单有个判断:表单不是一个对象,而是一套状态系统。
先区分字段值和表单状态
很多问题来自把所有东西都塞进 form。
一个成熟表单里至少有几类状态:
- value:字段当前值
- defaultValue:初始值,用来判断是否修改
- touched:用户是否操作过
- dirty:当前值是否不同于初始值
- error:字段校验错误
- validating:是否正在远程校验
- submitting:是否正在提交
- submitError:提交接口返回的业务错误
如果只用一个 form 对象存 value,其他状态就会到处乱放。
比如一个用户名字段,除了值本身,还要知道用户是否输入过、是否正在检查重复、检查失败的信息是什么。把这些都混进业务值里,会污染提交数据。
1// 不建议 2{ 3 username: 'alice', 4 usernameTouched: true, 5 usernameChecking: false, 6 usernameError: '用户名已存在' 7}
更清楚的模型是把字段状态和提交数据分开:
1type FieldState<T> = { 2 value: T 3 defaultValue: T 4 touched: boolean 5 error: string | null 6 validating: boolean 7}
业务提交时只取 value,界面展示时再读 error、touched、validating。这能让表单逻辑更可控。
受控和非受控不是谁淘汰谁
React 表单经常被讲成“受控组件才是正统”,但复杂表单里我反而更关心成本。
受控组件的优点是状态完全在 React 里,联动、校验、禁用、格式化都容易接入。缺点是每次输入都会触发 React 更新,字段多时容易有性能压力。
非受控组件的优点是输入过程更轻,DOM 自己维护当前值,提交或校验时再读取。缺点是联动复杂时不如受控直观。
所以选择不是教条,而是看字段类型:
- 强联动字段:适合受控
- 普通文本输入:可以非受控或字段级订阅
- 富文本、上传、复杂选择器:通常需要单独封装适配层
- 大型动态表格:不要让每个单元格输入都刷新整页
这也是为什么 react-hook-form 这类库很受欢迎。它不是简单“少写代码”,而是用非受控和订阅机制降低大表单的更新成本。字段变化时,只通知真正关心这个字段的组件,而不是让整个表单树重渲染。
如果项目里已经用了表单库,尽量顺着它的模型走。最怕的是一半字段交给表单库,一半字段自己 useState,最后提交时再手动拼对象。这种混合状态很容易出现“页面显示的是 A,提交出去的是 B”。
校验要分层
表单校验不要全写在提交按钮里。
我一般把校验分成三层:
1字段级校验:必填、长度、格式、数值范围 2表单级校验:开始时间不能晚于结束时间,至少选择一项 3服务端校验:唯一性、权限、业务规则、并发冲突
字段级校验适合在输入或失焦时触发:
1function validateName(value: string) { 2 if (!value.trim()) return '名称不能为空' 3 if (value.length > 40) return '名称不能超过 40 个字符' 4 return null 5}
表单级校验适合在提交前统一跑:
1function validateForm(values) { 2 const errors = {} 3 4 if (values.startAt && values.endAt && values.startAt > values.endAt) { 5 errors.endAt = '结束时间不能早于开始时间' 6 } 7 8 if (values.rules.length === 0) { 9 errors.rules = '至少配置一条规则' 10 } 11 12 return errors 13}
服务端校验不能省。前端校验只能提升体验,不能保证正确性。比如名称唯一、库存状态、权限范围,都必须以后端返回为准。
API 错误也要认真处理。提交失败时不要只弹一个“保存失败”,而应该区分错误类型:
1async function submit(values) { 2 try { 3 await api.save(values) 4 toast.success('保存成功') 5 } catch (error) { 6 if (error.code === 'VALIDATION_ERROR') { 7 setFieldErrors(error.fields) 8 return 9 } 10 11 if (error.code === 'CONFLICT') { 12 setSubmitError('数据已被其他人修改,请刷新后重试') 13 return 14 } 15 16 setSubmitError('保存失败,请稍后重试') 17 } 18}
这类错误处理很重要。用户填了十分钟的表单,如果接口失败后只丢一句通用错误,他不知道该改哪里,也不知道数据有没有保存。
远程校验要处理竞态
用户名查重、编码查重、地址识别,这些都属于远程校验。
最常见的坑是竞态:用户先输入 abc,请求 A 发出去;马上又输入 abcd,请求 B 发出去。如果 A 比 B 晚回来,旧结果就可能覆盖新结果。
处理方式有两种。
第一种是用递增版本号:
1let validateSeq = 0 2 3async function validateCode(code: string) { 4 const seq = ++validateSeq 5 setValidating(true) 6 7 try { 8 const result = await api.checkCode(code) 9 if (seq !== validateSeq) return 10 11 setError(result.exists ? '编码已存在' : null) 12 } catch { 13 if (seq !== validateSeq) return 14 setError('校验失败,请稍后重试') 15 } finally { 16 if (seq === validateSeq) { 17 setValidating(false) 18 } 19 } 20}
第二种是用 AbortController 取消上一次请求:
1let controller: AbortController | null = null 2 3async function validateCode(code: string) { 4 controller?.abort() 5 controller = new AbortController() 6 7 try { 8 const result = await fetch(`/api/check-code?code=${code}`, { 9 signal: controller.signal, 10 }).then((res) => res.json()) 11 12 setError(result.exists ? '编码已存在' : null) 13 } catch (error) { 14 if (error.name === 'AbortError') return 15 setError('校验失败,请稍后重试') 16 } 17}
无论哪种,核心都是同一个:旧请求不能覆盖新输入。
另外,远程校验不要每个按键都立刻请求。一般要加 debounce,并且只在基础格式校验通过后才触发。一个明显不合法的值,没有必要打到服务端。
联动逻辑不要散落在 onChange 里
复杂表单里最容易失控的是字段联动。
比如选择“企业用户”后显示公司名称,选择“个人用户”后清空税号;选择某个地区后刷新下级地区;切换套餐后重置价格策略。
如果这些逻辑散在各个字段的 onChange 里,后面会非常难维护。
我更喜欢把联动规则集中描述:
1function applyFormRules(values, changedKey) { 2 const next = { ...values } 3 4 if (changedKey === 'userType' && next.userType === 'personal') { 5 next.companyName = '' 6 next.taxNo = '' 7 } 8 9 if (changedKey === 'plan') { 10 next.priceRules = getDefaultPriceRules(next.plan) 11 } 12 13 return next 14}
字段更新时统一走一条通道:
1function updateField(key, value) { 2 setValues((prev) => { 3 const changed = { ...prev, [key]: value } 4 return applyFormRules(changed, key) 5 }) 6}
这样联动逻辑至少有一个固定入口。以后出现“为什么这个字段被清空了”,不用全项目搜 setForm。
更复杂的场景可以把联动规则做成配置,但不要太早抽象。规则数量少时,普通函数最清楚。
提交前要处理脏状态
复杂表单通常需要离开页面提醒、保存按钮禁用、草稿保存。这些都依赖脏状态。
脏状态不要靠“用户点过输入框”判断,而应该比较当前值和初始值。
1function isDirty(values, initialValues) { 2 return JSON.stringify(values) !== JSON.stringify(initialValues) 3}
这只是演示。真实项目里要注意字段顺序、日期对象、文件对象、后端默认值等问题,最好使用稳定的深比较工具,或者让表单库提供 dirty 状态。
还有一个细节:保存成功后,要把当前值更新为新的初始值。
1await submit(values) 2setInitialValues(values)
否则用户明明已经保存成功,页面还会提示“有未保存修改”。
回到最初那个 useState
回头看开头那个把几十个字段塞进一个 useState 的写法,问题不在于它简陋,而在于它把字段值、校验状态、提交状态全焊在了一起,谁都不敢单独动。拆开之后,FieldState 管值和校验,applyFormRules 管联动,isDirty 管脏检查,提交失败时错误能精确回填到某个字段——每一层各管一件事,改动一处不会牵连另一处。
这套表单后来又加了十几个字段、两轮联动规则,改起来没再出现过“为什么这个字段被清空了”这种全项目搜索的场面。原来那个巨大的 form 对象,现在只剩下 value 还留在里面,其余的状态各自待在该待的地方。