《TypeScript编程实战》13.1 React Hook Form + Zod

受控表单在大表单下会因每次按键重渲染整棵树而变慢。本节从这一性能问题切入,讲清 React Hook Form 的非受控与订阅式模型为何更快,再用 zodResolver 把 Zod schema 变成表单唯一的规则与类型来源,让字段名、错误结构与提交类型自动对齐。你会掌握 register、handleSubmit、formState 的用法与 Controller 的接入时机。

本节目标:理解 React Hook Form 如何减少大表单的更新范围,掌握 register / handleSubmit / formState 的标准用法,并学会用 zodResolver 把 Zod schema 变成表单唯一的规则与类型来源。读完后你能独立搭出一个字段级订阅、类型自动对齐、错误结构统一的表单层。

13.1 React Hook Form + Zod

表单是前端里最容易写、也最容易写坏的组件。它同时牵扯三件事:状态(用户输入了什么)、规则(什么算合法)、类型(代码里怎么描述这份数据)。多数项目的做法是把三件事各写一遍,于是「邮箱必填且格式正确」这个约束在 JSX 里写一遍、在提交函数里写一遍、在 TypeScript 类型里再写一遍——三份副本,改动时必然漏掉一处。

这一节把三件事收敛到一份 Zod schema 上,schema 沿用 zod@3.25.76;RHF 与 resolver 须选支持该版本的组合,并用锁文件固定。性能收益需用实际表单测量,下面的重渲染次数仅用于说明模型。

13.1.1 受控表单的性能天花板

先从最朴素的写法看起:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  return (
    <form>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <input value={password} onChange={(e) => setPassword(e.target.value)} />
    </form>
  );
}

value + onChange 就是所谓「受控组件」:React 是唯一数据源,每次按键都要 setState。这段代码在 2 个字段时毫无问题,到 50 个字段时就成了性能事故——因为状态提在 LoginForm 上,任何一个字段变化都会重渲染整个表单树。用户敲一个字符,50 个 <input> 全部重新渲染,输入延迟肉眼可见。

常见的补救是拆组件、上 memo、把 state 下移。这些手段有效,但它们是在跟 React 的渲染模型对抗:输入框的「值」本质上属于 DOM 自己的状态,硬把它搬到 React 里再同步回去,等于绕了一大圈。

13.1.2 非受控 + 订阅:RHF 的模型

React Hook Form(下称 RHF)走的是另一条路:输入框保持非受控,值留在 DOM 里;RHF 只通过 ref 读值,并让组件按字段名订阅变化。

维度受控(useState)非受控(RHF)
数据源React stateDOM 自身
按键触发的重渲染整个表单组件仅订阅该字段的组件
取值时机每次 onChange提交 / 校验时按需读
初始值value={x}defaultValues
重渲染次数(50 字段)每次按键 50 个每次按键 0~1 个

关键在于 formState 是订阅式的:只有当你解构出 formState.errors,组件才订阅错误变化;解构出 formState.isSubmitting,才订阅提交态。RHF 内部用一个 Proxy 记录你读了哪些字段,从而把重渲染范围压到最小。

这也是 RHF 文档反复强调的一条纪律:只解构你需要的那几个字段。

13.1.3 三个核心 API 的职责

RHF 的 API 面很窄,日常只需要三个:

API作用返回
register(name, options?)把输入框登记到表单{ name, onChange, onBlur, ref }
handleSubmit(onValid, onInvalid?)包一层提交入口,先校验后回调(e) => Promise<void>
formState订阅表单状态errors / isSubmitting / isDirty / touchedFields

register 的 options 里几个值得记的:

register('age', { valueAsNumber: true });        // 直接把 value 转成 number
register('items', { deps: ['type'] });           // 依赖字段变化时联动重新校验
register('tmp', { shouldUnregister: true });     // 组件卸载时从表单值里移除

接了 Zod resolver 之后,options 里的 required / min / max 只用于浏览器原生提示,真正的规则以 schema 为准。两处都写会出现「schema 说合法、浏览器说非法」的错位,所以约定是:规则只写在 schema 里。

13.1.4 最小可用示例

先装上依赖:

pnpm add react-hook-form zod @hookform/resolvers

一个完整可跑的表单:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { schema, type FormValues } from './schema';
export function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { nickname: '', email: '' },
  });
  const onSubmit = async (values: FormValues) => {
    await api.save(values);
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input {...register('nickname')} aria-invalid={!!errors.nickname} />
      {errors.nickname && <p role="alert">{errors.nickname.message}</p>}
      <input {...register('email')} aria-invalid={!!errors.email} />
      {errors.email && <p role="alert">{errors.email.message}</p>}
      <button disabled={isSubmitting}>保存</button>
    </form>
  );
}

register('email') 返回的四个属性展开到 <input> 上即完成绑定。注意 noValidate:原生校验气泡会抢在校验器之前拦下提交,导致你永远看不到自己的错误文案。aria-invalid 与 role="alert" 是顺手就能补上的无障碍细节,屏幕阅读器会读出错误。

13.1.5 用 Zod 描述规则

Zod 是运行时的 schema 库,它最值钱的地方是类型可以从 schema 推导出来,不用手写两遍。

import { z } from 'zod';
export const schema = z.object({
  nickname: z.string().min(2, '昵称至少 2 个字符').max(20, '昵称最多 20 个字符'),
  email: z.string().email('邮箱格式不正确'),
  age: z.coerce.number().int('年龄必须是整数').min(18, '需年满 18 岁'),
  role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
});
export type FormValues = z.infer<typeof schema>;

z.infer<typeof schema> 展开后是:

type FormValues = {
  nickname: string;
  email: string;
  age: number;
  role: 'admin' | 'editor' | 'viewer';
};

类型不是「另写的」,是从 schema 算出来的。改了 max(20) 为 max(30),类型不动(都是 string),但运行时行为自动跟上;把 role 从 z.enum 改成 z.string(),类型会立刻变宽,所有 switch 分支的 never 兜底会报错——编译器会替你找到该改的地方。

13.1.6 zodResolver 把两者焊在一起

resolver 是 RHF 与校验库之间的适配层。zodResolver(schema) 做的事很直白:

  1. 提交或触发校验时,把当前表单值交给 schema.safeParse;
  2. 解析失败时,把 ZodError.issues 转成 RHF 的 FieldErrors,按 path 挂到对应字段;
  3. 解析成功时,把 parse 的输出值(已转换、已填默认值)传给 onSubmit。

第 3 点容易被忽略:onSubmit 拿到的是 schema 输出,不是原始输入。所以 age 字段即使输入框给的是字符串 "20",因为用了 z.coerce.number(),到 onSubmit 里已经是 number 20;role 没填时也会被 .default('viewer') 补上。

没有 resolver 时,RHF 只能做内置的 required / min / max / pattern 这类规则,表达力弱且要另写类型。接了 Zod 之后,规则就是类型,类型就是规则。

13.1.7 校验时机 mode

默认情况下 RHF 在提交时才校验。可以按体验需求调整:

mode触发时机适合
onSubmit仅提交时(默认)字段多、讨厌打扰用户
onBlur失焦时大多数业务表单,推荐
onChange每次输入实时搜索、即时反馈
onTouched首次失焦后转为 onChange想「先松后紧」的体验
allblur + change 都触发需要最及时的反馈
useForm<FormValues>({
  resolver: zodResolver(schema),
  mode: 'onBlur',
  reValidateMode: 'onChange', // 出错后改为输入即校验,让错误尽快消失
});

reValidateMode 常被忽略:设成 onChange 后,字段一旦报错,用户边改错误边消失,比等到再次失焦才更新要舒服得多。

13.1.8 读值:watch 与 useWatch

想在渲染中读某个字段的值,有两个 API,差别在订阅范围:

// 一、watch:在表单组件内部调用,会让整个表单组件重渲染
const email = watch('email');
// 二、useWatch:独立 hook,把重渲染限制在调用它的子组件里
const email = useWatch({ control, name: 'email' });

watch 的代价是它订阅在调用它的那个组件上——如果 Form 顶层调了 watch('email'),每次邮箱输入都会重渲染整个表单,等于把 RHF 的性能优势还了回去。正确的做法是把依赖某字段的 UI 抽成子组件,在里面用 useWatch:

function RolePreview({ control }: { control: Control<FormValues> }) {
  const role = useWatch({ control, name: 'role' });
  return <span>当前角色:{role}</span>;
}

同一条纪律的另一种表述:watch 只在你确实需要整表联动时用,其余场景一律 useWatch 并下沉组件。顺带一提,useWatch 默认只返回值,要拿它的校验状态得用 formState。

13.1.9 接入第三方受控组件

RHF 的非受控模型遇到「只暴露 value / onChange 的受控组件」时会失效——比如大多数 UI 库的 Select、日期选择器。这时用 Controller 把它桥回来:

import { Controller, useForm } from 'react-hook-form';
function Form() {
  const { control, handleSubmit } = useForm<FormValues>({ resolver: zodResolver(schema) });
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="role"
        control={control}
        render={({ field, fieldState }) => (
          <>
            <Select value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
            {fieldState.error && <span>{fieldState.error.message}</span>}
          </>
        )}
      />
    </form>
  );
}

field 提供 value / onChange / onBlur / ref,fieldState 提供该字段的 error / isDirty / isTouched。把 field 原样透传给组件即可,不要自己拼装 onChange 逻辑,否则脏值追踪会失准。

原则很简单:原生 <input> 用 register,受控组件用 Controller。不要为了统一而全用 Controller,那等于放弃了非受控的性能优势。

13.1.10 完整示例:注册表单

把上面所有零件拼成一个带跨字段校验与默认值的注册表单。schema 里用 refine 表达「两次密码一致」这类跨字段规则:

export const registerSchema = z
  .object({
    email: z.string().email('邮箱格式不正确'),
    password: z.string().min(8, '密码至少 8 位'),
    confirm: z.string(),
    plan: z.enum(['free', 'pro']).default('free'),
    agree: z.literal(true, { errorMap: () => ({ message: '需同意服务条款' }) }),
  })
  .refine((v) => v.password === v.confirm, {
    message: '两次输入的密码不一致',
    path: ['confirm'], // 错误挂到 confirm 字段,而不是整个对象
  });

path 是关键:不写的话错误会挂在根对象上,errors.confirm 永远是 undefined,界面上什么也不显示。这是跨字段校验最常见的「校验生效了但看不见错误」的原因。

const { register, handleSubmit, formState } = useForm<RegisterValues>({
  resolver: zodResolver(registerSchema),
  mode: 'onBlur',
  defaultValues: { email: '', password: '', confirm: '', agree: false },
});

提交时再补一层服务端错误回填(setError),细节见 13.2:

const onSubmit = async (values: RegisterValues) => {
  try {
    await api.register(values);
  } catch (e) {
    setError('email', { type: 'server', message: '该邮箱已被注册' });
  }
};

注意 agree 用 z.literal(true) 而不是 z.boolean():后者允许 false,无法表达「必须勾选」。.default(false) 也别加,否则未勾选时会被默认值补成 false 而绕过校验——这里要的就是「没勾就报错」。

13.1.11 五个常见坑

一、defaultValues 与 defaultValue 混淆。 RHF 的初始值统一由 defaultValues 提供,写在 <input defaultValue> 上 RHF 读不到,会出现「看着有值、提交是空」。异步数据回填要用 reset(data),而不是改 defaultValues(它只在首次挂载时生效)。

二、z.coerce.number() 忘了加。 输入框的 e.target.value 永远是字符串,直接 z.number() 会稳定报 Expected number, received string。凡是数字字段,要么 z.coerce.number(),要么在 register 时用 valueAsNumber: true。

三、z.input 与 z.output 不是一回事。 一旦用了 coerce / transform / default,输入类型和输出类型就分叉了。本节例子里 useForm<FormValues> 用的其实是输出类型,严格来说应该写成三段泛型,这个问题留到 《TypeScript编程实战》13.2 表单类型推导与错误映射 专门讲。

四、refine 忘了写 path。 错误挂到根对象上,界面看不到任何提示,用户只会觉得「按钮点了没反应」。

五、resolver 版本错配。 @hookform/resolvers 的次版本与 Zod 大版本强绑定(v3 对应 Zod 3,v5 起同时支持 Zod 3/4)。装错版本的症状是运行时报 schema.parse is not a function,或类型上 zodResolver 与 useForm 的泛型不兼容。

13.1.12 与其它章节的衔接

组件的 props 泛型设计见 《TypeScript编程实战》11.1 组件 props 与泛型组件 ;表单提交后的服务端状态刷新(失效缓存、乐观更新)见 《TypeScript编程实战》14.2 乐观更新与缓存失效 。服务端契约校验同样复用 Zod,见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono) ,端到端类型安全见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 ;动态字段与嵌套数组见 《TypeScript编程实战》13.3 复杂表单与动态字段 。

站内延伸阅读:TypeScript 与 Zod 运行时校验 、Node.js 中的 Zod 校验实践 、运行时类型校验与类型安全 、前端表单与校验架构 、React 与 TypeScript 实践指南 。

小结

本节把表单的三份副本收敛成了一份 schema。性能上,RHF 用非受控输入 + 字段级订阅替代了受控组件的整树重渲染,formState 只解构所需字段、需要读值时用 useWatch 而非顶层 watch,是发挥性能的两个前提;规则上,zodResolver 把 safeParse 的失败结果按 path 映射成 FieldErrors,成功结果则把输出值交给 onSubmit;类型上,z.infer 让类型从 schema 推导,改规则时类型自动跟随,漏改的地方由编译器报出来。

接入方式只有两条规则:原生输入用 register,受控组件用 Controller。校验时机用 mode 与 reValidateMode 分开控制,前者管「第一次什么时候校验」,后者管「出错后什么时候重新校验」。跨字段规则用 refine 表达,并且一定要给 path,否则错误无处可挂。

至于 coerce / transform / default 带来的输入输出类型分叉,正是下一节的主题。下一节 《TypeScript编程实战》13.2 表单类型推导与错误映射 会把 z.input、z.output、z.infer 三者的关系讲透,并给出「服务端返回的错误如何回填到表单」这套工程上一定会遇到的映射方案。

阅读导航:上一节:12.3 SSR·ISR 数据流类型 · 下一节:13.2 表单类型推导与错误映射 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes