一、为什么需要专业的表单验证方案?
在企业级后台系统中,表单无处不在——登录注册、用户信息、订单提交、配置管理……你肯定遇到过这些问题:
- 手机号、邮箱等规则在多个表单中重复写正则
- 密码强度校验、两次密码一致性等复杂规则难维护
- 异步校验(如校验用户名是否已存在)难以优雅实现
- 表单验证逻辑与 UI 代码耦合严重,难以复用
一个好的表单验证方案应该具备:
- ✅ 声明式规则定义
- ✅ 与 UI 框架解耦
- ✅ 支持同步/异步验证
- ✅ 支持动态表单
- ✅ TypeScript 类型安全
二、VeeValidate vs Zod:为什么选择这个组合?
市面上主流的 Vue3 表单验证方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| VeeValidate | 专为 Vue 设计,与 UI 框架集成好,生态丰富 | 学习曲线稍陡 | Vue 项目首选 |
| Zod | 类型安全、声明式 Schema、与 TS 无缝集成 | 需要配合 Vue 适配器 | 注重类型安全的项目 |
| Vuelidate | 轻量、灵活 | 功能较弱 | 简单表单 |
| Element Plus 自带验证 | 开箱即用 | 复用性差,仅限该组件库 | 简单场景 |
我们的选择:VeeValidate + Zod 组合
- VeeValidate 负责与 Vue 组件绑定、错误展示
- Zod 负责声明验证规则(Schema),类型安全且可复用
- 两者结合,实现声明式、类型安全、高复用的表单验证
npm install vee-validate zod @vee-validate/zod
三、基础集成:让验证“动”起来
3.1 全局引入 VeeValidate
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { configure } from 'vee-validate'
// 全局配置(可选)
configure({
validateOnInput: true, // 输入时实时验证
validateOnChange: true, // 改变时验证
validateOnBlur: true, // 失焦时验证
})
const app = createApp(App)
app.mount('#app')
3.2 最简单的登录表单验证
<!-- LoginForm.vue -->
<template>
<Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
<div class="form-group">
<label>邮箱</label>
<Field name="email" type="email" class="input" />
<ErrorMessage name="email" class="error" />
</div>
<div class="form-group">
<label>密码</label>
<Field name="password" type="password" class="input" />
<ErrorMessage name="password" class="error" />
</div>
<button type="submit" :disabled="isSubmitting">登录</button>
</Form>
</template>
<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import { object, string } from 'zod'
// 定义验证 Schema(Zod)
const schema = object({
email: string()
.min(1, '邮箱不能为空')
.email('请输入有效的邮箱地址'),
password: string()
.min(6, '密码长度不能少于6位')
})
const onSubmit = (values) => {
console.log('表单数据:', values)
// 调用登录接口...
}
</script>
<style scoped>
.form-group { margin-bottom: 16px; }
.input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
.error { color: #f56c6c; font-size: 12px; margin-top: 4px; display: block; }
button { padding: 8px 24px; background: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:disabled { background: #ccc; cursor: not-allowed; }
</style>
四、深度集成 VeeValidate + Zod
4.1 使用 toTypedSchema 获得类型安全
// schemas/userSchema.ts
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
// 定义 Zod Schema
export const userSchema = z.object({
username: z.string()
.min(3, '用户名至少3个字符')
.max(20, '用户名最多20个字符')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
email: z.string()
.min(1, '邮箱不能为空')
.email('请输入有效的邮箱地址'),
age: z.number()
.min(18, '年龄必须大于18岁')
.max(100, '年龄必须小于100岁')
.optional(),
password: z.string()
.min(8, '密码至少8位')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, '密码必须包含大小写字母和数字'),
confirmPassword: z.string(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
agreeTerms: z.boolean().refine(val => val === true, '请同意用户协议')
}).refine(data => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'] // 错误指向 confirmPassword 字段
})
// 转换为 VeeValidate 可用的 Schema
export const typedUserSchema = toTypedSchema(userSchema)
// 推导出表单数据类型
export type UserFormValues = z.infer<typeof userSchema>
4.2 完整的用户注册表单
<!-- RegisterForm.vue -->
<template>
<Form
:validation-schema="typedUserSchema"
@submit="onSubmit"
v-slot="{ errors, isSubmitting, values }"
class="register-form"
>
<h2>用户注册</h2>
<!-- 用户名 -->
<div class="field">
<label>用户名</label>
<Field name="username" type="text" />
<ErrorMessage name="username" class="error" />
</div>
<!-- 邮箱 -->
<div class="field">
<label>邮箱</label>
<Field name="email" type="email" />
<ErrorMessage name="email" class="error" />
</div>
<!-- 年龄 -->
<div class="field">
<label>年龄</label>
<Field name="age" type="number" />
<ErrorMessage name="age" class="error" />
</div>
<!-- 密码 -->
<div class="field">
<label>密码</label>
<Field name="password" type="password" />
<ErrorMessage name="password" class="error" />
<div class="hint">至少8位,包含大小写字母和数字</div>
</div>
<!-- 确认密码 -->
<div class="field">
<label>确认密码</label>
<Field name="confirmPassword" type="password" />
<ErrorMessage name="confirmPassword" class="error" />
</div>
<!-- 角色 -->
<div class="field">
<label>角色</label>
<Field name="role" as="select">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="guest">访客</option>
</Field>
</div>
<!-- 协议 -->
<div class="field checkbox">
<Field name="agreeTerms" type="checkbox" />
<label>我已阅读并同意《用户协议》</label>
<ErrorMessage name="agreeTerms" class="error" />
</div>
<!-- 提交按钮 -->
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '注册中...' : '立即注册' }}
</button>
<!-- 实时预览表单数据(调试用) -->
<pre class="preview">{{ values }}</pre>
</Form>
</template>
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import { typedUserSchema, type UserFormValues } from '@/schemas/userSchema'
import { ElMessage } from 'element-plus'
const onSubmit = async (values: UserFormValues) => {
console.log('表单数据:', values)
try {
// 调用注册 API
// await registerApi(values)
ElMessage.success('注册成功!')
} catch (error) {
ElMessage.error('注册失败')
}
}
</script>
<style scoped>
.register-form {
max-width: 500px;
margin: 0 auto;
padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.field {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
input, select {
width: 100%;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
}
input:focus, select:focus {
outline: none;
border-color: #409eff;
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox label {
margin-bottom: 0;
}
.checkbox input {
width: auto;
}
.error {
color: #f56c6c;
font-size: 12px;
margin-top: 4px;
}
.hint {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
.preview {
margin-top: 20px;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
}
</style>
五、自定义验证规则
5.1 使用 Zod 自定义方法
Zod 提供了 .refine 和 .superRefine 来实现复杂自定义验证。
// 自定义验证:手机号
const phoneSchema = z.string().refine(
(val) => /^1[3-9]\d{9}$/.test(val),
{ message: '请输入有效的手机号码' }
)
// 自定义验证:身份证号
const idCardSchema = z.string().refine(
(val) => /^[1-9]\d{5}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X)$/i.test(val),
{ message: '请输入有效的身份证号码' }
)
// 组合使用
const profileSchema = z.object({
phone: phoneSchema,
idCard: idCardSchema
})
5.2 全局自定义规则(VeeValidate)
如果希望规则可以在多个表单中复用,可以注册全局规则。
// 在 main.ts 或插件中
import { defineRule } from 'vee-validate'
// 定义全局规则
defineRule('phone', (value: string) => {
if (!value) return true // 允许为空
const isPhone = /^1[3-9]\d{9}$/.test(value)
return isPhone || '请输入有效的手机号码'
})
defineRule('passwordStrength', (value: string) => {
if (!value) return true
const hasLower = /[a-z]/.test(value)
const hasUpper = /[A-Z]/.test(value)
const hasNumber = /\d/.test(value)
const hasSpecial = /[!@#$%^&*]/.test(value)
if (hasLower && hasUpper && hasNumber && hasSpecial) {
return true
}
return '密码必须包含大小写字母、数字和特殊字符'
})
在组件中使用全局规则:
<template>
<Field name="phone" rules="phone" />
<Field name="password" rules="passwordStrength" />
</template>
六、复杂表单实战
6.1 联动验证:两个字段相互依赖
// 场景:开始日期不能晚于结束日期
const dateRangeSchema = z.object({
startDate: z.string().min(1, '请选择开始日期'),
endDate: z.string().min(1, '请选择结束日期')
}).refine(data => new Date(data.startDate) <= new Date(data.endDate), {
message: '开始日期不能晚于结束日期',
path: ['endDate']
})
6.2 异步验证:校验用户名是否已存在
<template>
<Field
name="username"
:validateOnInput="false"
:validateOnBlur="true"
:validate="validateUsernameAsync"
/>
</template>
<script setup>
import { defineRule, Field } from 'vee-validate'
import { checkUsernameExists } from '@/api/user'
// 定义异步验证函数
const validateUsernameAsync = async (value) => {
if (!value) return true
// 模拟网络请求
const exists = await checkUsernameExists(value)
if (exists) {
return '用户名已被占用,请更换'
}
return true
}
</script>
更好的方式:使用 Zod 的 .refine 支持异步:
import { z } from 'zod'
const usernameSchema = z.string()
.min(3)
.refine(async (val) => {
const exists = await checkUsernameExists(val)
return !exists
}, { message: '用户名已被占用' })
6.3 动态表单字段(数组字段)
// 场景:多个联系方式,可动态增删
const contactSchema = z.object({
contacts: z.array(z.object({
type: z.enum(['phone', 'email']),
value: z.string().min(1, '联系方式不能为空')
})).min(1, '至少添加一个联系方式')
})
<template>
<Form :validation-schema="typedContactSchema">
<div v-for="(contact, index) in contacts" :key="index">
<Field :name="`contacts[${index}].type`" as="select">
<option value="phone">手机</option>
<option value="email">邮箱</option>
</Field>
<Field :name="`contacts[${index}].value`" />
<button @click="removeContact(index)">删除</button>
</div>
<button @click="addContact">添加联系方式</button>
</Form>
</template>
<script setup>
import { ref } from 'vue'
import { useForm } from 'vee-validate'
const contacts = ref([{ type: 'phone', value: '' }])
const addContact = () => {
contacts.value.push({ type: 'phone', value: '' })
}
const removeContact = (index) => {
contacts.value.splice(index, 1)
}
</script>
七、与 UI 组件库集成(Element Plus)
<template>
<Form :validation-schema="schema" v-slot="{ errors }">
<el-form-item label="用户名" :error="errors.username">
<Field name="username" v-slot="{ field }">
<el-input v-bind="field" placeholder="请输入用户名" />
</Field>
</el-form-item>
<el-form-item label="邮箱" :error="errors.email">
<Field name="email" v-slot="{ field }">
<el-input v-bind="field" placeholder="请输入邮箱" />
</Field>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit">提交</el-button>
</el-form-item>
</Form>
</template>
八、可复用的表单组件封装
将验证逻辑封装成可复用的组合式函数:
// composables/useFormValidation.ts
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import type { ZodSchema } from 'zod'
export function useFormValidation<T>(schema: ZodSchema<T>, onSubmit: (values: T) => void) {
const { handleSubmit, isSubmitting, errors, values, resetForm } = useForm({
validationSchema: toTypedSchema(schema)
})
const submit = handleSubmit(async (values) => {
await onSubmit(values as T)
})
return {
submit,
isSubmitting,
errors,
values,
resetForm
}
}
使用:
<script setup>
import { useFormValidation } from '@/composables/useFormValidation'
import { userSchema } from '@/schemas/userSchema'
const { submit, isSubmitting, errors } = useFormValidation(userSchema, async (values) => {
console.log('提交:', values)
})
</script>
<template>
<form @submit="submit">
<!-- 表单内容 -->
</form>
</template>
九、完整代码示例:用户设置表单
<!-- UserSettings.vue -->
<template>
<div class="settings-container">
<h2>个人设置</h2>
<Form :validation-schema="settingsSchema" @submit="saveSettings" v-slot="{ isSubmitting }">
<!-- 基本信息 -->
<div class="section">
<h3>基本信息</h3>
<div class="field">
<label>昵称</label>
<Field name="nickname" type="text" placeholder="请输入昵称" />
<ErrorMessage name="nickname" />
</div>
<div class="field">
<label>手机号</label>
<Field name="phone" type="tel" placeholder="请输入手机号" />
<ErrorMessage name="phone" />
</div>
<div class="field">
<label>邮箱</label>
<Field name="email" type="email" placeholder="请输入邮箱" />
<ErrorMessage name="email" />
</div>
</div>
<!-- 安全设置 -->
<div class="section">
<h3>安全设置</h3>
<div class="field">
<label>新密码</label>
<Field name="newPassword" type="password" placeholder="留空表示不修改" />
<ErrorMessage name="newPassword" />
</div>
<div class="field">
<label>确认密码</label>
<Field name="confirmPassword" type="password" />
<ErrorMessage name="confirmPassword" />
</div>
</div>
<!-- 偏好设置 -->
<div class="section">
<h3>偏好设置</h3>
<div class="field checkbox-group">
<label>通知方式</label>
<div class="checkboxes">
<label><Field name="notifyEmail" type="checkbox" value="true" /> 邮件通知</label>
<label><Field name="notifySms" type="checkbox" value="true" /> 短信通知</label>
</div>
</div>
</div>
<div class="actions">
<button type="submit" :disabled="isSubmitting">保存设置</button>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { ElMessage } from 'element-plus'
// 定义设置表单的 Zod Schema
const settingsSchema = toTypedSchema(z.object({
nickname: z.string().min(2, '昵称至少2个字符').max(20, '昵称最多20个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
email: z.string().email('请输入有效的邮箱地址'),
newPassword: z.string().optional(),
confirmPassword: z.string().optional(),
notifyEmail: z.boolean().optional(),
notifySms: z.boolean().optional()
}).refine(data => {
if (data.newPassword && data.newPassword !== data.confirmPassword) {
return false
}
return true
}, {
message: '两次输入的密码不一致',
path: ['confirmPassword']
}).refine(data => {
if (data.newPassword && data.newPassword.length < 6) {
return false
}
return true
}, {
message: '密码长度不能少于6位',
path: ['newPassword']
}))
const saveSettings = async (values: any) => {
console.log('保存设置:', values)
ElMessage.success('设置已保存')
}
</script>
<style scoped>
.settings-container {
max-width: 600px;
margin: 0 auto;
padding: 24px;
}
.section {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #eee;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.field input, .field select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.checkbox-group .checkboxes {
display: flex;
gap: 16px;
}
.checkbox-group label {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: normal;
}
.actions {
text-align: center;
}
button {
padding: 10px 32px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
十、性能优化与最佳实践
10.1 性能优化建议
- 使用
validateOnInput: false,改为validateOnBlur或validateOnChange,减少高频验证 - 大型表单使用
lazy模式,仅在提交时验证 - 避免在验证函数中执行复杂计算
10.2 最佳实践总结
| 场景 | 推荐方案 |
|---|---|
| 简单登录表单 | 直接在组件内写 Zod Schema |
| 多处使用的验证规则 | 定义全局 Zod Schema 文件,统一导出 |
| 与 Element Plus 集成 | 使用 v-slot="{ field }" 传递属性 |
| 动态表单数组 | 使用 useFieldArray |
| 异步验证 | Zod .refine + async 函数 |
十一、总结
通过本文,我们完整掌握了:
- VeeValidate + Zod 的集成方式
- Zod Schema 的声明式验证规则
- 自定义验证规则(同步/异步)
- 复杂场景:联动验证、数组表单、异步校验
- 与 UI 组件库的集成
- 封装可复用的表单验证逻辑
这套方案的优势:
- ✅ 类型安全:Zod + TypeScript 推导出完整的表单数据类型
- ✅ 高复用:验证规则可以跨组件/跨项目复用
- ✅ 解耦:验证逻辑与 UI 分离,易于测试和维护
- ✅ 声明式:用 Schema 描述验证规则,清晰直观
现在,你可以用这套方案重构项目中的所有表单了!🎯