Painless Headless Form Validation for Vue with VeeValidate

轻松引入的 Vue 无头表单验证:VeeValidate

前言

最近在迁移旧的 Element Plus🔗 项目时,需要寻找另一款客户端表单验证方案。在使用 Shadcn Vue 的过程中,我发现 VeeValidate🔗 是一款专为 Vue 打造的无头表单验证工具,你可以集成自己的 UI,使用原生表单组件也完全没有问题。

  • 无头表单库(只负责验证逻辑,不绑定 UI),自带 UI 时不会受现成组件限制
  • 与 Vue 组件和 Composition API 深度集成
  • Shadcn Vue🔗 提供了许多封装完善、可以直接使用的组件与样式
  • 支持 TypeScript 与 Yup🔗、Zod🔗、Valibot🔗

基础示例

vee-validate 提供两种验证表单的方式:

  • Higher-order components (HOC)
  • Composition API

Component

通过 Field🔗 和 Form🔗 现成组件(默认渲染原生表单 HTML):

<template>
<Form :validation-schema="validationSchema" @submit="onSubmit">
<Field name="email" type="email" />
<ErrorMessage name="email" />
<Field name="password" type="password" />
<ErrorMessage name="password" />
<button>提交</button>
</Form>
</template>
<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as zod from 'zod';
const validationSchema = toTypedSchema(
zod.object({
email: zod.string().min(1, { message: '此项为必填项' }).email({ message: '必须是有效的电子邮箱' }),
password: zod.string().min(1, { message: '此项为必填项' }).min(8, { message: '长度太短' }),
})
);
function onSubmit(values) {
alert(JSON.stringify(values, null, 2));
}
</script>

我们可以随时替换组件中的状态,并通过 slot props🔗 将其传递回来,显示在自定义输入组件中:

<template>
<Field v-model="name" type="text" name="name" v-slot="{ field }">
<Input v-bind="field">
</Field>
<template>

这也解释了为什么 Shadcn Vue 表单字段🔗 会封装成这样:

<template>
<FormField v-slot="{ componentField }">
<FormItem>
<FormLabel>用户名</FormLabel>
<FormControl>
<Input placeholder="shadcn" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
</FormField>
</template>

Composition API

有时也需要更灵活地控制表单,例如:异步设置表单初始值🔗。由于 initialValues 只能在表单初始化时设置,无法使用响应式值,因此在「多步骤表单」或「需要获取异步数据的表单」中,通常需要使用 useForm 预先定义表单,再通过获得的 setValues 方法修改该表单中的值,例如在打开 Dialog 时初始化表单值。

<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent>
<DialogHeader class="mb-2">
<DialogTitle>编辑 Foo Bar</DialogTitle>
<DialogDescription class="sr-only">编辑 Foo Bar</DialogDescription>
</DialogHeader>
<form class="flex flex-col gap-4" id="testSendForm" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="foo">
<FormItem>
<FormLabel>Foo</FormLabel>
<FormControl>
<Input type="text" placeholder="请输入 foo" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bar">
<FormItem>
<FormLabel>Bar</FormLabel>
<FormControl>
<Input type="text" placeholder="请输入 bar" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button :disabled="isPending" form="testSendForm" @click="onSubmit">
{{ isSendTestMailPending ? '正在编辑...' : '编辑' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
open: boolean;
defaultFormValue: DefaultFormValue
}>()
const emit = defineEmits(['update:open'])
const { open } = toRefs(props)
const testSendSchema = z.object({
foo: z.string().min(1, '请输入 Foo'),
bar: z.string().min(1, '请输入 Bar'),
})
type TestSendSchema = z.infer<typeof testSendSchema>
const { handleSubmit, setValues } = useForm<TestSendSchema>({
validationSchema: toTypedSchema(testSendSchema),
})
const onSubmit = handleSubmit(async (submittedValue: TestSendSchema) => {
await sendTestMail({
...submittedValue,
})
emit('update:open', false)
})
// 打开 Dialog 时初始化表单
watch(open, (isOpen) => {
if (isOpen && props.defaultFormValue) {
setValues({
foo: props.defaultFormValue.foo,
bar: props.defaultFormValue.bar,
})
}
})
</script>

复杂案例

多步骤表单

官方提供了 Multi-step Form Wizard🔗 示例,其核心概念其实只是将 validation-schema 替换为动态值,根据 step 切换当前需要验证的内容。直接使用 Shadcn Vue 实现的现成方案🔗 最快捷省事。

<script setup lang="ts">
const currentSchema = computed(() => {
return schemas[currentStep.value];
});
</script>
<template>
<Form
@submit="nextStep"
:validation-schema="currentSchema"
keep-values
>
</template>

动态添加字段

假设有一个字段,希望能够自由改变输入内容的数量,例如可以填写 1 个链接或 5 个链接,具体数量由用户决定。

<FieldArray /> 组件用于管理可重复的数组字段。它是一个无渲染组件,也就是说,它本身不会渲染任何内容:

<template>
<Form @submit="onSubmit" :initial-values="initialValues">
<FieldArray name="links" v-slot="{ fields, push, remove }">
<div v-for="(field, idx) in fields" :key="field.key">
<Field :name="`links[${idx}].url`" type="url" />
<button type="button" @click="remove(idx)">删除</button>
</div>
<button type="button" @click="push({ id: Date.now(), name: '', url: '' })">添加</button>
</FieldArray>
<button>提交</button>
</Form>
</template>
<script setup>
// 可以为这些数组字段设置初始值
const initialValues = {
links: [{ id: 1, url: 'https://github.com/logaretm' }],
};
function onSubmit(values) {
alert(JSON.stringify(values, null, 2));
}
</script>

也可以使用 composition API 实现:

<template>
<FormField name="ips">
<FormItem>
<FormLabel>允许的源 IP</FormLabel>
<div class="space-y-2">
<div v-for="(field, index) in ips" :key="field.key" class="flex items-center gap-2">
<FormField v-slot="{ componentField }" :name="`ips[${index}].value`">
<FormItem class="flex-grow">
<div class="flex gap-2">
<FormControl>
<Input type="text" v-bind="componentField" placeholder="请输入 IP 地址" />
</FormControl>
<Button type="button" variant="outline" size="icon" @click="removeIp(index)" :disabled="ips.length === 1">
<icon-material-symbols:remove />
</Button>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<Button type="button" variant="outline" size="sm" @click="() => addIp({ value: '' })" :disabled="ips.length >= 5">
<icon-material-symbols:add />
添加 IP
</Button>
<FormMessage />
</FormItem>
</FormField>
</template>
<script setup lang="ts">
const { fields: ips, push: addIp, remove: removeIp } = useFieldArray<{ value: string }>('ips');
const apiSettingSchema = z.object({
ips: z.array(z.object({ value: z.ipv4({ message: 'IP 格式不正确' }) })).min(1, '至少需要一个 IP 地址'),
});
</script>

总结

我不太喜欢反复查阅这些库的使用方法,因为这类内容通常等到实际用到时再查文档就行,但往往仍需要花费一些时间。这篇文章就像是阅读文档后整理的速查笔记,记录了常用的功能示例和使用心得。