实战:让错误聚焦从零到一)
TanStack Form Preact 焦点管理Focus Management实战让错误聚焦从零到一【免费下载链接】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 FormPreact 版本中当你希望提交失败时自动把光标定位到第一个有错误的输入框上官方不会给你一个开箱即用的Form focusFirstInvalidField /开关——因为它是一个无头Headless表单库设计哲学决定了它不预设任何 DOM 结构。本文基于 focus-management.md 指南结合仓库源码带你完整掌握两种业界标准方案基于 DOM 的querySelector方案以及适用于 React Native经由 compat的手动字段注册方案并讲透背后的触发时机与状态模型。为什么 TanStack Form 不内置焦点管理在动手之前先理解一个关键设计决策。TanStack Form 的核心原则之一是无头Headless它只负责表单状态、校验与提交逻辑完全不了解你的标记markup长什么样。正如 哲学文档 中 Controlled is Cool 一节所述这种受控设计带来了可预测、易测试、支持非 DOM 环境React Native、Three.js 等的优势但代价就是——库无法替你管理焦点。原因很直白焦点管理必须知道输入框在页面哪里、用什么标签渲染而这正是无头库刻意不掌握的信息。因此焦点管理被设计为应用层职责由你在onSubmitInvalid回调中自行实现。好处是你可以针对自己的组件库如 MUI、Headless UI 或自研设计系统定制任意聚焦策略而不会被库的默认行为束缚。前置知识onSubmitInvalid 的触发时机与参数要让焦点管理代码正确工作必须先理解onSubmitInvalid是什么时候被调用的。查看核心实现 FormApi.ts_handleSubmit的流程如下将表单标记为未提交isSubmitted: false并递增submissionAttempts批量把所有未触碰untouched的字段标记为isTouched: true调用validateAllFields(submit)做字段级校验若!isFieldsValid立即触发onSubmitInvalid并返回再调用validate(submit)做表单级校验若!isValid同样触发onSubmitInvalid并返回全部通过后才会执行onSubmit。也就是说onSubmitInvalid会在任何校验未通过时被同步调用并且它的回调签名是onSubmitInvalid?: (props: { value: TFormData formApi: FormApiTFormData meta: TSubmitMeta }) void这正是两个焦点管理方案共同的钩子在校验失败时通过formApi或 DOM 查询拿到错误字段信息然后调用focus()。方案一DOM 环境下的 querySelector 聚焦这是浏览器中最简洁的方案。核心思路给每个输入框设置aria-invalid属性反映其校验状态然后在onSubmitInvalid中用document.querySelector([aria-invalidtrue])找到第一个错误输入并聚焦。由于aria-invalid遵循 DOM 文档顺序querySelector天然返回页面中第一个带有该属性的元素无需手动排序。完整示例原文代码import { useForm } from tanstack/preact-form import { z } from zod export default function App() { const form useForm({ defaultValues: { age: 0 }, validators: { onChange: z.object({ age: z.number().min(12), }), }, onSubmit() { alert(Submitted!) }, onSubmitInvalid() { const InvalidInput document.querySelector( [aria-invalidtrue], ) as HTMLInputElement InvalidInput?.focus() }, }) return ( form onSubmit{(e) { e.preventDefault() e.stopPropagation() void form.handleSubmit() }} form.Field nameage children{(field) ( label Age input name{field.name} value{field.state.value} aria-invalid{ !field.state.meta.isValid field.state.meta.isTouched } onInput{(e) field.handleChange(e.target.valueAsNumber)} typenumber / /label )} / div button typesubmitSubmit/button /div /form ) }逐行拆解关键点1.aria-invalid的判定条件aria-invalid{ !field.state.meta.isValid field.state.meta.isTouched }这里用到的是字段状态field.state.meta中的两个布尔标志定义见 types.tsisValid字段当前是否有错误isTouched字段是否被用户触碰过。只有确实有错且用户已触碰时才标记aria-invalidtrue。这样首次渲染用户还没碰任何输入框时不会立刻出现红色错误态避免打开页面就是一片报错的糟糕体验。2. 表单提交的接缝处理onSubmit{(e) { e.preventDefault() e.stopPropagation() void form.handleSubmit() }}preventDefault阻止浏览器原生提交stopPropagation防止事件冒泡然后显式调用form.handleSubmit()。在 useForm.tsx 中可以看到Preact 适配层把handleSubmit委托给了核心层的_handleSubmit因此这里触发的正是上文剖析的那条校验-回调链路。3. 提交失败时聚焦onSubmitInvalid() { const InvalidInput document.querySelector( [aria-invalidtrue], ) as HTMLInputElement InvalidInput?.focus() }由于_handleSubmit在触发onSubmitInvalid前已经把未触碰字段全部标记为isTouched: trueFormApi.ts所以提交那一刻所有有错误的字段的aria-invalid都会变为truequerySelector便能可靠命中第一个错误输入框。?.focus()中的可选链保证了查询结果为空时不会抛异常。提示当你的输入框与校验状态绑定松散、或字段较多时还可以把aria-invalid的判断抽取为一个辅助函数例如const isInvalid (field) !field.state.meta.isValid field.state.meta.isTouched让 JSX 更清爽。方案二React Nativevia compat下的手动字段注册聚焦React Native 没有 DOM自然没有querySelectorAll。此时需要手动维护一份已渲染字段的清单在提交失败时遍历这份清单结合表单的错误映射errorMap找到第一个有错误的输入并聚焦。注意Preact 与 React Native 的集成需要借助 compat 层preact/compat让 Preact 组件可以运行在 React Native 渲染环境中。本方案的核心在于字段清单由应用自己维护与渲染框架无关。完整示例import { useRef } from preact/hooks import { Text, View, TextInput, Button, Alert } from react-native import { useForm } from tanstack/preact-form import { z } from zod export default function App() { // This can be extracted to a hook that returns the fields ref, // a focusFirstField function, and a addField function const fields useRef([] as Array{ input: TextInput; name: string }) const form useForm({ defaultValues: { age: 0 }, validators: { onChange: z.object({ age: z.number().min(12), }), }, onSubmit() { Alert.alert(Submitted!) }, onSubmitInvalid({ formApi }) { const errorMap formApi.state.errorMap.onChange const inputs fields.current let firstInput for (const input of inputs) { if (!input || !input.input) continue if (!!errorMap[input.name]) { firstInput input.input break } } firstInput?.focus() }, }) return ( View style{{ padding: 16 }} form.Field nameage children{(field) ( View style{{ marginVertical: 16 }} TextAge/Text TextInput keyboardTypenumeric ref{(input) { // fields.current needs to be manually incremented so that we // know what fields are rendered or not and in what order fields.current[0] { input, name: field.name } }} style{{ borderWidth: 1, borderColor: #999999, borderRadius: 4, marginTop: 8, padding: 8, }} onChangeText{(val) field.handleChange(Number(val))} value{field.state.value} / /View )} / Button titleSubmit onPress{form.handleSubmit} / /View ) }逐行拆解关键点1. 字段清单的维护方式const fields useRef([] as Array{ input: TextInput; name: string }) ... ref{(input) { fields.current[0] { input, name: field.name } }}用useRef持有清单保证组件重渲染期间引用稳定在TextInput的ref回调里按索引写入{ input, name: field.name }注释明确说明这是手动递增必须自行保证索引与渲染顺序一致才能知道哪些字段已渲染、渲染顺序如何之所以不能用push是因为条件渲染、删除字段等场景会让fields.current的索引与实际渲染顺序错位按索引赋值则每个字段始终占据固定槽位。2. 错误信息的来源errorMaponSubmitInvalid({ formApi }) { const errorMap formApi.state.errorMap.onChange ... if (!!errorMap[input.name]) { firstInput input.input break } }formApi.state.errorMap是表单级错误映射其结构定义在 types.ts 中键为onMount/onChange/onBlur/onSubmit等校验时机值为对应字段名到错误信息的映射。本示例使用onChange校验Zod schema因此读取errorMap.onChange然后按input.name即field.name逐条比对存在该字段的错误信息就把它记为firstInputbreak保证取到的是清单中的第一个错误字段。3. 防御性判断if (!input || !input.input) continue清单中的槽位可能未被赋值例如尚未渲染跳过即可这保证了解构input.input与后续firstInput?.focus()的安全性。4. 触发展开React Native 中Button没有原生 submit 语义直接绑定onPress{form.handleSubmit}即可与 DOM 方案的form.handleSubmit()殊途同归最终都汇入_handleSubmit的校验-回调链路。进阶把焦点逻辑抽取为可复用 Hook原文注释中已提示这套逻辑可以抽取成一个 hook。对于真实项目——尤其是多字段、动态表单——强烈建议这样做让表单组件保持纯粹、焦点策略集中管理。一个参考实现思路import { useRef, useCallback } from preact/hooks export function useFocusFirstErrorFieldTFormData() { const fields useRef([] as Array{ input: any; name: string }) const addField useCallback((index: number, input: any, name: string) { fields.current[index] { input, name } }, []) const focusFirstField useCallback((errorMap: Recordstring, unknown) { for (const field of fields.current) { if (!field?.input) continue if (errorMap[field.name]) { field.input.focus() break } } }, []) return { fields, addField, focusFirstField } }随后在onSubmitInvalid中一行调用onSubmitInvalid({ formApi }) { focusFirstField(formApi.state.errorMap.onChange) }在form.Field的ref回调中一行注册ref{(input) addField(0, input, field.name)}这样做的好处字段增删、错误判定策略变化时只需改动 hook 内部所有表单统一受益——这也正好呼应了 TanStack Form Libraries are liberating哲学文档的主张把常用逻辑封装进你自己的组件体系。两种方案对比与选型建议维度DOM 方案querySelectorReact Native 方案手动清单适用环境浏览器Preact DOM 渲染React Nativevia compat等无 DOM 环境依赖 APIdocument.querySelector、aria-invaliduseReferrorMapref回调错误来源从 DOM 属性反向推断直接读取formApi.state.errorMap字段顺序由 DOM 文档顺序决定天然有序需手动按索引维护渲染顺序与 UI 库结合依赖输入框正确透传aria-invalid依赖field.name与 errorMap 键一致选型建议纯浏览器项目优先用 DOM 方案代码最少且天然支持文档顺序一旦目标平台包含 React Native或将来可能跨端尽早切换到手动清单方案——两种方案共用onSubmitInvalid触发点与formApi状态模型迁移成本可控。小结焦点管理是 TanStack Form 无头设计中留给应用层的经典案例库负责把校验结果meta.isValid、errorMap与失败时机onSubmitInvalid完整暴露给你聚焦策略由你按环境自由定制。掌握本文两个方案后你可以在任何 Preact 渲染环境中实现提交失败自动聚焦第一个错误输入并且随时可以扩展出聚焦特定区块滚动到错误位置等更多可访问性增强能力。更多相关阅读字段状态与元数据的完整字段说明见 FieldState 相关类型提交处理的完整流程见 submission-handling.md校验机制总览见 validation.md。【免费下载链接】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),仅供参考