React Hook Form 与 Zod:在生产环境中使用一年,无样板代码或隐藏陷阱
在大型项目生产环境中积极使用 React Hook Form 和 Zod 组合一年后,团队确认:声明式表单方法可以将代码量减少 30–40%,但需要注意实现细节。我们分享了发现的陷阱以及如何克服它们。
从 MobX 到声明式表单:为什么切换方法?
在之前的项目迭代中,表单使用 MobX 以 MVC 风格构建。这种方法允许控制状态,但会产生样板代码:每个字段都需要在控制器中单独的方法、错误数组,以及与 UI 的手动同步。例如,添加新字段意味着创建:
export class SomeController {
public setDuration = (v: number) => {
this.store.duration = v;
}
}
这样的模板代码与业务逻辑无关,却占用了组件体积的 60%。切换到 React Hook Form (RHF) 与 Zod 解决了问题:验证 schema 以声明式描述,库自动处理状态同步和错误处理。
基本表单示例:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Enter name'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(0, 'Invalid vozrast').optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function ExampleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit">Send</button>
</form>
);
}
结果:代码量大幅缩小,维护变得更简单,开发新表单的速度显著加快。然而,在实际使用中,文档中未提及的细微问题逐渐浮出水面。
React Hook Form 与 Zod 组合的三大陷阱
使用 RHF 和 Zod 一年,我们遇到了三个关键问题,这些问题在特定场景下会破坏表单功能。
1. 处理 undefined 和 null
RHF 不支持 undefined 作为字段值。尝试设置:
methods.setValue('age', undefined)
会导致字段忽略该更改。文档中提到了这一点,但并不明显。标准修复方法是使用 null,并将字段声明为 .nullable():
const schema = z.object({
age: z.number().nullable()
});
methods.setValue('age', null);
然而,这可能与期望接收数字的自定义组件冲突。例如,年龄输入组件在遇到 null 时可能出错。另一方面:组件清空时返回 undefined,但 RHF 不会处理该值。
重置按钮会增加额外复杂性。reset() 方法从 defaultValues 获取值,但如果它们被设置为 undefined,重置就会失败。解决方案:字符串字段使用 null 或空字符串。
2. Zod 中的意外类型错误
问题出现在数据类型不匹配时。例如,这个 schema:
const schema = z.object({
field1: z.string().min(1, 'Fill field').optional()
});
如果组件返回 null(而非 undefined),Zod 会抛出 invalid type (expected string received null) 错误。用户会看到系统消息,而不是你的自定义文本。
解决方案:为所有验证类型显式设置错误文本:
const schema = z.object({
field1: z.string('Fill field').min(1, 'Fill field').optional()
});
这样确保任何错误都会显示你的消息。
3. isDirty 标志的不稳定性
formState.isDirty 标志用于显示表单是否有更改。但使用空 defaultValues 初始化:
const methods = useForm({
resolver: zodResolver(formSchema),
defaultValues: {},
});
会导致 isDirty 在渲染后立即变为 true,即使用户尚未触碰任何内容。与此同时,dirtyFields 仍为空对象。
这是由于 defaultValues 结构与 schema 不匹配所致。为了避免假阳性,确保 defaultValues 包含 schema 中的每个字段——即使使用 undefined 或 null。
如何避免常见错误?
根据经验,这里是经过验证的实践方法:
- 处理空值:
- 字符串字段:默认使用空字符串。
- 数字/对象字段:在 Zod 中应用 .nullable(),值使用 null。
- 始终验证自定义组件是否支持所选类型。
- 可靠验证:
- 如上例,为每个验证步骤指定通用错误消息。
- 在组件开发时测试 null 和 undefined 场景。
- 正确的 isDirty 行为:
- 初始化 defaultValues 以匹配 schema,覆盖所有字段。
- 避免使用空对象 {}——显式列出字段,并使用 undefined 或 null。
我们还推荐在项目中添加统一的表单辅助函数,例如处理 RHF 特殊情况的重置函数:
const resetForm = () => {
methods.reset({
name: null,
email: null,
age: null,
});
};
关键要点
- 代码减少并不消除测试需求: 声明式方法节省时间,但需要对边缘情况进行严格检查。
- 类型安全是你的朋友: Zod 与 TypeScript 可防止许多 bug,但 schema 必须正确配置。
- 记录怪癖: 在项目内部文档中记录常见的 RHF 和 Zod 问题的变通方法。
— Editorial Team
暂无评论。