Notes

表单架构:React Hook Form 的受控/非受控、校验、性能与可测试性(架构师视角)

一句话结论:把表单当成“高频 UI state + 低频提交事件”的系统来设计——用 React Hook Form(默认非受控)把输入期的渲染成本压到最低,同时用分层的校验策略与**清晰的状态边界(UI / Form / Server / URL)**保证一致性、可测试性与可访问性。

0. 为什么表单会成为架构问题?

表单一旦变大(字段多、联动多、校验复杂、可复用组件多),常见症状是:

  • 输入卡顿:每个字符导致大面积 re-render
  • 校验混乱:前端规则、后端规则、异步校验各写一套
  • 状态打架:表单值、URL 条件、列表缓存彼此不同步
  • 可测性差:只能 E2E 点点点,边界很难覆盖
  • a11y 缺失:错误不可读、焦点不可预期

这篇的目标不是教 API,而是给你一套“默认推荐”的工程语义:

  • 输入期:性能优先(少渲染)
  • 提交期:一致性优先(与服务端/缓存对齐)
  • 展示期:可访问性优先(语义清晰、错误可被理解)

1. 受控 vs 非受控:source of truth 在哪?

1.1 受控(Controlled)输入

  • 真相在 React state
  • 每次 onChangesetState → re-render

优点:

  • 联动和派生 UI 容易做
  • 值完全可控

代价:

  • 大表单很容易把每个字符变成“全树更新”

1.2 非受控(Uncontrolled)输入

  • 真相在 DOM input value(通过 ref 读取/订阅)
  • 输入本身不必触发组件重渲染

优点:

  • 性能友好(尤其是大量字段)

代价:

  • 需要更明确“何时读取值、何时提交值”

1.3 React Hook Form 的选择:默认非受控 + 精准订阅

React Hook Form(RHF)默认走非受控路径:

  • register:把 input 接入表单系统
  • formState:包含 errors/dirty/isSubmitting 等,但不要全量订阅
  • useFormState / useWatch:做“局部订阅”,避免无谓渲染

经验:表单性能的关键不是“用不用 RHF”,而是你有没有把订阅面控制住。

1.4 何时用 Controller

当你接入的组件天然是受控组件(很多 UI 库都是),才用 Controller 做桥接。

工程约束:

  • 只在必要字段用 Controller
  • 昂贵的组件隔离渲染边界(拆分字段组件 + memo + 局部订阅)

2. 表单的状态分层:UI / Form / Server / URL 怎么切?

把表单放回“状态分层”的大系统里:

  • UI State(交互体验)
    • 展开/收起、step、局部提示、弹窗开关
  • Form State(草稿)(RHF 管理)
    • values / touched / dirty / errors / isSubmitting
  • Server State(服务端数据与一致性)
    • 提交成功后的实体、列表/详情缓存(TanStack Query 或 Next 缓存)
  • URL State(可分享/可回放的条件)
    • 筛选/分页/排序等

关键约束:

  1. 表单草稿(Form state)不等于服务端真相(Server state)
  2. 筛选表单的“条件真相”应该在 URL(否则 back/forward 不可回放)

3. 校验策略:输入期提示 vs 提交期一致性

3.1 推荐的三层校验

  1. 输入期(局部、轻量)
  • required / minLength / pattern 等
  • 触发策略:优先 onBlur,不要每个字符都同步报错
  1. 提交期(全量、可复用)
  • 用 schema(Zod/Yup/Valibot)+ resolver 统一表达
  • 统一错误文案与 i18n
  1. 服务端校验(最终权威)
  • HTTP 400/422 字段错误映射到 RHF:setError('field', { message })
  • 表单级错误(难定位字段) → error summary

经验:前端校验解决“及时反馈与减少无效提交”,后端校验解决“最终正确性与安全边界”。

3.2 异步校验(Async validation)

典型:用户名是否可用、优惠码校验。

工程约束:

  • 防抖/取消(避免请求风暴)
  • 不要让异步校验破坏输入流畅(必要时配合 Transition 或延后到 blur)

4. 性能:让表单成为“高频交互但低成本渲染”的系统

4.1 性能目标(你要能验证)

  • 输入不掉帧(INP 不被表单拖垮)
  • 联动不卡顿
  • 提交按钮状态稳定(避免抖动/重复提交)

开发期验证手段:

  • React Profiler 看 re-render 范围
  • 浏览器 Performance 看长任务

4.2 RHF 的关键性能策略

  • 减少订阅面
    • 不要在顶层组件全量读取 formState
    • 使用 useFormState({ control, name }) / useWatch({ name }) 做局部订阅
  • 隔离昂贵字段
    • 一个字段一个组件(Field Component)
    • memo + 局部订阅
  • FieldArray(动态字段)
    • 大量动态行时考虑分页/虚拟列表
  • 初始化/重置策略
    • 初始值来自服务端时,避免每次 render 都 reset
    • 建议:只在数据 ready 时执行一次 reset(defaultValues)

5. 可测试性:让表单能被“业务语义”测试

5.1 测试分层(建议)

  • 单元测试:schema/格式化/映射函数(纯函数)
  • 组件集成测试:React Testing Library + user-event
  • E2E:关键路径(注册/支付/创建工单)

5.2 可测试性的架构要点

  • 提交副作用抽成可注入依赖(API client / mutationFn)
  • 校验逻辑集中在 schema(不要散落 JSX)
  • 测试尽量用语义化查询(role/label/name),间接提升可访问性

6. 与 Server State(TanStack Query)协作:提交、失效与错误映射

6.1 表单提交的定位

  • 表单草稿不是 Server State
  • 提交成功后的实体更新、列表刷新才进入 Server State

6.2 常见集成模式

  • useMutation 提交
  • 成功后:
    • 默认策略:invalidateQueries(正确性高)
    • 进阶策略:乐观更新(见下一篇)

6.3 把服务端字段错误映射回表单

  • 服务端返回 422(字段校验失败)
  • 前端将错误映射到字段:
    • setError('email', { message: '邮箱已存在' })
  • 同时提供表单级错误 summary,避免用户找不到问题

7. 与 URL State 协作:筛选表单、可分享链接与回退回放

筛选表单是最容易“状态打架”的地方。

7.1 推荐模式:URL 是条件真相,表单是编辑器

  • URL → 解析为默认值(初始化/回放)
  • 用户编辑 → 暂存在 RHF(草稿)
  • 用户点击“应用筛选” → 写回 URL(replace/push 视业务)
  • URL 变化 → 派生 queryKey → 列表数据刷新/命中缓存

7.2 常见坑

  • 双向实时同步导致循环:watch → 写 URL → 触发 reset → …
  • 每个字符都写 URL:back/forward 变成“逐字符回放”灾难

8. 可访问性(A11y):错误与焦点必须可预期

  • label/htmlFor 与输入关联(或 aria-label)
  • 错误呈现:
    • aria-invalid
    • aria-describedby 指向错误文本
  • 提交失败:
    • 提供 error summary
    • 焦点移动到 summary 或第一个错误字段

经验:可访问性做得好,用户体验和可测试性也会更好。

9. Checklist

  • 是否避免“全表单受控化”?只在必要处用 Controller
  • 校验是否分层(输入期轻量、提交期 schema、后端最终权威)?
  • 是否避免全量 watch() / 全量订阅 formState
  • 提交成功后是否有明确的 invalidate/refetch 策略(粒度合适)?
  • 筛选条件是否以 URL 为真相?写 URL 的时机是否合理(提交/防抖)?
  • 错误信息是否能被读屏读到?提交失败后焦点是否可预期?

关联阅读

cd ..