表单校验
本教程共 38 篇 · 第 33 篇 · 更新于 2026-07-27 · 约 12 分钟阅读
33. 表单校验
本节目标:掌握 TanStack Form 的完整校验体系。学会同步校验、异步校验、字段级与表单级校验、用 Zod/Valibot 做 Schema 校验、动态校验和自定义错误信息。学完你能应对各种表单校验场景。
33.1 校验的三种时机
表单校验在三个时机触发:
- onChange — 值变化时校验,实时反馈。
- onBlur — 失焦时校验,等用户输完再检查。
- onSubmit — 提交时校验,最后一道防线。
打个比方:onChange 像你边写代码边看报错提示,onBlur 像你写完一行按了保存才检查,onSubmit 像你提交代码评审时才被告知哪里有问题。
TanStack Form 支持在每个时机单独设校验规则:
<form.Field
name="age"
validators={{
onChange: ({ value }) => value < 0 ? '年龄不能为负' : undefined,
onBlur: ({ value }) => value < 18 ? '必须年满 18 岁' : undefined,
onSubmit: ({ value }) => value > 150 ? '年龄不真实' : undefined,
}}
>
{(field) => (
<>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(Number(e.target.value))}
/>
{field.state.meta.errors.length > 0 && (
<span className="text-red-500">{field.state.meta.errors[0]}</span>
)}
</>
)}
</form.Field>
校验函数返回 undefined 表示通过,返回字符串表示有错误。
Note三个时机不是非选其一,可以同时设。它们各自在对应时机触发,错误信息会汇总到
field.state.meta.errors里。errorMap能按时机区分错误来源。
33.2 同步校验
同步校验是最常见的—检查值是否符合规则,立刻返回结果。
33.2.1 字段级同步校验
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
if (!value) return '用户名不能为空'
if (value.length < 3) return '用户名至少 3 个字符'
if (value.length > 20) return '用户名最多 20 个字符'
if (!/^[a-zA-Z0-9_]+$/.test(value)) return '只能包含字母、数字和下划线'
return undefined // 校验通过
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">
{field.state.meta.errors[0]}
</span>
)}
</div>
)}
</form.Field>
校验函数接收一个对象,里面有 value(当前值)和 field(字段实例),返回 undefined 或错误字符串。
33.2.2 表单级同步校验
有些校验不是针对单个字段,而是跨字段联动。比如「密码和确认密码必须一致」:
const form = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
// 表单级校验:在 onSubmit 时检查密码匹配
onSubmit: ({ value }) => {
if (value.password !== value.confirmPassword) {
return '两次输入的密码不一致'
}
return undefined
},
},
onSubmit: async ({ value }) => {
console.log('提交', value)
},
})
表单级校验在 useForm 的 validators 里设,value 是整个表单的值。返回的错误会出现在 form.state.meta.errors 里。
Tip跨字段校验放表单级,单字段校验放字段级。密码匹配、日期范围(结束日期 > 开始日期)这类放表单级最合适。
33.3 异步校验
有些校验需要请求服务端,比如检查用户名是否已被注册。这种用异步校验:
<form.Field
name="username"
validators={{
// 异步校验函数
onChangeAsync: async ({ value }) => {
// 模拟请求服务端检查用户名
await new Promise((resolve) => setTimeout(resolve, 1000))
const taken = ['admin', 'test', 'user'] // 已被占用的用户名
if (taken.includes(value)) {
return '该用户名已被注册'
}
return undefined
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">
{field.state.meta.errors[0]}
</span>
)}
</div>
)}
</form.Field>
异步校验函数名加 Async 后缀:onChangeAsync、onBlurAsync、onSubmitAsync。
33.3.1 防抖
异步校验每次输入都发请求太浪费。加个防抖(debounce),用户停下来才发:
<form.Field
name="username"
validators={{
onChangeAsyncDebounceMs: 500, // 停止输入 500ms 后才执行校验
onChangeAsync: async ({ value }) => {
const res = await fetch(`/api/check-username?name=${value}`)
const data = await res.json()
return data.taken ? '用户名已被注册' : undefined
},
}}
>
{(field) => (
/* ... */
)}
</form.Field>
onChangeAsyncDebounceMs: 500 表示用户停止输入 500 毫秒后才触发异步校验。在此期间继续输入会取消上一次的校验。这比自己在 setTimeout 里写防抖方便多了。
Warning异步校验期间,用户可能已经继续输入了。TanStack Form 会自动取消过期的异步校验(基于最新的值),不会出现旧结果覆盖新结果的问题。但如果你在异步函数里用了
AbortController,记得在取消时处理。
33.4 Schema 校验(Zod)
手写校验函数适合简单场景。字段多了,手写校验又长又重复。Schema 校验用专门的校验库定义规则,更简洁更可维护。
33.4.1 安装 Zod
TanStack Form v1 原生支持 Standard Schema 规范,Zod、Valibot 等 schema 库可以直接传入,不需要额外的适配器包:
npm install zod
33.4.2 定义 Schema
用 Zod 定义表单数据的校验规则:
import { z } from 'zod'
const formSchema = z.object({
username: z
.string()
.min(3, '用户名至少 3 个字符')
.max(20, '用户名最多 20 个字符')
.regex(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线'),
email: z.string().email('请输入有效的邮箱地址'),
age: z.number().min(18, '必须年满 18 岁').max(150, '年龄不真实'),
})
Zod 的链式 API 很直观:z.string() 定义字符串类型,.min()/.max() 加约束,第二个参数是错误信息。
33.4.3 在表单中使用
把 Schema 直接传给 useForm 的 validators,不用任何包装函数:
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
const form = useForm({
defaultValues: {
username: '',
email: '',
age: 0,
},
validators: {
onChange: formSchema, // 直接传 Zod schema
},
onSubmit: async ({ value }) => {
console.log('提交', value)
},
})
Schema 校验的好处:
- 一处定义,处处使用:一个 Schema 对象校验整个表单,不用每个字段单独写。
- 类型自动推导:Zod Schema 能推导出 TypeScript 类型,和
defaultValues的类型一致。 - 错误信息统一:错误信息在 Schema 里定义,格式统一。
Note用 Standard Schema 校验时,
field.state.meta.errors里的元素是对象(有message属性),不是字符串。展示错误时用errors[0]?.message取信息。手写校验返回字符串时,errors[0]直接就是字符串。两种方式别搞混。
TipSchema 校验也可以放在字段级。
<Field validators={{ onChange: z.string().email('请输入有效邮箱') }}>只校验单个字段。表单级校验整个对象,字段级校验单个值。
33.5 Schema 校验(Valibot)
Valibot 是比 Zod 更轻量的替代品,API 类似但打包体积小很多。同样原生支持 Standard Schema,直接传入即可:
npm install valibot
import { object, string, number, minLength, email, minValue } from 'valibot'
const formSchema = object({
username: string([minLength(3, '用户名至少 3 个字符')]),
email: string([email('请输入有效的邮箱地址')]),
age: number([minValue(18, '必须年满 18 岁')]),
})
const form = useForm({
defaultValues: { username: '', email: '', age: 0 },
validators: {
onChange: formSchema, // 直接传 Valibot schema
},
onSubmit: async ({ value }) => console.log(value),
})
NoteZod 和 Valibot 功能类似,选哪个看团队偏好。Zod 生态更广、用的人多;Valibot 更轻量、Tree-shaking 友好。TanStack Form 两个都支持。
33.6 动态校验
校验规则需要根据其他字段的值变化,叫动态校验(Dynamic Validation)。
比如:选了「企业用户」类型时,公司名字段必填;选了「个人用户」时不必填。
const form = useForm({
defaultValues: {
userType: 'personal' as 'personal' | 'enterprise',
companyName: '',
},
onSubmit: async ({ value }) => console.log(value),
})
// 公司名字段:根据 userType 动态校验
<form.Field
name="companyName"
validators={{
onChange: ({ value, fieldApi }) => {
const userType = fieldApi.form.getFieldValue('userType')
if (userType === 'enterprise' && !value) {
return '企业用户必须填写公司名'
}
return undefined
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
disabled={form.getFieldValue('userType') !== 'enterprise'}
/>
{field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
关键点:校验函数里通过 fieldApi.form.getFieldValue('userType') 读取其他字段的值,根据它决定校验规则。
Warning动态校验有性能考量:每次
companyName变化都读userType的值。如果依赖的字段很多,考虑用表单级校验统一处理。
33.7 自定义错误信息
校验函数返回的字符串就是错误信息。想返回更结构化的错误,可以返回对象:
<form.Field
name="password"
validators={{
onChange: ({ value }) => {
const errors = []
if (value.length < 8) errors.push('至少 8 个字符')
if (!/[A-Z]/.test(value)) errors.push('需要大写字母')
if (!/[0-9]/.test(value)) errors.push('需要数字')
if (errors.length > 0) {
return errors.join(';') // "至少 8 个字符;需要大写字母;需要数字"
}
return undefined
},
}}
>
{(field) => (
<div>
<input type="password" ... />
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<ul className="text-red-500 text-sm">
{field.state.meta.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
)}
</div>
)}
</form.Field>
33.8 完整校验示例
把同步、异步、Schema 校验组合起来:
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
const registerSchema = z.object({
username: z.string().min(3, '至少 3 个字符').max(20, '最多 20 个字符'),
email: z.string().email('邮箱格式不正确'),
age: z.number().min(18, '必须年满 18 岁'),
})
function RegisterForm() {
const form = useForm({
defaultValues: {
username: '',
email: '',
age: 0,
},
validators: {
onChange: registerSchema, // 直接传 Zod schema
},
onSubmit: async ({ value }) => {
console.log('提交:', value)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="max-w-md space-y-4"
>
{/* 用户名:Schema 校验 + 异步检查是否被占用 */}
<form.Field
name="username"
validators={{
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
await new Promise((r) => setTimeout(r, 300))
const taken = ['admin', 'root']
return taken.includes(value) ? '用户名已被占用' : undefined
},
}}
>
{(field) => (
<div>
<label className="block text-sm mb-1">用户名</label>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="border rounded px-3 py-2 w-full"
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">
{field.state.meta.errors[0]}
</span>
)}
</div>
)}
</form.Field>
{/* 邮箱:Schema 校验 */}
<form.Field name="email">
{(field) => (
<div>
<label className="block text-sm mb-1">邮箱</label>
<input
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="border rounded px-3 py-2 w-full"
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">
{field.state.meta.errors[0]}
</span>
)}
</div>
)}
</form.Field>
{/* 年龄:Schema 校验 */}
<form.Field name="age">
{(field) => (
<div>
<label className="block text-sm mb-1">年龄</label>
<input
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(Number(e.target.value))}
className="border rounded px-3 py-2 w-full"
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">
{field.state.meta.errors[0]}
</span>
)}
</div>
)}
</form.Field>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button
type="submit"
disabled={!canSubmit || isSubmitting}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isSubmitting ? '提交中...' : '注册'}
</button>
)}
</form.Subscribe>
</form>
)
}
这个表单同时用了 Schema 校验(Zod)和异步校验(检查用户名占用),还有防抖和错误展示。
33.9 常见坑
坑一:同步校验和异步校验混用错误不显示。 同步校验的错误在 errors 里,异步校验的错误也在 errors 里。但异步校验完成前 errors 是空的。如果你的错误展示逻辑依赖同步状态,可能异步错误显示不出来。确保错误展示逻辑检查的是最新的 field.state.meta.errors。
坑二:Schema 校验和手写校验冲突。 同时在表单级用 Schema 校验、字段级又手写校验,两个都返回错误,errors 里会有两条。确定用一种方式为主,另一种补充。
坑三:异步校验没加防抖。 不加 onChangeAsyncDebounceMs,每输一个字符就发请求,服务端被刷爆,用户体验也差(频繁显示/隐藏错误)。
坑四:Schema 类型和 defaultValues 类型不匹配。 Zod 里 age: z.number() 但 defaultValues 里 age: ''(字符串),类型推导冲突。两边类型要一致。
坑五:动态校验读不到最新值。 在校验函数里用闭包变量读其他字段值,可能拿到旧值。用 fieldApi.form.getFieldValue() 保证读到最新值。
33.10 小结
这一章讲了 TanStack Form 的完整校验体系:
- 三种时机:
onChange(实时)、onBlur(失焦)、onSubmit(提交),可同时使用。 - 同步校验:字段级返回
undefined或错误字符串;表单级做跨字段校验。 - 异步校验:
onChangeAsync等异步函数,配onChangeAsyncDebounceMs防抖。 - Schema 校验:用 Zod 或 Valibot 定义校验规则,v1 原生支持 Standard Schema,直接传入不用适配器。
- 动态校验:校验函数里通过
fieldApi.form.getFieldValue()读其他字段值,动态调整规则。
下一章讲表单组合、数组字段、联动字段、监听器等进阶用法。