深入解析 Preact Form 的 UseFieldOptions:字段选项的类型契约与源码实现
【免费下载链接】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
导读
UseFieldOptions是@tanstack/preact-form中useFieldHook 与Field组件的核心选项类型。它决定了 Preact 应用中每一个表单字段如何声明名称、默认值、验证器与事件监听器,并额外引入了mode选项来区分"普通值字段"与"数组字段"两种渲染模式。读完本文,你将完整掌握UseFieldOptions的全部类型参数、可配置属性及其默认行为,并能从 packages/preact-form/src/types.ts 与 packages/preact-form/src/useField.tsx 的源码层面理解这些选项在运行时是如何被消费的。
UseFieldOptions 是什么
UseFieldOptions在源码中定义于 packages/preact-form/src/types.ts:21,官方注释只有一句话:"The field options"(字段选项)。它是useFieldHook 的参数类型,同时被Field组件的 props 类型复用,是整个 Preact 适配层与底层form-core交互的"字段配置契约"。
从类型结构看,它并非从零定义,而是组合了两个来源:
export interface UseFieldOptions<...22 个泛型参数...> extends FieldApiOptions<...>, FieldOptionsMode {}其中:
FieldApiOptions来自@tanstack/form-core(定义于 packages/form-core/src/FieldApi.ts:383),提供了字段的名称、默认值、异步验证防抖、验证器、监听器等全部核心配置;FieldOptionsMode是 Preact 适配层自己声明的本地接口(packages/preact-form/src/types.ts:14),只新增了一个可选属性mode。
在 docs/framework/preact/reference/interfaces/UseFieldOptions.md 的文档页面中,mode被标注为唯一的直属属性,其余所有配置均以 "Inherited from"(继承自)的形式来自FieldApiOptions,这正是文档把属性表拆分成"直属"与"继承"两部分的底层原因。
为什么需要 22 个泛型参数
UseFieldOptions的 22 个泛型参数并非摆设,它们分别刻画了字段自身与所属表单两条链路上的生命周期验证函数类型:
| 泛型参数 | 约束 | 含义 |
|---|---|---|
TParentData | 无约束 | 父级表单数据的类型,即整个表单defaultValues的类型 |
TName | extends DeepKeys<TParentData> | 字段名,必须是父数据的"深键",保证name不会越界 |
TData | extends DeepValue<TParentData, TName> | 该字段的值类型,由TParentData与TName推导 |
TOnMount/TOnChange/TOnBlur/TOnSubmit/TOnDynamic | extends undefined \| FieldValidateOrFn<TParentData, TName, TData> | 字段各生命周期事件的同步验证函数 |
TOnChangeAsync/TOnBlurAsync/TOnSubmitAsync/TOnDynamicAsync | extends undefined \| FieldAsyncValidateOrFn<TParentData, TName, TData> | 字段各生命周期事件的异步验证函数 |
TFormOnMount至TFormOnServer(共 10 个) | extends undefined \| FormValidateOrFn<TParentData>或FormAsyncValidateOrFn<TParentData> | 所属表单链路上的同步/异步验证函数 |
TSubmitMeta | 无约束 | 表单提交时的元数据类型 |
这种"字段链路 + 表单链路"双层泛型设计,使得useField创建出的FieldApi实例能够把字段级验证与表单级验证的类型信息完整串联起来,保证validators中每个回调的参数与返回值都被精确约束。
mode 属性:value 与 array 两种字段模式
mode是UseFieldOptions唯一直接声明的属性,也是最容易被忽略却对渲染性能影响巨大的开关:
interface FieldOptionsMode { mode?: 'value' | 'array' }- 默认缺省为
'value':字段被当作普通标量值处理; - 显式设置为
'array':字段被当作数组处理,用于动态增删条目的场景。
数组模式下 useField 的特殊响应式处理
mode的值直接改变了useField内部的响应式订阅策略。在 packages/preact-form/src/useField.tsx:191 中:
const reactiveStateValue = useSelector( fieldApi.store, (opts.mode === 'array' ? (state) => state.meta._arrayVersion || 0 : (state) => state.value) as (state: typeof fieldApi.state) => TData | number, )- 普通模式下,Hook 订阅
state.value,值一变即触发重渲染; - 数组模式下,Hook 只订阅
state.meta._arrayVersion(一个随数组结构变化而递增的版本号)。源码注释明确说明了这样做的原因:"For array mode, only track length changes to avoid re-renders when child properties change",并引用了 TanStack Form 的 issue #1925。
也就是说,当你在数组模式下列表项内部修改某个子字段的值时,父级数组字段不会因为数组内部对象的属性变化而整体重渲染,只有执行pushValue、removeValue这类改变数组长度/结构的操作时才会触发。这正是 Preact Form 在大列表表单中保持流畅的关键实现细节。
与此同时,返回值也做了适配(useField.tsx:230):数组模式下暴露给渲染层的state.value仍然取自fieldApi.state.value(真实数组),而响应式追踪则依赖版本号,两者职责分离。
数组模式的官方用法
Preact 指南 docs/framework/preact/guides/arrays.md 展示了mode="array"的完整用法:
function App() { const form = useForm({ defaultValues: { people: [], }, onSubmit({ value }) { alert(JSON.stringify(value)) }, }) return ( <div> <form.Field name="people" mode="array"> {(field) => { return ( <div> {field.state.value.map((_, i) => ( <div key={i}> <form.Field key={i} name={`people[${i}].name`}> {(subField) => ( <input value={subField.state.value} onInput={(e) => subField.handleChange(e.target.value)} /> )} </form.Field> <button onClick={() => field.removeValue(i)} type="button" > 删除 </button> </div> ))} <button onClick={() => field.pushValue({ name: '' })} type="button" > 添加成员 </button> </div> ) }} </form.Field> </div> ) }注意两点:外层数组字段必须显式传入mode="array";内层子字段使用people[${i}].name这种深键语法访问数组元素,且渲染列表项时需要借助key保证 Preact 的 diff 正确性。
继承自 FieldApiOptions 的必填属性:name 与 form
UseFieldOptions直接继承的FieldApiOptions中,有两个必填属性,是所有字段都绕不开的:
| 属性 | 类型 | 说明 |
|---|---|---|
name | TName | 字段名。类型被约束为DeepKeys<TParentData>,保证 name 一定是父数据的合法深键(见 packages/form-core/src/types.ts:970) |
form | FormApi<...> | 字段所属的表单实例,由useForm创建并传入 |
正因为name的类型是DeepKeys<TParentData>,写错字段名会在编译期直接报错,而不是等到运行时才暴露 —— 这是 TanStack Form "type-safe" 定位的核心体现。在useField的实现中,form与name还被特殊对待:Hook 内部用useState快照了这两个值,只有当它们变化时才重建FieldApi实例(packages/preact-form/src/useField.tsx:180),其余选项则交给每次渲染都会执行的fieldApi.update(opts)热更新。
可选配置:默认值、异步防抖与错误处理
继承自FieldLikeApiOptions(定义于 packages/form-core/src/types.ts:1021)的五个可选属性,控制着字段的初始化与异步验证策略:
| 属性 | 类型 | 默认行为 / 说明 |
|---|---|---|
defaultValue | NoInfer<TData> | 字段的默认值。NoInfer包装意味着类型推断方向唯一,避免 TS 因双向推断产生歧义 |
asyncDebounceMs | number | 异步验证的默认防抖毫秒数。仅在没有更具体的防抖设置(如onChangeAsyncDebounceMs)时生效 |
asyncAlways | boolean | 设为true时,即使同步验证已经产生错误,也仍然执行异步验证 |
defaultMeta | Partial<FieldLikeMeta<...>> | 字段初始元数据,可预置isTouched、isDirty等状态 |
disableErrorFlat | boolean | 关闭field.errors上的flat(1)拍平操作。默认开启拍平;除非需要保留嵌套错误结构,否则不建议开启(源码注释原话) |
其中asyncDebounceMs与asyncAlways直接对应form-core中异步验证的执行时机:防抖用于合并高频输入期间的连续验证请求;asyncAlways则用于"同步校验通过后仍需要后端异步确认"(如用户名查重)的场景。
validators:字段生命周期验证器集合
validators属性类型为FieldValidators(定义于 packages/form-core/src/FieldApi.ts:307),它是字段配置中最常使用的部分,完整属性如下:
| 属性 | 类型 | 触发时机 |
|---|---|---|
onMount | 同步验证函数 | 字段挂载时 |
onChange | 同步验证函数 | 字段值变化时 |
onChangeAsync | 异步验证函数 | 字段值变化时(异步) |
onChangeAsyncDebounceMs | number | 仅当大于 0 时生效,按毫秒数防抖onChangeAsync |
onChangeListenTo | DeepKeys<TParentData>[] | 监听其他字段名列表,当这些字段的值变化时触发本字段的onChange/onChangeAsync |
onBlur | 同步验证函数 | 字段失焦时 |
onBlurAsync | 异步验证函数 | 字段失焦时(异步) |
onBlurAsyncDebounceMs | number | 防抖onBlurAsync的毫秒数 |
onBlurListenTo | DeepKeys<TParentData>[] | 监听其他字段,触发本字段的onBlur/onBlurAsync |
onSubmit | 同步验证函数 | 表单提交时 |
onSubmitAsync | 异步验证函数 | 表单提交时(异步) |
onSubmitAsyncDebounceMs | number | 防抖onSubmitAsync的毫秒数 |
onDynamic | 同步验证函数 | 字段动态变化时(如insertValue等结构变更) |
onDynamicAsync | 异步验证函数 | 字段动态变化时(异步) |
onDynamicAsyncDebounceMs | number | 防抖onDynamicAsync的毫秒数 |
其中onChangeListenTo与onBlurListenTo是实现"关联字段校验"(linked fields)的关键:例如当"确认密码"字段需要监听"密码"字段的变化时,只需在onChangeListenTo中声明'password',即可在密码变化时自动重跑确认密码的校验。
参考官方示例 examples/preact/simple/src/index.tsx 中同步验证器的典型写法:
<form.Field name="firstName" validators={{ onChange: ({ value }) => !value ? 'A first name is required' : value.length < 3 ? 'First name must be at least 3 characters' : undefined, }} children={(field) => ( <> <label htmlFor={field.name}>First Name:</label> <input id={field.name} name={field.name} value={field.state.value} onBlur={field.handleBlur} onInput={(e) => field.handleChange(e.currentTarget.value)} /> <FieldInfo field={field} /> </> )} />验证器返回undefined表示通过,返回字符串(或错误对象数组)表示失败。同时注意示例中的FieldInfo组件通过field.state.meta.isTouched、isValid、isValidating等元数据渲染错误提示,这些元数据正是由 packages/form-core/src/FieldApi.ts 在每次验证后写入字段 store 的。
listeners:事件监听器
除验证器外,FieldApiOptions还继承了一个listeners属性(packages/form-core/src/FieldApi.ts:325),类型为FieldListeners<TParentData, TName, TData>:
listeners?: FieldListeners<TParentData, TName, TData>它允许你在不修改组件渲染逻辑的前提下,为字段的onChange、onBlur、onMount、onSubmit等事件挂载副作用监听器。与validators的区别在于:listeners关注"事件发生时的副作用"(如打点上报、联动其他字段状态),而validators关注"事件发生时的校验结果"。两者共享同一套事件触发时机,可以同时配置。
useField 如何消费 UseFieldOptions
理解了配置项之后,再来看 packages/preact-form/src/useField.tsx:104 中这些选项的完整消费链路:
- 实例化:
useState中通过new FieldApi({ ...opts })创建底层FieldApi实例,所有UseFieldOptions被展开传入; - 按需重建:仅当
form或name变化时重建实例,避免不必要的对象销毁; - 响应式订阅:按
mode选择订阅state.value或state.meta._arrayVersion,并单独订阅isTouched、isBlurred、isDirty、errorMap、errorSourceMap、isValidating等元数据(useField.tsx:199); - 暴露扩展实例:通过
useMemo构造一个 getter 化的extendedFieldApi,其state每次访问都会返回最新的响应式值,保证 Preact 渲染阶段总能读到最新状态; - 挂载与热更新:
useIsomorphicLayoutEffect(fieldApi.mount)负责挂载,随后每个渲染周期调用fieldApi.update(opts),将最新的验证器、监听器等选项同步进实例。
Field组件(同文件 packages/preact-form/src/useField.tsx:637)正是useField的封装:它解构出children后把剩余选项原样交给useField,再将返回的fieldApi作为参数传入渲染函数。
UseFieldOptions 与 UseFieldOptionsBound 的区别
在 packages/preact-form/src/types.ts 中还存在一个同族的UseFieldOptionsBound接口(第 82 行),二者区别在于:
| 接口 | 继承来源 | 使用位置 |
|---|---|---|
UseFieldOptions | FieldApiOptions+FieldOptionsMode | useFieldHook 的参数(含表单链路全部泛型) |
UseFieldOptionsBound | FieldOptions+FieldOptionsMode | 由createFormHook绑定的Field组件 props,表单链路类型已从 Form 实例预绑定,因此不再需要 10 个TForm*泛型 |
简单说:独立使用useField时你需要显式携带完整的 22 个泛型上下文;而通过form.Field组件使用时,Field已经知道所属form的类型,泛型被"绑定"住了,这也是UseFieldOptionsBound更短、更易用的原因。
完整实战:把 UseFieldOptions 的全部能力组合起来
结合前面所有属性,一个覆盖mode、验证器、异步防抖与监听器的完整示例:
import { useForm } from '@tanstack/preact-form' interface User { firstName: string password: string confirmPassword: string hobbies: Array<string> } function App() { const form = useForm({ defaultValues: { firstName: '', password: '', confirmPassword: '', hobbies: [], } satisfies User, onSubmit: async ({ value }) => { console.log(value) }, }) return ( <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() void form.handleSubmit() }} > {/* 普通值字段 + 同步验证 + 异步防抖验证 */} <form.Field name="firstName" validators={{ onChange: ({ value }) => value.length < 2 ? '姓名至少 2 个字符' : undefined, onChangeAsyncDebounceMs: 300, onChangeAsync: async ({ value }) => { await new Promise((r) => setTimeout(r, 200)) return value.includes('x') ? '不能包含 x' : undefined }, }} children={(field) => ( <input value={field.state.value} onInput={(e) => field.handleChange(e.currentTarget.value)} /> )} /> {/* 关联字段校验:确认密码监听密码的变化 */} <form.Field name="confirmPassword" validators={{ onChangeListenTo: ['password'], onChange: ({ value, fieldApi }) => value !== fieldApi.form.state.values.password ? '两次输入的密码不一致' : undefined, }} children={(field) => ( <input type="password" value={field.state.value} onInput={(e) => field.handleChange(e.currentTarget.value)} /> )} /> {/* 数组字段:mode="array" */} <form.Field name="hobbies" mode="array" children={(field) => ( <div> {field.state.value.map((_, i) => ( <span key={i}> <form.Field name={`hobbies[${i}]`}> {(subField) => ( <input value={subField.state.value} onInput={(e) => subField.handleChange(e.currentTarget.value)} /> )} </form.Field> <button type="button" onClick={() => field.removeValue(i)} > 删除 </button> </span> ))} <button type="button" onClick={() => field.pushValue('')} > 添加爱好 </button> </div> )} /> </form> ) }这个示例中:
onChangeAsyncDebounceMs: 300让输入停止 300ms 后才发起异步校验,避免每次击键都触发;onChangeListenTo: ['password']让确认密码字段在密码变化时自动重校验;mode="array"的 hobbies 字段只追踪数组结构版本号,编辑某个爱好不会引发整列表重渲染。
小结与延伸阅读
UseFieldOptions是@tanstack/preact-form字段层的类型总纲:它以FieldApiOptions为骨架(名称、默认值、防抖、验证器、监听器),以FieldOptionsMode.mode为 Preact 适配层的点睛之笔,让普通值字段与数组字段共享同一套配置模型、却拥有完全不同的响应式渲染策略。理解它,就理解了useField与Field组件背后的全部配置语义。
想继续深入,可以在当前仓库中查阅:
- 接口源码:packages/preact-form/src/types.ts(
UseFieldOptions与FieldOptionsMode的完整定义) - Hook 实现:packages/preact-form/src/useField.tsx(
mode与响应式订阅的运行时行为) - 底层基类:packages/form-core/src/FieldApi.ts(
FieldApiOptions、FieldOptions、FieldValidators定义) - 类型约束:packages/form-core/src/types.ts(
FieldLikeApiOptions中defaultValue、asyncDebounceMs等属性) - 完整可运行示例:examples/preact/simple/src/index.tsx(
useForm+form.Field的最小表单) - 数组模式指南:docs/framework/preact/guides/arrays.md
- 基础概念指南:docs/framework/preact/guides/basic-concepts.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),仅供参考