复杂前端表单设计:不要把所有字段都塞进一个 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,界面展示时再读 errortouchedvalidating。这能让表单逻辑更可控。

受控和非受控不是谁淘汰谁

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 还留在里面,其余的状态各自待在该待的地方。