Painless Form Validation 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>Submit</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: 'This is required' }).email({ message: 'Must be a valid email' }),
password: zod.string().min(1, { message: 'This is required' }).min(8, { message: 'Too short' }),
})
);
function onSubmit(values) {
alert(JSON.stringify(values, null, 2));
}
</script>

我們可以隨時替換元件中的狀態給實際要表現的元件透過 slot porps🔗 傳遞回來顯示於自製的輸入元件:

<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>Username</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>Edit Foo Bar</DialogTitle>
<DialogDescription class="sr-only">Edit 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 ? 'Editing...' : 'Edit' }}
</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)">Remove</button>
</div>
<button type="button" @click="push({ id: Date.now(), name: '', url: '' })">Add</button>
</FieldArray>
<button>Submit</button>
</Form>
</template>
<script setup>
// you can set initial values for those array fields
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>Allow Source 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>

總結

我不是很喜歡翻看這些套件的使用方法,因為這種東西都是用到再翻文件就好,但通常要花一定的時間。這篇文章就像是閱讀文件後的快速筆記,把常見的功能範例和心得記錄下來。