Vue3 表单验证实战 | 用 VeeValidate/Zod 实现高复用表单(含自定义验证规则)

4 阅读4分钟

一、为什么需要专业的表单验证方案?

在企业级后台系统中,表单无处不在——登录注册、用户信息、订单提交、配置管理……你肯定遇到过这些问题:

  • 手机号、邮箱等规则在多个表单中重复写正则
  • 密码强度校验、两次密码一致性等复杂规则难维护
  • 异步校验(如校验用户名是否已存在)难以优雅实现
  • 表单验证逻辑与 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 描述验证规则,清晰直观

现在,你可以用这套方案重构项目中的所有表单了!🎯