表单架构: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
- 每次
onChange→setState→ 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(可分享/可回放的条件)
- 筛选/分页/排序等
关键约束:
- 表单草稿(Form state)不等于服务端真相(Server state)
- 筛选表单的“条件真相”应该在 URL(否则 back/forward 不可回放)
3. 校验策略:输入期提示 vs 提交期一致性
3.1 推荐的三层校验
- 输入期(局部、轻量)
- required / minLength / pattern 等
- 触发策略:优先
onBlur,不要每个字符都同步报错
- 提交期(全量、可复用)
- 用 schema(Zod/Yup/Valibot)+ resolver 统一表达
- 统一错误文案与 i18n
- 服务端校验(最终权威)
- 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)
- 初始值来自服务端时,避免每次 render 都
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-invalidaria-describedby指向错误文本
- 提交失败:
- 提供 error summary
- 焦点移动到 summary 或第一个错误字段
经验:可访问性做得好,用户体验和可测试性也会更好。
9. Checklist
- 是否避免“全表单受控化”?只在必要处用
Controller? - 校验是否分层(输入期轻量、提交期 schema、后端最终权威)?
- 是否避免全量
watch()/ 全量订阅formState? - 提交成功后是否有明确的 invalidate/refetch 策略(粒度合适)?
- 筛选条件是否以 URL 为真相?写 URL 的时机是否合理(提交/防抖)?
- 错误信息是否能被读屏读到?提交失败后焦点是否可预期?
关联阅读
- 状态分层(表单在大系统里的定位):
./01-state-layering-ui-server-url-global.md - Query 缓存与失效(提交后如何刷新):
./02-tanstack-query-caching-and-invalidation.md - 乐观更新与一致性(下一篇/配套):
./03-optimistic-updates-and-consistency.md - URL State 心智模型(筛选表单进 URL):
../10-routing-and-navigation/01-routing-and-url-state-mental-model.md - Transition(输入流畅 vs 结果区更新):
../09-react-18-in-practice/03-transition-useTransition-useDeferredValue.md