Vben Form 表单
框架提供的表单组件,可适配 Element Plus、Ant Design Vue、Naive UI 等框架。
如果文档内没有参数说明,可以尝试在在线示例内寻找
写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
适配器
表单内部使用 TanStack Form 管理状态与校验生命周期,并使用 Zod 4 描述 schema。业务侧仍通过 useVbenForm、FormApi 和组件适配器使用表单,不应直接依赖底层 TanStack 实例。
从 Zod 3 或旧表单引擎升级时,请先阅读 Zod 4 与 TanStack Form 迁移指南。
适配器说明
每个应用都有不同的 UI 框架,所以在应用的 src/adapter/form 和 src/adapter/component 内部,你可以根据自己的需求,进行组件适配。下面是 Ant Design Vue 的适配器示例代码,可根据注释查看说明:
ant design vue 表单适配器
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { initComponentAdapter } from './component';
initComponentAdapter();
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 仅当组件不发送 update:*、只发送 change 时启用
changeEventFallback: false,
// 一些组件库空值为 null,重置表单时需要和实际组件行为保持一致
emptyStateValue: null,
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;ant design vue 组件适配器
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Component, SetupContext } from 'vue';
import { h } from 'vue';
import { globalShareState } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
AutoComplete,
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
Upload,
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
) => {
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
return h(component, { ...props, ...attrs, placeholder }, slots);
};
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };基础用法
README
下方示例代码中的,存在一些国际化、主题色未适配问题,这些问题只在文档内会出现,实际使用并不会有这些问题,可忽略,不必纠结。
使用 useVbenForm 创建最基础的表单。
<script lang="ts" setup>
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [BaseForm] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
layout: 'horizontal',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'radioGroup',
label: '单选组',
},
{
component: 'Radio',
fieldName: 'radio',
label: '',
renderComponentContent: () => {
return {
default: () => ['Radio'],
};
},
},
{
component: 'CheckboxGroup',
componentProps: {
name: 'cname',
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'checkboxGroup',
label: '多选组',
},
{
component: 'Checkbox',
fieldName: 'checkbox',
label: '',
renderComponentContent: () => {
return {
default: () => ['我已阅读并同意'],
};
},
},
{
component: 'Mentions',
componentProps: {
options: [
{
label: 'afc163',
value: 'afc163',
},
{
label: 'zombieJ',
value: 'zombieJ',
},
],
placeholder: '请输入',
},
fieldName: 'mentions',
label: '提及',
},
{
component: 'Rate',
fieldName: 'rate',
label: '评分',
},
{
component: 'Switch',
componentProps: {
class: 'w-auto',
},
fieldName: 'switch',
label: '开关',
},
{
component: 'DatePicker',
fieldName: 'datePicker',
label: '日期选择框',
},
{
component: 'RangePicker',
fieldName: 'rangePicker',
label: '范围选择器',
},
{
component: 'TimePicker',
fieldName: 'timePicker',
label: '时间选择框',
},
{
component: 'TreeSelect',
componentProps: {
allowClear: true,
placeholder: '请选择',
showSearch: true,
treeData: [
{
label: 'root 1',
value: 'root 1',
children: [
{
label: 'parent 1',
value: 'parent 1',
children: [
{
label: 'parent 1-0',
value: 'parent 1-0',
children: [
{
label: 'my leaf',
value: 'leaf1',
},
{
label: 'your leaf',
value: 'leaf2',
},
],
},
{
label: 'parent 1-1',
value: 'parent 1-1',
},
],
},
{
label: 'parent 2',
value: 'parent 2',
},
],
},
],
treeNodeFilterProp: 'label',
},
fieldName: 'treeSelect',
label: '树选择',
},
],
wrapperClass: 'grid-cols-1',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<BaseForm />
</template>查询表单
查询表单是一种特殊的表单,用于查询数据。查询表单不会触发表单验证,只会触发查询事件。
<script lang="ts" setup>
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [QueryForm] = useVbenForm({
// 默认展开
collapsed: false,
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
layout: 'horizontal',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
},
{
component: 'DatePicker',
fieldName: 'datePicker',
label: '日期选择框',
},
],
// 是否可展开
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<QueryForm />
</template>表单值编解码
当组件值与后端 payload 不一致时,使用表单级 codec 统一定义双向转换。encode 接收完整 TFormValues 并返回完整 TSubmitValues;decode 执行反向转换。多字段拆分、合并和删除都在一个纯函数边界完成,不依赖 schema 顺序或字符串路径写入。
codec 直接写在 useVbenForm 选项中即可。只需标注 encode 的表单值入参,TSubmitValues 会从返回对象自动推导,并传递给 decode、getValues() 和提交回调:
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
interface ValueFormatFormValues {
firstName?: string;
lastName?: string;
tags?: string[];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
fullName: [values.firstName, values.lastName].filter(Boolean).join(' '),
tags: (values.tags ?? []).join(','),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
const [firstName = '', ...lastNameParts] = values.fullName
.trim()
.split(/\s+/);
return {
firstName,
lastName: lastNameParts.join(' '),
tags: values.tags ? values.tags.split(',') : [],
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
handleValuesChange,
schema: [
{
component: 'Input',
fieldName: 'firstName',
help: '与姓氏一起编码为 fullName',
label: '名字',
},
{
component: 'Input',
fieldName: 'lastName',
help: '与名字一起编码为 fullName',
label: '姓氏',
},
{
component: 'Select',
componentProps: {
mode: 'multiple',
options: [
{ label: '管理员', value: 'admin' },
{ label: '审核员', value: 'reviewer' },
{ label: '访客', value: 'guest' },
],
placeholder: '请选择标签',
},
fieldName: 'tags',
help: '数组编码为逗号分隔字符串',
label: '标签',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: unknown) {
return JSON.stringify(value, null, 2);
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
async function handleSetSubmitValues() {
await formApi.setSubmitValues({
fullName: 'Ada Lovelace',
tags: 'admin,reviewer',
});
await syncPreviewValues();
message.success('已通过 codec.decode 回填提交值');
}
function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
function handleValuesChange(
values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
const rawValues = values ?? (await formApi.getRawValues());
liveValues.value = { ...rawValues };
transformedValues.value = await formApi.getValues();
}
onMounted(async () => {
await nextTick();
await syncPreviewValues();
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">多字段转换原子执行</Tag>
</div>
<Card title="Codec 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetSubmitValues">从提交值回填</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="getRawValues() 输出(组件值)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</div>
</template>性能基准
表单性能基准覆盖组件初始化、单字段与批量更新、重置、Zod 校验、动态 schema、字段联动、codec 编码与快照,以及数组字段编辑、增删和子 schema 更新。完整运行:
pnpm test:benchmark只检查表单相关基准时,可以直接指定文件:
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts基准结果用于比较同一环境、同一场景在修改前后的相对变化,不应把单次运行的绝对耗时作为跨机器阈值。运行前应停止开发服务器等高 CPU 任务,并保持 Node.js 版本一致。benchmark 文件不会进入普通 test:unit 流程。
表单校验
表单校验是一个非常重要的功能,可以通过 rules 属性进行校验。
<script lang="ts" setup>
import { message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const [Form] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
scrollToFirstError: true,
layout: 'horizontal',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入',
},
// 字段名
fieldName: 'field1',
// 界面显示的label
label: '字段1',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
defaultValue: '默认值',
fieldName: 'field2',
label: '默认值(必填)',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field3',
label: '默认值(非必填)',
rules: z.string().default('默认值').optional(),
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field31',
label: '自定义信息',
rules: z.string().min(1, { message: '最少输入1个字符' }),
},
{
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入',
},
// 字段名
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字',
rules: 'required',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
defaultValue: undefined,
fieldName: 'options',
label: '下拉选',
rules: 'selectRequired',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'radioGroup',
label: '单选组',
rules: 'selectRequired',
},
{
component: 'CheckboxGroup',
componentProps: {
name: 'cname',
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'checkboxGroup',
label: '多选组',
rules: 'selectRequired',
},
{
component: 'Checkbox',
fieldName: 'checkbox',
label: '',
renderComponentContent: () => {
return {
default: () => ['我已阅读并同意'],
};
},
rules: 'selectRequired',
},
{
component: 'DatePicker',
defaultValue: undefined,
fieldName: 'datePicker',
label: '日期选择框',
rules: 'selectRequired',
},
{
component: 'RangePicker',
defaultValue: undefined,
fieldName: 'rangePicker',
label: '区间选择框',
rules: 'selectRequired',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入',
},
fieldName: 'password',
label: '密码',
rules: 'required',
},
],
wrapperClass: 'grid-cols-1',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<Form />
</template>表单联动
表单联动是一个非常常见的功能,可以通过 dependencies 属性进行联动。
注意 需要指定 dependencies 的 triggerFields 属性,设置由谁的改动来触发,以便表单组件能够正确的联动。
新代码推荐使用 dependencies.resolve(context) 一次返回完整动态状态。它只在 triggerFields 变化时执行,并原子更新 if、show、disabled、required、rules、componentProps、help 和 renderComponentContent,避免多个异步回调产生中间状态。原有多回调结构继续兼容。
<script lang="ts" setup>
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [Form] = useVbenForm({
// 提交函数
handleSubmit: onSubmit,
schema: [
{
component: 'Input',
defaultValue: 'hidden value',
dependencies: {
show: false,
// 随意一个字段改变时,都会触发
triggerFields: ['field1Switch'],
},
fieldName: 'hiddenField',
label: '隐藏字段',
},
{
component: 'Switch',
defaultValue: true,
fieldName: 'field1Switch',
help: '通过Dom控制销毁',
label: '显示字段1',
},
{
component: 'Switch',
defaultValue: true,
fieldName: 'field2Switch',
help: '通过css控制隐藏',
label: '显示字段2',
},
{
component: 'Switch',
fieldName: 'field3Switch',
label: '禁用字段3',
},
{
component: 'Switch',
fieldName: 'field4Switch',
label: '字段4必填',
},
{
component: 'Input',
dependencies: {
if(values) {
return !!values.field1Switch;
},
// 只有指定的字段改变时,才会触发
triggerFields: ['field1Switch'],
},
// 字段名
fieldName: 'field1',
// 界面显示的label
label: '字段1',
},
{
component: 'Input',
dependencies: {
show(values) {
return !!values.field2Switch;
},
triggerFields: ['field2Switch'],
},
fieldName: 'field2',
label: '字段2',
},
{
component: 'Input',
dependencies: {
disabled(values) {
return !!values.field3Switch;
},
triggerFields: ['field3Switch'],
},
fieldName: 'field3',
label: '字段3',
},
{
component: 'Input',
dependencies: {
required(values) {
return !!values.field4Switch;
},
triggerFields: ['field4Switch'],
},
fieldName: 'field4',
label: '字段4',
},
{
component: 'Input',
dependencies: {
rules(values) {
if (values.field1 === '123') {
return 'required';
}
return null;
},
triggerFields: ['field1'],
},
fieldName: 'field5',
help: '当字段1的值为`123`时,必填',
label: '动态rules',
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
dependencies: {
resolve({ values }) {
if (values.field2 === '123') {
return {
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
},
};
}
return { componentProps: {} };
},
triggerFields: ['field2'],
},
fieldName: 'field6',
help: '当字段2的值为`123`时,更改下拉选项',
label: '动态配置',
},
],
// 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<Form />
</template>自定义组件
如果你的业务组件库没有提供某个组件,你可以自行封装一个组件,然后加到表单内部。
<script lang="ts" setup>
import { h } from 'vue';
import { Input, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [Form] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
labelClass: 'w-2/6',
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
layout: 'horizontal',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
fieldName: 'field',
label: '自定义后缀',
suffix: () => h('span', { class: 'text-red-600' }, '元'),
},
{
component: 'Input',
fieldName: 'field1',
label: '自定义组件slot',
renderComponentContent: () => ({
prefix: () => 'prefix',
suffix: () => 'suffix',
}),
},
{
component: h(Input, { placeholder: '请输入' }),
fieldName: 'field2',
label: '自定义组件',
rules: 'required',
},
{
component: 'Input',
fieldName: 'field3',
label: '自定义组件(slot)',
rules: 'required',
},
],
wrapperClass: 'grid-cols-1',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<Form>
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps" />
</template>
</Form>
</template>操作
一些常见的表单操作。
<script lang="ts" setup>
import { Button, message, Space } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [BaseForm, formApi] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 使用 tailwindcss grid布局
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
layout: 'horizontal',
// 水平布局,label和input在同一行
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'field1',
// 界面显示的label
label: 'field1',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'fieldOptions',
label: '下拉选',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
function handleClick(
action:
| 'batchAddSchema'
| 'batchDeleteSchema'
| 'disabled'
| 'hiddenAction'
| 'hiddenResetButton'
| 'hiddenSubmitButton'
| 'labelWidth'
| 'resetDisabled'
| 'resetLabelWidth'
| 'showAction'
| 'showResetButton'
| 'showSubmitButton'
| 'updateActionAlign'
| 'updateResetButton'
| 'updateSchema'
| 'updateSubmitButton',
) {
switch (action) {
case 'batchAddSchema': {
formApi.setState((prev) => {
const currentSchema = prev?.schema ?? [];
const newSchema = [];
for (let i = 0; i < 2; i++) {
newSchema.push({
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: `field${i}${Date.now()}`,
label: `field+`,
});
}
return {
schema: [...currentSchema, ...newSchema],
};
});
break;
}
case 'batchDeleteSchema': {
formApi.setState((prev) => {
const currentSchema = prev?.schema ?? [];
return {
schema: currentSchema.slice(0, -2),
};
});
break;
}
case 'disabled': {
formApi.setState({ commonConfig: { disabled: true } });
break;
}
case 'hiddenAction': {
formApi.setState({ showDefaultActions: false });
break;
}
case 'hiddenResetButton': {
formApi.setState({ resetButtonOptions: { show: false } });
break;
}
case 'hiddenSubmitButton': {
formApi.setState({ submitButtonOptions: { show: false } });
break;
}
case 'labelWidth': {
formApi.setState({
commonConfig: {
labelWidth: 150,
},
});
break;
}
case 'resetDisabled': {
formApi.setState({ commonConfig: { disabled: false } });
break;
}
case 'resetLabelWidth': {
formApi.setState({
commonConfig: {
labelWidth: 100,
},
});
break;
}
case 'showAction': {
formApi.setState({ showDefaultActions: true });
break;
}
case 'showResetButton': {
formApi.setState({ resetButtonOptions: { show: true } });
break;
}
case 'showSubmitButton': {
formApi.setState({ submitButtonOptions: { show: true } });
break;
}
case 'updateActionAlign': {
formApi.setState({
// 可以自行调整class
actionWrapperClass: 'text-center',
});
break;
}
case 'updateResetButton': {
formApi.setState({
resetButtonOptions: { disabled: true },
});
break;
}
case 'updateSchema': {
formApi.updateSchema([
{
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
},
fieldName: 'fieldOptions',
},
]);
message.success('字段 `fieldOptions` 下拉选项更新成功。');
break;
}
case 'updateSubmitButton': {
formApi.setState({
submitButtonOptions: { loading: true },
});
break;
}
}
}
</script>
<template>
<div>
<Space class="mb-5 flex-wrap">
<Button @click="handleClick('updateSchema')">updateSchema</Button>
<Button @click="handleClick('labelWidth')">更改labelWidth</Button>
<Button @click="handleClick('resetLabelWidth')">还原labelWidth</Button>
<Button @click="handleClick('disabled')">禁用表单</Button>
<Button @click="handleClick('resetDisabled')">解除禁用</Button>
<Button @click="handleClick('hiddenAction')">隐藏操作按钮</Button>
<Button @click="handleClick('showAction')">显示操作按钮</Button>
<Button @click="handleClick('hiddenResetButton')">隐藏重置按钮</Button>
<Button @click="handleClick('showResetButton')">显示重置按钮</Button>
<Button @click="handleClick('hiddenSubmitButton')">隐藏提交按钮</Button>
<Button @click="handleClick('showSubmitButton')">显示提交按钮</Button>
<Button @click="handleClick('updateResetButton')">修改重置按钮</Button>
<Button @click="handleClick('updateSubmitButton')">修改提交按钮</Button>
<Button @click="handleClick('updateActionAlign')">
调整操作按钮位置
</Button>
<Button @click="handleClick('batchAddSchema')"> 批量添加表单项 </Button>
<Button @click="handleClick('batchDeleteSchema')">
批量删除表单项
</Button>
</Space>
<BaseForm />
</div>
</template>API
useVbenForm 返回一个数组,第一个元素是表单组件,第二个元素是表单的方法。
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
// Form 为弹窗组件
// formApi 为弹窗的方法
const [Form, formApi] = useVbenForm({
// 属性
// 事件
});
</script>
<template>
<Form />
</template>类型传递与插槽
使用 useVbenForm<TFormValues, TSubmitValues> 分别声明组件表单值和提交值。schema、slots、setValues、getRawValues() 使用 TFormValues;getValues() 和 submit() 返回 Promise<TSubmitValues>,其中 submit() 只接收可选的原生 Event;handleSubmit 第一参数使用 TSubmitValues。两种结构相同时只传一个泛型即可。
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values, rawValues) {
// values: AccountFormValues
// rawValues: Readonly<AccountFormValues>
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
const values = await formApi.getValues(); // AccountFormValues
return values;
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value、componentField.modelValue 均为 string -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<!-- values: AccountFormValues -->
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>字段命名插槽提供 field、componentField、modelValue、name、disabled、isInValid、values 和 formApi。默认插槽提供 shapes、values 和 formApi;reset-before、submit-before、expand-before、expand-after 提供 values 和 formApi。未声明 TValues 时仍兼容任意字段名,但 slot props 会回退为宽泛类型。
FormApi
useVbenForm 返回的第二个参数,是一个对象,包含了一些表单的方法。
| 方法名 | 描述 | 类型 | 版本号 |
|---|---|---|---|
| submit | 提交表单 | (e?: Event) => Promise<TSubmitValues> | - |
| validateAndSubmit | 校验通过后提交表单 | () => Promise<TSubmitValues | undefined> | - |
| reset | 重置表单 | (state?: FormResetState<TFormValues>, options?: FormResetOptions) => Promise<void> | - |
| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | (fieldNames?: FormFieldName<TFormValues> | FormFieldName<TFormValues>[]) => Promise<void> | - |
| setValues | 设置表单组件值,默认会过滤不在 schema 中定义的字段 | (fields: Partial<TFormValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void> | - |
| setSubmitValues | 通过 codec.decode 回填完整提交值 | (values: TSubmitValues, filterFields?: boolean, shouldValidate?: boolean) => Promise<void> | - |
| getValues | 获取经过 codec.encode 或旧格式化管道的提交值 | () => Promise<TSubmitValues> | - |
| getRawValues | 获取未格式化的独立表单值快照 | () => Promise<TFormValues> | - |
| getValueSnapshot | 一次获取表单值和提交值 | () => Promise<FormValueSnapshot<TFormValues, TSubmitValues>> | - |
| formatValues | 编码指定的表单值快照 | (rawValues: Readonly<TFormValues>) => TSubmitValues | - |
| validate | 表单校验 | () => Promise<FormValidationResult> | - |
| validateField | 校验指定字段 | (fieldName: string) => Promise<FormValidationResult> | - |
| isFieldValid | 检查某个字段是否已通过校验 | (fieldName: string)=>Promise<boolean> | - |
| updateSchema | 更新formSchema | (schema:FormSchema[])=>void | - |
| setFieldValue | 设置字段值 | (field: string, value: any, shouldValidate?: boolean)=>Promise<void> | - |
| setState | 设置组件状态(props) | (stateOrFn:| ((prev: VbenFormProps) => Partial<VbenFormProps>)| Partial<VbenFormProps>)=>Promise<void> | - |
| getState | 获取组件状态(props) | ()=>Promise<VbenFormProps> | - |
| form | 稳定的 FormContextApi,提供 values、errors、set/reset/validate/submit 与数组字段操作,不暴露底层 TanStack 泛型 | FormContextApi | - |
| getFieldComponentRef | 获取指定字段的组件实例 | <T=unknown>(fieldName: string)=>T | >5.5.3 |
| getFocusedField | 获取当前已获得焦点的字段 | ()=>string|undefined | >5.5.3 |
旧命名 submitForm、validateAndSubmitForm、resetForm、resetValidate 分别对应 submit、validateAndSubmit、reset、clearValidation。它们仍可调用,但已标记 @deprecated,开发环境每个旧名称只警告一次,生产环境静默。
FormContextApi 响应式读取
formApi.form 提供细粒度 selector。字段组件应优先使用字段级方法,避免订阅整份 values 或 errors:
| 方法 | 返回值 | 用途 |
|---|---|---|
useFieldValue(fieldName) | Readonly<Ref<FormFieldValue>> | 订阅一个字段值。 |
useFieldValues(fieldNames) | Readonly<Ref<FormFieldValue[]>> | 订阅一组声明字段值。 |
useFieldError(fieldName) | Readonly<Ref<string | undefined>> | 订阅一个字段错误。 |
useValues() | Readonly<Ref<TValues>> | 订阅整份表单值。 |
useSelector(selector) | Readonly<Ref<TResult>> | 兼容入口,可从 { values, errors, meta } 组合选择状态。 |
const email = formApi.form.useFieldValue('email');
const emailError = formApi.form.useFieldError('email');
const submitting = formApi.form.useSelector((state) => state.meta.submitting);Props
所有属性都可以传入 useVbenForm 的第一个参数中。
| 属性名 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| layout | 表单项布局 | 'horizontal' | 'vertical'| 'inline' | horizontal |
| showCollapseButton | 是否显示折叠按钮 | boolean | false |
| wrapperClass | 表单的布局,基于tailwindcss | any | - |
| actionWrapperClass | 表单操作区域class | any | - |
| actionLayout | 表单操作按钮位置 | 'newLine' | 'rowEnd' | 'inline' | rowEnd |
| actionPosition | 表单操作按钮对齐方式 | 'left' | 'center' | 'right' | right |
| handleReset | 表单重置回调 | (values: Record<string, any>,) => Promise<void> | void | - |
| codec | 表单值与提交值的双向编解码器 | FormCodec<TFormValues, TSubmitValues> | - |
| handleSubmit | 表单提交回调 | (values: TSubmitValues, rawValues: Readonly<TFormValues>) => Promise<void> | void | - |
| handleValuesChange | 表单值变化回调 | (rawValues: Readonly<TFormValues>, fieldsChanged: string[], getFormattedValues: () => TSubmitValues) => void | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | (collapsed: boolean) => void | - |
| actionButtonsReverse | 调换操作按钮位置 | boolean | false |
| resetButtonOptions | 重置按钮组件参数 | ActionButtonOptions | - |
| submitButtonOptions | 提交按钮组件参数 | ActionButtonOptions | - |
| showDefaultActions | 是否显示默认操作按钮 | boolean | true |
| collapsed | 是否折叠,在showCollapseButton为true时生效 | boolean | false |
| collapseTriggerResize | 折叠时,触发resize事件 | boolean | false |
| collapsedRows | 折叠时保持的行数 | number | 1 |
| fieldMappingTime | 用于将表单内的数组值映射成 2 个字段 | [string, [string, string],Nullable<string>|[string,string]|((any,string)=>any)?][] | - |
| commonConfig | 表单项的通用配置,每个配置都会传递到每个表单项,表单项可覆盖 | FormCommonConfig | - |
| schema | 表单项的每一项配置 | FormSchema[] | - |
| submitOnEnter | 按下回车健时提交表单 | boolean | false |
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | boolean | false |
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | boolean | false |
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | boolean | false |
formApi.form 的挂载时机
formApi.form 是 <Form /> 挂载后注入的 FormContextApi。不要在调用 useVbenForm 时从第二个返回值中解构或缓存 form,否则会保留挂载前的空引用。业务操作优先使用 formApi 上会等待挂载的公开方法,例如 getRawValues()、setFieldError()、setFieldValue() 和 validate();只有在已经挂载的表单上下文中才直接使用 formApi.form 的细粒度订阅方法。
handleValuesChange
handleValuesChange 的第一个参数是未编码的只读 TFormValues,第二个参数是本次发生变化的 schema 字段名。第三个参数 getFormattedValues 是惰性函数:不调用就不会执行 codec 或旧格式化管道。
getRawValues() 和 getValues() 分别只生成一份目标快照;确实需要同时比较两种结构时再调用 getValueSnapshot()。handleSubmit(values, rawValues) 会在提交边界同时提供格式化结果和对应的原始快照。
旧格式化 API
schema.valueFormat、fieldMappingTime 和 arrayToStringFields 仍保持原运行时行为,但已经标记为 @deprecated,开发环境首次使用时会提示迁移。配置 codec 后只执行 codec;同时存在的旧配置会被忽略,避免重复转换。
TS 类型说明
ActionButtonOptions
export interface ActionButtonOptions {
/** 样式 */
class?: ClassType;
/** 是否禁用 */
disabled?: boolean;
/** 是否加载中 */
loading?: boolean;
/** 按钮大小 */
size?: ButtonVariantSize;
/** 按钮类型 */
variant?: ButtonVariants;
/** 是否显示 */
show?: boolean;
/** 按钮文本 */
content?: string;
/** 任意属性 */
[key: string]: any;
}FormCommonConfig
export interface FormCommonConfig {
/**
* 仅当组件不发送 update:*、只发送 change 时启用兼容回退
* @default false
*/
changeEventFallback?: boolean;
/**
* 所有表单项的props
*/
componentProps?: ComponentProps;
/**
* 所有表单项的控件样式
*/
controlClass?: string;
/**
* 在表单项的Label后显示一个冒号
*/
colon?: boolean;
/**
* 所有表单项的禁用状态
* @default false
*/
disabled?: boolean;
/**
* 所有表单项的控件样式
* @default {}
*/
formFieldProps?: FormFieldOptions;
/**
* 所有表单项的栅格布局
* @default ""
*/
formItemClass?: (() => string) | string;
/**
* 隐藏所有表单项label
* @default false
*/
hideLabel?: boolean;
/**
* 是否隐藏必填标记
* @default false
*/
hideRequiredMark?: boolean;
/**
* 所有表单项的label样式
* @default ""
*/
labelClass?: string;
/**
* 所有表单项的label宽度
*/
labelWidth?: number;
/**
* 所有表单项的model属性名。使用自定义组件时可通过此配置指定组件的model属性名。已经在modelPropNameMap中注册的组件不受此配置影响
* @default "modelValue"
*/
modelPropName?: string;
/**
* 所有表单项的wrapper样式
*/
wrapperClass?: string;
}FormSchema
export interface FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> extends FormCommonConfig {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?:
| MaybeComponentProps
| ((ctx: FormSchemaContext<TValues>) => MaybeComponentProps);
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
/** 描述 */
description?: string;
/** 字段名,也作为自定义插槽的名称 */
fieldName: string;
/** 帮助信息 */
help?: string | ((ctx: FormSchemaContext<TValues>) => Component | string);
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单的标签(如果是一个string,会用于默认必选规则的消息提示) */
label?: CustomRenderType;
/** 自定义组件内部渲染 */
renderComponentContent?: (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/** @deprecated 使用表单级 codec */
valueFormat?: FormValueFormat;
}顶层 componentProps、help 和 renderComponentContent 函数只接收轻量 FormSchemaContext,适合数组行索引、字段路径等 schema 信息。需要读取表单值时,使用 dependencies.resolve({ values, ... }),避免每个字段订阅整份 values。
FormValueFormat
FormValueFormat 是兼容类型,已标记为 @deprecated。新代码应使用 FormCodec<TFormValues, TSubmitValues>。
type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;- 返回
undefined:保持当前字段已被移除 - 返回其他值:将当前字段恢复/写回为该值
setValue(fieldName, value):用于把一个字段拆分写入其他字段
表单联动
表单联动需要通过 schema 内的 dependencies 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。
dependencies: {
triggerFields: ['type', 'role'],
resolve({ values, actions, controller, schema }) {
const editable = values.type === 'editable';
return {
componentProps: { placeholder: schema.fieldName },
disabled: !editable,
required: values.role === 'owner',
rules: editable ? 'required' : null,
show: values.type !== 'hidden',
};
},
}resolve 返回的字段会一次性提交;支持 if、show、disabled、required、rules、componentProps、help 和 renderComponentContent。未返回 rules 时继续使用静态规则,显式返回 rules: null 时关闭静态规则。actions 是稳定的 FormContextApi,controller 是高层 FormApi,schema 包含字段名和数组行上下文。
旧的 if/show/disabled/required/rules/componentProps/trigger 回调语法仍完整兼容并保持原求值顺序,但已标记为 @deprecated,开发环境首次使用时会提示迁移。新旧语法在同一个 dependencies 对象中互斥;绕过类型同时传入时以 resolve 为准。
表单校验
表单校验需要通过 schema 内的 rules 属性进行配置。
字段默认在 blur、change 和 submit 时校验。使用 formFieldProps.validateOn 限制交互触发时机,submit 始终校验;异步校验可通过 asyncDebounceMs 防抖:
formFieldProps: {
asyncDebounceMs: 300,
validateOn: ['blur'],
}rules的值可以是字符串(预定义的校验规则名称),也可以是一个zod的schema。
预定义的校验规则
// 表示字段必填,默认会根据适配器的required进行国际化
{
rules: 'required';
}
// 表示字段必填,默认会根据适配器的required进行国际化,用于下拉选择之类
{
rules: 'selectRequired';
}zod
rules也支持 zod 的 schema,可以进行更复杂的校验,zod 的使用请查看 zod文档。
import { z } from '#/adapter/form';
// 基础类型
{
rules: z.string().min(1, { message: '请输入字符串' });
}
// 可选(可以是undefined),并且携带默认值。注意zod的optional不包括空字符串''
{
rules: z.string().default('默认值').optional();
}
// 可以是空字符串、undefined或者一个邮箱地址(两种不同的用法)
{
rules: z.union([z.string().email().optional(), z.literal('')]);
}
{
rules: z.string().email().or(z.literal('')).optional();
}
// 复杂校验
{
z.string()
.min(1, { message: '请输入' })
.refine((value) => value === '123', {
message: '值必须为123',
});
}Slots
可以使用以下插槽在表单中插入自定义的内容
| 插槽名 | 描述 |
|---|---|
| reset-before | 重置按钮之前的位置 |
| submit-before | 提交按钮之前的位置 |
| expand-before | 展开按钮之前的位置 |
| expand-after | 展开按钮之后的位置 |
字段插槽
除了以上内置插槽之外,schema属性中每个字段的fieldName都可以作为插槽名称,这些字段插槽的优先级高于component定义的组件。也就是说,当提供了与fieldName同名的插槽时,这些插槽的内容将会作为这些字段的组件,此时component的值将会被忽略。
贡献者
Netfan
vben
dream-weave
xingyu4j
谦元吉
xueyang
superdl1996
huangxiaomin
vince
Jin Mao
Li Kui