news 2026/9/17 3:56:35

TanStack Form 表单验证完全指南:字段级与表单级的同步、异步及 Schema 验证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TanStack Form 表单验证完全指南:字段级与表单级的同步、异步及 Schema 验证

TanStack Form 表单验证完全指南:字段级与表单级的同步、异步及 Schema 验证

【免费下载链接】form🤖 Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, and Lit.项目地址: https://gitcode.com/GitHub_Trending/form/form

验证是 TanStack Form 的核心能力。无论你使用 React、Vue、Angular、Solid 还是 Preact,这套 headless 表单状态管理库都提供了一致的验证机制:你可以自由控制验证时机(输入、失焦、提交、挂载)、在字段级或表单级定义规则、使用同步函数、异步请求甚至 Standard Schema 生态(Zod、Valibot、ArkType、Effect/Schema)来完成类型安全的验证。读完本文,你将掌握 TanStack Form 中验证的完整用法,包括错误展示、异步防抖、表单级向字段级回写错误,以及如何阻止无效表单提交。

验证何时执行?由你自己决定

TanStack Form 的验证时机完全可配置。<Field />组件接受一系列回调属性,如onChangeonBluronSubmit等。这些回调会收到字段的当前值(value)以及fieldApi对象。如果发现验证错误,只需返回错误信息字符串,它会自动出现在field.state.meta.errors中。

下面的例子在每次按键时(onChange)执行验证:

<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>

如果希望改为在字段失焦时验证,只需把验证器从onChange换到onBlur,并记得在<input>上绑定field.handleBlur(同时仍需保留onChange让 TanStack Form 收到输入变化):

<form.Field name="age" validators={{ onBlur: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>

你甚至可以针对同一个字段,在不同时机做不同的验证。例如每次按键检查"是否满 13 岁",失焦时检查"是否为负数":

<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, onBlur: ({ value }) => (value < 0 ? 'Invalid value' : undefined), }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>

由于field.state.meta.errors是数组,某一时刻触发的所有相关错误都会被展示。如果你想知道错误是"什么时候"产生的,可以使用field.state.meta.errorMap,它按验证时机(onChangeonBlur等)分键保存错误。

从实现上看,这些验证时机由 ValidationLogic.ts 中的defaultValidationLogic统一调度:当事件类型为change时运行onChange/onChangeAsync并附带清理服务端错误;blur时运行onBlur/onBlurAsyncsubmit时则会依次运行 change、blur、submit 与服务端相关验证器,确保提交前所有时机都被覆盖。

展示错误:errors 数组与 errorMap

配置好验证后,把错误数组映射到 UI 上即可:

<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => { return ( <> {/* ... */} {!field.state.meta.isValid && ( <em>{field.state.meta.errors.join(',')}</em> )} </> ) }} </form.Field>

也可以使用errorMap精准读取某一时机产生的错误:

<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> {/* ... */} {field.state.meta.errorMap['onChange'] ? ( <em>{field.state.meta.errorMap['onChange']}</em> ) : null} </> )} </form.Field>

值得强调的是,errors数组和errorMap的类型与验证器返回的类型完全一致。因此验证器可以返回任意结构化对象,而不只是字符串:

<form.Field name="age" validators={{ onChange: ({ value }) => (value < 13 ? { isOldEnough: false } : undefined), }} > {(field) => ( <> {/* ... */} {/* errorMap.onChange 的类型是 `{isOldEnough: false} | undefined` */} {/* meta.errors 的类型是 `Array<{isOldEnough: false} | undefined>` */} {!field.state.meta.errorMap['onChange']?.isOldEnough ? ( <em>The user is not old enough</em> ) : null} </> )} </form.Field>

在 types.ts 中可以确认errorMap的定义:ValidationErrorMaponMountonChangeonBluronSubmitonDynamiconServer为键(onXxxAsync的结果会合并进对应同步键中),而errors数组则是由meta.errors派生的扁平化集合。此外,字段元数据还提供isValidating(是否有异步验证进行中)与isValid(是否存在错误)等派生状态,相关类型见 FieldLikeMetaDerived。真实项目中的FieldInfo组件展示了典型用法,参见 examples/react/standard-schema/src/index.tsx。

字段级验证 vs 表单级验证

前面每个<Field>通过onChangeonBlur等回调定义了自己的验证规则。同样地,你也可以通过useForm()传入类似的回调,在表单级定义验证规则:

export default function App() { const form = useForm({ defaultValues: { age: 0, }, onSubmit: async ({ value }) => { console.log(value) }, validators: { // 像给字段添加验证器一样,给整个表单添加验证器 onChange({ value }) { if (value.age < 13) { return 'Must be 13 or older to sign' } return undefined }, }, }) // 订阅表单的 `errorMap`,让它的更新触发重新渲染 // 也可以使用 `form.Subscribe` const formErrorMap = useSelector(form.store, (state) => state.errorMap) return ( <div> {/* ... */} {formErrorMap.onChange ? ( <div> <em>There was an error on the form: {formErrorMap.onChange}</em> </div> ) : null} {/* ... */} </div> ) }

注意:上面使用的是返回string的函数验证器。当使用 Standard Schema 验证器(Zod、Valibot、ArkType、Effect/Schema)时,state.errorMap.onChange的类型变为Record<string, StandardSchemaV1Issue[]>,按字段名作为键。需要遍历该 Record 来渲染消息:

{ formErrorMap.onChange ? ( <div> <em> There was an error on the form:{' '} {Object.values(formErrorMap.onChange) .flat() .map((issue) => issue.message) .join(', ')} </em> </div> ) : null }

表单级 errorMap 的完整类型定义(FormValidationErrorMap)与GlobalFormValidationError结构可以在 types.ts 中找到。当验证器返回{ form, fields }结构时,form键是全局表单错误,fields键则按字段深键(DeepKeys)映射到具体字段,例如'socials[0].url''details.email'

从表单验证器设置字段级错误

一个常见场景是:在表单的onSubmitAsync验证器中,通过一次 API 调用同时验证所有字段,并把错误写回各个字段:

export default function App() { const form = useForm({ defaultValues: { age: 0, socials: [], details: { email: '', }, }, validators: { onSubmitAsync: async ({ value }) => { // 在服务端验证整个 value const hasErrors = await verifyDataOnServer(value) if (hasErrors) { return { form: 'Invalid data', // `form` 键是可选的 fields: { age: 'Must be 13 or older to sign', // 用字段名设置嵌套字段的错误 'socials[0].url': 'The provided URL does not exist', 'details.email': 'An email is required', }, } } return null }, }, }) return ( <div> <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() void form.handleSubmit() }} > <form.Field name="age"> {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field> <form.Subscribe selector={(state) => [state.errorMap]} children={([errorMap]) => errorMap.onSubmit ? ( <div> <em>There was an error on the form: {errorMap.onSubmit}</em> </div> ) : null } /> {/*...*/} </form> </div> ) }

这个模式有完整的可运行示例:examples/react/field-errors-from-form-validators/src/index.tsx 通过Promise.all并行调用两个模拟服务端接口(年龄校验、用户名占用校验),把失败信息分别回写到ageusername字段,同时用form.Subscribe展示全局错误。

需要特别提醒:如果表单级验证函数返回了某个错误,它可能被字段级验证覆盖。例如:

const form = useForm({ defaultValues: { age: 0, }, validators: { onChange: ({ value }) => { return { fields: { age: value.age < 12 ? 'Too young!' : undefined, }, } }, }, }) // ... return ( <form.Field name="age" validators={{ onChange: ({ value }) => (value % 2 === 0 ? 'Must be odd!' : undefined), }} children={() => <>{/* ... */}</>} /> )

上述代码最终只会显示'Must be odd!',即使表单级验证返回了'Too young!'。因为字段级验证在同一次事件中优先级更高并覆盖了表单级写回的错误。这也是源码中validateSync之后字段级错误 map 合并顺序所决定的(见 FieldApi.ts 中fieldsErrorMap与字段自身setErrorMap的叠加逻辑)。

异步函数验证

大多数验证是同步的,但网络请求等异步操作同样是刚需。TanStack Form 为此提供了专门的onChangeAsynconBlurAsync等异步验证方法:

<form.Field name="age" validators={{ onChangeAsync: async ({ value }) => { await new Promise((resolve) => setTimeout(resolve, 1000)) return value < 13 ? 'You must be 13 to make an account' : undefined }, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>

同步与异步验证器可以共存。例如同一个字段同时定义onBluronBlurAsync

<form.Field name="age" validators={{ onBlur: ({ value }) => (value < 13 ? 'You must be at least 13' : undefined), onBlurAsync: async ({ value }) => { const currentAge = await fetchCurrentAgeOnProfile() return value < currentAge ? 'You can only increase the age' : undefined }, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>

执行顺序上,同步验证(onBlur)先运行,异步验证(onBlurAsync)仅在同步验证通过后运行。如果希望无论同步验证结果如何都强制执行异步验证,把asyncAlways设为true即可。这个"同步失败即短路"的行为在 FieldApi.ts 中体现:hasErrored && !this.options.asyncAlways时会中止(abort)挂起的异步验证并直接返回错误。

底层还会用_pendingValidationsCount计数器跟踪进行中的异步验证(见 FieldApi.ts),多个异步验证同时完成时不会出现竞态,isValidating也会随之正确翻转。

内置防抖

异步验证往往用于查询数据库,但每次按键都发网络请求无异于自毁后端。TanStack Form 内置了防抖支持,只需一个属性:

<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsync: async ({ value }) => { // ... }, }} children={(field) => { return <>{/* ... */}</> }} />

上述配置会让所有异步调用以 500ms 延迟防抖执行。你还可以按验证器单独覆盖防抖时间:

<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsyncDebounceMs: 1500, onChangeAsync: async ({ value }) => { // ... }, onBlurAsync: async ({ value }) => { // ... }, }} children={(field) => { return <>{/* ... */}</> }} />

效果是onChangeAsync每 1500ms 执行一次,而onBlurAsync仍按 500ms 执行。防抖的解析逻辑在 utils.ts 的getAsyncValidatorArray中:onChangeAsyncDebounceMsonBlurAsyncDebounceMsonDynamicAsyncDebounceMs优先于asyncDebounceMs(默认 0),而submit相关验证始终立即执行(debounceMs = 0)。

通过 Schema 库进行验证

函数验证灵活但略显啰嗦。TanStack Form 原生支持所有遵循Standard Schema 规范的校验库,最常用的包括:

  • Zod
  • Valibot
  • ArkType
  • Effect/Schema

使用方式与自定义函数完全一致,直接把 schema 传给validators即可。你也可以为整个表单定义一个 schema 传给表单级验证器,错误会自动分发到各字段:

const userSchema = z.object({ age: z.number().gte(13, 'You must be 13 to make an account'), }) function App() { const form = useForm({ defaultValues: { age: 0, }, validators: { onChange: userSchema, }, }) return ( <div> <form.Field name="age" children={(field) => { return <>{/* ... */}</> }} /> </div> ) }

提示:请使用最新版本的 schema 库,旧版本可能尚未支持 Standard Schema 规范。

另外要注意:验证不会返回转换后的值(transformed values)。需要转换值请参考提交处理指南。

表单级与字段级的异步 schema 验证同样受支持,还能结合防抖:

<form.Field name="age" validators={{ onChange: z.number().gte(13, 'You must be 13 to make an account'), onChangeAsyncDebounceMs: 500, onChangeAsync: z.number().refine( async (value) => { const currentAge = await fetchCurrentAgeOnProfile() return value >= currentAge }, { message: 'You can only increase the age', }, ), }} children={(field) => { return <>{/* ... */}</> }} />

如果需要对 Standard Schema 验证做更精细的控制,可以把 schema 与回调函数组合使用,通过fieldApi.parseValueWithSchema手动解析:

<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsync: async ({ value, fieldApi }) => { const errors = fieldApi.parseValueWithSchema( z.number().gte(13, 'You must be 13 to make an account'), ) if (errors) return errors // 继续你的自定义验证 }, }} children={(field) => { return <>{/* ... */}</> }} />

parseValueWithSchema只做解析并返回 issues,不会写入内部错误状态(见 FieldApi.ts),因此非常适合在自定义异步验证流程中按需组合 schema。

Standard Schema 的底层实现

TanStack Form 通过鸭子类型识别 Standard Schema:只要对象带有'~standard'属性即被视为标准 schema(standardSchemaValidator.ts 中的isStandardSchemaValidator)。StandardSchemaV1接口定义了version: 1vendorvalidate(value)等方法(同文件 L120-L149)。

验证结果分两种路径:字段级验证直接返回StandardSchemaV1Issue[];表单级验证则通过prefixSchemaToErrors把 issue 的path逐段拼回字段深键路径(数组用[index]、对象用.key),再组织成{ form, fields }结构(standardSchemaValidator.ts),这就是"整表单 schema 错误自动分发到字段"的原理。

一个真实的综合示例见 examples/react/standard-schema/src/index.tsx:同一个表单里定义了 Zod、Valibot、ArkType、Effect 四种 schema,注释掉其他三行即可无缝切换,验证逻辑与错误展示代码完全不用改。

阻止无效表单被提交

onChangeonBlur等回调在表单提交时也会运行,因此无效表单的提交会被自动拦截。表单状态对象提供了canSubmit标志:当任一字段无效且表单已被触摸(touched)时,canSubmitfalse;表单未被触摸前即使某些字段"技术上"无效,canSubmit也保持true

你可以通过form.Subscribe订阅canSubmit,例如据此禁用提交按钮(实践上建议用aria-disabled代替disabled,因为禁用按钮对无障碍不友好):

const form = useForm(/* ... */) return ( /* ... */ // 动态提交按钮 <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]} children={([canSubmit, isSubmitting]) => ( <button type="submit" disabled={!canSubmit}> {isSubmitting ? '...' : 'Submit'} </button> )} /> )

canSubmitisPristine的组合同样出现在官方示例中:examples/react/field-errors-from-form-validators/src/index.tsx 与 examples/react/standard-schema/src/index.tsx 都通过selector={(state) => [state.canSubmit, state.isSubmitting]}驱动提交按钮状态。

如果要在用户产生任何交互前就禁止提交,可以把canSubmitisPristine组合使用,例如!canSubmit || isPristine这种条件能有效阻止未修改即提交。

小结

TanStack Form 的验证体系围绕三个维度展开:时机onChange/onBlur/onSubmit/onMount及其Async变体)、层级(字段级与表单级,且表单级可通过fields深键回写错误到任意嵌套字段)、形式(同步函数、异步函数、内置防抖、Standard Schema 生态)。底层由 ValidationLogic.ts 统一编排验证器执行顺序,由 types.ts 提供端到端的类型安全——错误类型从验证器返回值一路推导到errorMaperrors数组。配合 standardSchemaValidator.ts 的 Standard Schema 适配层,你可以在保持类型完整性的同时,用最简洁的方式构建健壮的表单验证。

【免费下载链接】form🤖 Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, and Lit.项目地址: https://gitcode.com/GitHub_Trending/form/form

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 3:56:05

OpenCode 跑 code-reviewer:Key 用 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 3:56:04

PEP 8实战指南:从缩进到自动化工具,打造高可读性Python代码

先给你看两段功能完全一样的代码&#xff0c;都是计算一个列表里所有偶数的平方和。第一段是新手常见的写法&#xff0c;第二段做了风格调整&#xff0c;你感受一下差别&#xff1a;# 写法一 def calc(nums):result0for i in nums:if i%20:resulti*ireturn result# 写法二 def …

作者头像 李华
网站建设 2026/9/17 3:55:51

用纯Bash实现单文件配环境Agent:自动检测、安装与验证

我上周刚拿到一台新开发机&#xff0c;装完系统以后光把 Node、Java、Go、Docker 这些轮子配齐&#xff0c;来回切窗口、找安装包、改 PATH、翻报错&#xff0c;就折腾了大半个下午。这不是第一次了。所以第三次重复做这件事的时候&#xff0c;我实在没忍住&#xff0c;把整套路…

作者头像 李华
网站建设 2026/9/17 3:54:59

Cucumber自动化测试实战:从BDD到Gherkin的完整指南

我第一次用 Cucumber&#xff0c;是在一个购物网站自动化改造项目里。当时团队已经维护了一套 Selenium 脚本&#xff0c;用例数量不少&#xff0c;但产品经理和项目负责人每次验收都要另开一场会&#xff0c;逐条解释“这个脚本到底验证了什么”。直到我们把用例全部改成 Gher…

作者头像 李华
网站建设 2026/9/17 3:54:54

SQL中count(1)、count(*)与count(列名)的区别及性能优化

年初我帮团队复盘一个慢SQL问题&#xff0c;优化完发现执行计划里count(1)被优化器和count(*)处理成了完全一样的东西。但到了count(列名)&#xff0c;情况突然不一样了。群里当时吵了一轮&#xff1a;有人说 count(1) 比 count(*) 快&#xff0c;有人说 count(列名) 最快&…

作者头像 李华
网站建设 2026/9/17 3:52:10

AI测试工具兴起,2027年非AI驱动工具淘汰,测试工程师如何转型

最近测试圈子里传得最凶的一件事&#xff0c;就是微软内部文件提到2027年要淘汰所有非AI驱动的测试工具。很多朋友跑来问我&#xff0c;说这是不是意味着我们这帮写脚本、点页面的测试工程师要集体失业了。我的看法比较直接&#xff1a;这份文件更像是一个行业风向标&#xff0…

作者头像 李华