表单
VEF 的表单能力主要来自 @vef-framework-react/components,两个主要入口是:
useForm()useFormContext()
可以把它理解为对 TanStack Form 的一层面向企业级场景的 UI 封装。完整的字段组件列表和所有共享的 FormItemProps 字段见 Form 表单;本篇聚焦于一个表单是如何被组装起来的。
典型用法
import { Grid, useCodeSetOptionsSelect, useFormContext } from "@vef-framework-react/components";
import { z } from "@vef-framework-react/shared";
const validators = {
name: z.string("Required").min(2, "At least 2 characters"),
gender: z.string("Required")
};
export function UserForm() {
const { AppField } = useFormContext<{ name: string; gender: string }>();
const { gender } = useCodeSetOptionsSelect({
gender: "common.gender"
});
return (
<Grid columnGap="small">
<Grid.Item span={12}>
<AppField name="name" validators={{ onBlur: validators.name }}>
{field => <field.Input required label="Name" />}
</AppField>
</Grid.Item>
<Grid.Item span={12}>
<AppField name="gender" validators={{ onChange: validators.gender }}>
{field => <field.Select {...gender} required label="Gender" />}
</AppField>
</Grid.Item>
</Grid>
);
}
useForm() 创建表单实例
import { useForm } from "@vef-framework-react/components";
const {
Form,
AppField,
SubmitButton
} = useForm({
defaultValues: {
keyword: ""
},
onSubmit({ value }) {
console.log(value);
}
});
useFormContext() 更适合拆分后的表单
当一个表单被拆分成多个独立组件时,通常不需要再创建一个新的表单实例,而是可以从上下文中取到 AppField:
const { AppField } = useFormContext<FormValues>();
一旦表单规模超出单个组件,这就是应该采用的模式——搜索栏、场景相关的表单主体、操作按钮区的页脚,都可以从同一个表单上下文中取出 AppField,而不必通过 props 层层传递表单状态。
标签布局:FormLayout
每个字段包装器都从共享的表单布局上下文中读取标签摆放方式。三个字段——layout(默认 "horizontal")、labelAlign("right")和 labelWidth(100)——只需在 Form 组件上设置一次,就会应用到内部的每个字段:
<Form layout="vertical">
{/* every AppField renders its label above the control */}
</Form>
当 Form 元素不由你渲染时——在 FormModal、FormDrawer 或 CRUD 场景表单内部——改为通过 formLayout 属性传入同样的三个字段(v2.12.0 新增):
<FormDrawer formLayout={{ layout: "vertical" }} ... />
<CrudPage formLayout={{ layout: "vertical" }} ... />
对于较窄的抽屉,纵向标签是常规选择——100px 的标签列会占掉太多宽度。FormLayout 类型从 @vef-framework-react/components 导出。
用 z 做校验
import { z } from "@vef-framework-react/shared";
const validators = {
username: z.string("Required").min(2, "At least 2 characters").max(16, "At most 16 characters"),
email: z.email().nullish()
};
@vef-framework-react/shared 重新导出了一个已按项目配置好的 Zod 实例,因此校验器无需额外依赖即可与代码库的其余部分保持一致。
向选项类字段中灌入数据
VEF 应用通常会避免在字段组件内部发起请求。取而代之的是,用 hook 生成可以直接展开到控件上的 props,让数据来源和字段渲染各自成为独立的关注点。
Select
const roleSelectProps = useDataOptionsSelect({
filterable: true,
queryOptions: {
queryKey: [findRoleOptions.key],
queryFn: findRoleOptions
}
});
Tree Select
const deptTreeSelectProps = useDataOptionsTreeSelect({
filterable: true,
queryOptions: {
queryKey: [findDepartmentTree.key],
queryFn: findDepartmentTree
}
});
码集
const { gender } = useCodeSetOptionsSelect({
gender: "common.gender"
});
// <field.Select {...gender} />
useCodeSetOptionsSelect(keys, options?) 返回一个 { alias: SelectProps } 映射。传入 { filterable: true }(或把别名的值换成按键配置 { key: "...", filterable: true })可以启用拼音搜索——底层数据从哪里来、键名如何变成类型化的联合类型,见 码集。
字段依赖与联动
由于表单层底层就是 TanStack Form,一个字段响应另一个字段不需要额外的机制。三种手段覆盖了常见场景:
Subscribe—— 当表单状态中被选中的部分发生变化时,重新渲染一小块 UI;用它来根据另一个字段的值显示、隐藏、禁用某个字段或改变其必填性。AppField上的listeners—— 在字段值变化时执行副作用;用它来重置或派生依赖字段的值。useFormStore()——Subscribe的 hook 形式,适用于整个组件都需要该值的情况。
响应另一个字段的变化
Subscribe 和 AppField 来自同一个地方——useForm() 或 useFormContext()。它的 selector 决定什么会触发重新渲染,因此在无关字段中输入不会重新渲染被订阅的区块:
const { AppField, Subscribe } = useFormContext<MenuFormValues>();
<AppField name="path">
{field => (
<Subscribe selector={state => state.values.type}>
{type => (
<field.Input
disabled={type === "button"}
label="Path"
required={type !== "button"}
/>
)}
</Subscribe>
)}
</AppField>
同一个组件也可以用在 AppField 之外,用于显示或隐藏一整个区块:
<Subscribe selector={state => state.values.orgId}>
{orgId => orgId && <DepartmentSection orgId={orgId} />}
</Subscribe>
保持 selector 尽量窄——用 state.values.type 而不是 state.values——这样该区块只会在它真正依赖的值变化时重新渲染。
派生与重置字段值
AppField 接受一个 listeners prop。onChange 监听器会收到新的 value 和 fieldApi,后者的 form 属性可以触达表单的其余部分——form.resetField() 把依赖字段恢复为默认值,form.setFieldValue() 写入派生值(它也接受更新函数 prev => next):
<AppField
name="quantity"
listeners={{
onChange: ({ value, fieldApi: { form } }) => {
form.setFieldValue("total", (value ?? 0) * form.state.values.unitPrice);
}
}}
>
{field => <field.InputNumber required label="Quantity" />}
</AppField>
与 validators 不同,listeners 只用于副作用——它们不会产生错误信息。
选项级联
级联把上面两种机制结合起来:控制字段在变化时重置它的依赖字段,依赖字段的选项查询以控制字段的值作为 key。
function DepartmentField({ orgId }: { orgId?: string }) {
const { AppField } = useFormContext<StaffFormValues>();
const deptTreeSelectProps = useDataOptionsTreeSelect({
filterable: true,
queryOptions: {
queryKey: [findDepartmentTree.key, { orgId }],
queryFn: findDepartmentTree,
enabled: Boolean(orgId)
}
});
return (
<AppField name="deptId">
{field => <field.TreeSelect {...deptTreeSelectProps} label="Department" />}
</AppField>
);
}
由父组件把两个字段串起来:
<AppField
name="orgId"
listeners={{
onChange: ({ fieldApi: { form } }) => form.resetField("deptId")
}}
>
{field => <field.TreeSelect {...orgTreeSelectProps} required label="Organization" />}
</AppField>
<Subscribe selector={state => state.values.orgId}>
{orgId => <DepartmentField orgId={orgId} />}
</Subscribe>
因为 orgId 是 queryKey 的一部分,切换机构会自动重新拉取部门选项(enabled 让查询在选定机构之前保持空闲),而 listeners 中的重置则保证过期的 deptId 不会在切换后残留。
在 Hook 代码中订阅
当需要在普通的 hook 代码而不是 JSX 中使用该值时,useFormStore(form.store, selector) 拥有与 Subscribe 相同的 selector 语义:
import { useForm, useFormStore } from "@vef-framework-react/components";
const form = useForm({
defaultValues: { type: "menu", path: "" }
});
const type = useFormStore(form.store, state => state.values.type);
如果想把一个完全自定义的控件接入依赖字段,见自定义表单组件。
为什么要做这种拆分
把数据来源(useDataOptionsSelect()、useCodeSetOptionsSelect() 等)与字段渲染(AppField)和布局(Grid)分开,意味着:
- 选项加载逻辑可以在新建表单、编辑表单和搜索栏之间复用
- 字段组件永远不需要知道自己的选项到底来自码集、查询还是树形数据
- 更换某个字段的数据来源不会影响它的布局或校验逻辑
完整的可用字段组件列表(field.Input、field.Select、field.Bool 等)见 Form 表单。