Vue 3 表单校验的优雅实践:配置驱动 + Composable 的架构设计

如何在 Vue 3 + TypeScript 项目中构建一套可维护、可扩展、支持国际化的表单校验体系?本文分享一套经过实战检验的架构方案。

📖 前言

表单校验是前端开发中最常见却也最容易"写乱"的模块。你是否遇到过这些问题?

  • 📌 校验规则散落在各个组件中,重复代码随处可见
  • 📌 需求变更时要全局搜索,逐个修改正则或提示文案
  • 📌 业务规则和基础规则混在一起,难以复用
  • 📌 国际化支持困难,硬编码的中文难以维护
  • 📌 新成员接手项目时,不知道该用哪个校验函数

本文将分享一套基于 配置驱动 + Vue 3 Composable 的表单校验架构,帮助你解决以上痛点。


🎯 设计目标

在动手之前,我们先明确架构设计的目标:

目标描述
单一数据源所有校验配置集中管理,避免分散
关注点分离配置、逻辑、国际化各司其职
易于扩展添加新规则只需修改配置 + 实现方法
类型安全充分利用 TypeScript 提供类型提示
国际化友好支持多语言,文案集中管理
开发体验好API 简洁直观,上手成本低

🏗️ 架构设计

整体架构图

┌─────────────────────────────────────────────────────────────┐
│                        业务组件                              │
│  const { base, business, combined, preset } = useSimpleRules() │
└────────────────────────────┬────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────┐
│                   useSimpleRules (Composable)                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │   base   │ │ business │ │ combined │ │  preset  │       │
│  │ 基础规则  │ │ 业务规则  │ │ 组合规则  │ │ 预设规则  │       │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘       │
└───────┼────────────┼────────────┼────────────┼──────────────┘
        │            │            │            │
        ▼            ▼            ▼            ▼
┌─────────────────────────────────────────────────────────────┐
│                  validation.config.ts                        │
│  ┌─────────────────────┐  ┌─────────────────────┐          │
│  │ BaseValidationConfig│  │BusinessValidationConfig│         │
│  │   patterns/ranges   │  │   patterns/ranges    │          │
│  └─────────────────────┘  └─────────────────────┘          │
│  ┌─────────────────────────────────────────────┐           │
│  │          ValidationI18nKeys                  │           │
│  │     国际化 key 映射(统一管理)                │           │
│  └─────────────────────────────────────────────┘           │
└─────────────────────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────────────────┐
│                   i18n/validation.ts                         │
│                   国际化文案(中/英/...)                      │
└─────────────────────────────────────────────────────────────┘

文件结构

src/
├── config/
│   └── validation.config.ts      # 校验规则配置(正则、范围、i18n key)
├── composables/
│   └── validation/
│       └── useSimpleRules.ts     # 校验规则 Composable
└── i18n/
    └── zh/
        └── validation.ts         # 国际化文案

💡 核心实现

1. 配置层:validation.config.ts

配置层负责集中管理所有校验相关的"原材料":正则表达式、数值范围、长度限制、国际化 key。

/**
 * 基础校验规则配置
 */
export const BaseValidationConfig = {
  // 正则表达式
  patterns: {
    email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    phone: /^1[3-9]\d{9}$/,
    telephone: /^[0-9()()\-]+$/,
    integer: /^-?\d+$/,
    positiveInteger: /^\d+$/,
    url: /^https?:\/\/.+/,
    noChinese: /^[^\u4E00-\u9FA5]+$/,
    username: /^\w+$/,
    idCard: /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
  },

  // 数值范围
  ranges: {
    age: { min: 1, max: 150 },
    port: { min: 1, max: 65535 },
    percentage: { min: 0, max: 100 },
  },

  // 长度限制
  lengths: {
    username: { min: 3, max: 20 },
    password: { min: 6, max: 20 },
    name: { min: 2, max: 50 },
  },
} as const

/**
 * 业务校验规则配置(物流/航运领域)
 */
export const BusinessValidationConfig = {
  patterns: {
    bookingNo: /^[A-Z]{4}\d{6,10}$/,      // 订舱号:4位字母 + 6-10位数字
    containerNo: /^[A-Z]{4}\d{7}$/,        // 集装箱号:4位字母 + 7位数字
    hsCode: /^\d{8,10}$/,                  // HS编码:8-10位数字
    blNo: /^[A-Z0-9]{10,20}$/,             // 提单号
    vesselVoyage: /^[A-Z0-9\s/-]+$/,       // 船名航次
    fax: /^[\d\-()]+$/,                    // 传真号
    zipCode: /^\d{6}$/,                    // 邮编
    noSpace: /^\S+$/,                      // 无空格
  },

  ranges: {
    weight: { min: 0.01, max: 50000 },     // 重量(KG)
    volume: { min: 0.01, max: 10000 },     // 体积(立方米)
    quantity: { min: 1, max: 999999 },     // 数量
    containerCount: { min: 1, max: 9999 }, // 集装箱数量
  },

  lengths: {
    blNo: { min: 1, max: 10 },
    companyName: { min: 1, max: 500 },
    address: { min: 1, max: 1000 },
  },
} as const

/**
 * 验证提示信息配置(i18n key)
 */
export const ValidationI18nKeys = {
  // 基础提示
  required: 'validation.required',
  requiredSelect: 'validation.requiredSelect',
  
  // 格式提示
  email: 'validation.email',
  phone: 'validation.phone',
  telephone: 'validation.telephone',
  
  // 业务提示
  bookingNo: 'validation.bookingNo',
  containerNo: 'validation.containerNo',
  weight: 'validation.weight',
  // ... 更多 key
} as const

设计思考:

  • 使用 as const 断言,让 TypeScript 推导出字面量类型,获得更精确的类型提示
  • 将基础配置和业务配置分开,便于不同项目复用基础部分
  • 国际化 key 统一管理,避免硬编码字符串散落各处

2. 国际化层:validation.ts

/**
 * 表单验证国际化配置 - 中文
 * 使用扁平化 key 结构,与其他 i18n 文件保持一致
 */
export default {
  // 基础提示
  required: '此字段为必填项',
  requiredSelect: '请选择',
  requiredInput: '请输入',

  // 格式提示
  email: '请输入有效的邮箱地址',
  phone: '请输入有效的手机号码',
  telephone: '请输入有效的电话号码(支持数字、短横线、括号)',
  url: '请输入有效的网址',
  username: '用户名只能包含英文字母、数字和下划线',

  // 类型提示
  number: '请输入有效的数字',
  integer: '请输入整数',
  positiveNumber: '请输入正数',
  noChinese: '不能输入中文',

  // 范围提示(支持插值)
  numberRange: '数值必须在 {min} - {max} 之间',
  lengthRange: '请输入 {min} - {max} 个字符',
  minLength: '最少输入 {min} 个字符',
  maxLength: '最多输入 {max} 个字符',

  // 业务提示
  bookingNo: '订舱号格式不正确(4位字母 + 6-10位数字)',
  containerNo: '集装箱号格式不正确(4位字母 + 7位数字)',
  hsCode: 'HS编码格式不正确(8-10位数字)',
  weight: '重量必须为正数且不超过 50000 KG',
  volume: '体积必须为正数且不超过 10000 立方米',
}

3. Composable 层:useSimpleRules.ts

这是架构的核心,负责将配置转化为可直接使用的校验规则。

import type { FormItemRule } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
  BaseValidationConfig,
  BusinessValidationConfig,
  ValidationI18nKeys,
} from '~/config/validation.config'

export function useSimpleRules() {
  const { t } = useI18n()

  // ==================== 基础校验规则 ====================

  const base = {
    /** 必填项 */
    required: (message?: string): FormItemRule => ({
      required: true,
      message: message || t(ValidationI18nKeys.required),
      trigger: 'blur',
    }),

    /** 下拉选择必选 */
    requiredSelect: (message?: string): FormItemRule => ({
      required: true,
      message: message || t(ValidationI18nKeys.requiredSelect),
      trigger: 'change',
    }),

    /** 邮箱格式 */
    email: (message?: string): FormItemRule => ({
      pattern: BaseValidationConfig.patterns.email,
      message: message || t(ValidationI18nKeys.email),
      trigger: 'blur',
    }),

    /** 手机号格式 */
    phone: (message?: string): FormItemRule => ({
      pattern: BaseValidationConfig.patterns.phone,
      message: message || t(ValidationI18nKeys.phone),
      trigger: 'blur',
    }),

    /** 数字范围(支持对象结构 { value: number }) */
    numberRange: (min: number, max: number, message?: string): FormItemRule => ({
      validator: (rule, value, callback) => {
        // 兼容 ElInputNumber 的对象结构
        let numValue: number
        if (value && typeof value === 'object' && typeof value.value === 'number') {
          numValue = value.value
        } else if (typeof value === 'number') {
          numValue = value
        } else if (typeof value === 'string') {
          numValue = Number(value)
        } else {
          callback(new Error(message || t(ValidationI18nKeys.numberRange, { min, max })))
          return
        }

        if (Number.isNaN(numValue) || numValue < min || numValue > max) {
          callback(new Error(message || t(ValidationI18nKeys.numberRange, { min, max })))
          return
        }
        callback()
      },
      trigger: 'blur',
    }),

    /** 自定义校验函数 */
    custom: (
      validator: (value: any) => boolean | Promise<boolean>,
      message: string
    ): FormItemRule => ({
      validator: (rule, value, callback) => {
        Promise.resolve(validator(value))
          .then((result) => {
            result ? callback() : callback(new Error(message))
          })
          .catch(() => callback(new Error(message)))
      },
      trigger: 'blur',
    }),

    // ... 更多基础规则
  }

  // ==================== 业务校验规则 ====================

  const business = {
    /** 订舱号格式 */
    bookingNo: (message?: string): FormItemRule => ({
      pattern: BusinessValidationConfig.patterns.bookingNo,
      message: message || t(ValidationI18nKeys.bookingNo),
      trigger: 'blur',
    }),

    /** 重量校验(0.01 - 50000) */
    weight: (message?: string): FormItemRule => ({
      validator: (rule, value, callback) => {
        if (!value) {
          callback()
          return
        }
        const { min, max } = BusinessValidationConfig.ranges.weight
        const weight = typeof value === 'object' ? value.value : Number(value)
        
        if (Number.isNaN(weight) || weight < min || weight > max) {
          callback(new Error(message || t(ValidationI18nKeys.weight)))
          return
        }
        callback()
      },
      trigger: 'blur',
    }),

    // ... 更多业务规则
  }

  // ==================== 组合规则 ====================

  const combined = {
    /** 必填 + 邮箱 */
    requiredEmail: (message?: string): FormItemRule[] => 
      [base.required(), base.email(message)],

    /** 必填 + 订舱号 */
    requiredBookingNo: (message?: string): FormItemRule[] => 
      [base.required(), business.bookingNo(message)],

    // ... 更多组合规则
  }

  // ==================== 预设规则 ====================

  const preset = {
    /** 用户名(必填 + 格式 + 长度 3-20) */
    username: (message?: string): FormItemRule[] => {
      const { min, max } = BaseValidationConfig.lengths.username
      return [
        base.required(),
        base.username(message),
        base.length(min, max),
      ]
    },

    /** 密码(必填 + 长度 6-20) */
    password: (message?: string): FormItemRule[] => {
      const { min, max } = BaseValidationConfig.lengths.password
      return [base.required(), base.length(min, max, message)]
    },

    // ... 更多预设规则
  }

  return {
    base,
    business,
    combined,
    preset,
    config: {
      base: BaseValidationConfig,
      business: BusinessValidationConfig,
      i18nKeys: ValidationI18nKeys,
    },
  }
}

🎨 使用示例

示例 1:用户注册表单

<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { reactive, ref } from 'vue'
import { useSimpleRules } from '@/composables/validation/useSimpleRules'

const formRef = ref<FormInstance>()
const { base, combined, preset } = useSimpleRules()

const formData = reactive({
  username: '',
  password: '',
  confirmPassword: '',
  email: '',
  phone: '',
})

// 声明式定义规则,简洁清晰
const rules: FormRules = {
  username: preset.username(),
  password: preset.password(),
  confirmPassword: [
    base.required('请确认密码'),
    base.custom(
      value => value === formData.password,
      '两次输入的密码不一致'
    ),
  ],
  email: combined.requiredEmail(),
  phone: combined.requiredPhone(),
}
</script>

<template>
  <el-form ref="formRef" :model="formData" :rules="rules">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="formData.username" />
    </el-form-item>
    <!-- ... 其他表单项 -->
  </el-form>
</template>

示例 2:订舱业务表单

const { base, business, combined } = useSimpleRules()

const rules: FormRules = {
  bookingNo: combined.requiredBookingNo(),
  containerNo: combined.requiredContainerNo(),
  hsCode: combined.requiredHsCode(),
  weight: [
    base.required('请输入重量'),
    business.weight(),
  ],
  volume: [
    base.required('请输入体积'),
    business.volume(),
  ],
}

示例 3:自定义校验消息

// 所有规则方法都支持自定义消息参数
const rules: FormRules = {
  email: combined.requiredEmail('请输入企业邮箱'),
  bookingNo: business.bookingNo('订舱号格式错误,请参考示例:ABCD123456'),
  weight: business.weight('货物重量超出限制'),
}

🔧 扩展新规则

当业务需要新增校验规则时,只需 3 步:

Step 1: 添加配置

// validation.config.ts
export const BusinessValidationConfig = {
  patterns: {
    // 新增:发票号正则
    invoiceNo: /^INV\d{10}$/,
  },
}

export const ValidationI18nKeys = {
  // 新增:发票号 i18n key
  invoiceNo: 'validation.invoiceNo',
}

Step 2: 添加翻译

// i18n/zh/validation.ts
export default {
  invoiceNo: '发票号格式不正确(INV + 10位数字)',
}

Step 3: 实现规则

// useSimpleRules.ts
const business = {
  invoiceNo: (message?: string): FormItemRule => ({
    pattern: BusinessValidationConfig.patterns.invoiceNo,
    message: message || t(ValidationI18nKeys.invoiceNo),
    trigger: 'blur',
  }),
}

📊 架构对比

维度传统方式本架构方案
代码位置分散在各组件集中在配置 + Composable
修改成本需全局搜索修改只改一处,全局生效
复用性复制粘贴引用调用
国际化硬编码或分散统一 i18n key 管理
类型提示无或弱完善的 TypeScript 支持
扩展方式改动多处代码配置 + 实现,职责分离
测试难度难以单元测试纯函数,易于测试

🎓 最佳实践

1. 优先使用高层 API

// ✅ 推荐:使用预设规则
username: preset.username()

// ⚠️ 不推荐:手动组合基础规则
username: [
  base.required(),
  base.username(),
  base.length(3, 20),
]

2. 业务规则与基础规则分离

// ✅ 业务特有规则放 business
business.bookingNo()
business.containerNo()

// ✅ 通用规则放 base
base.email()
base.phone()

3. 复杂校验使用 custom

// ✅ 跨字段校验
base.custom(
  value => value === formData.password,
  '两次输入的密码不一致'
)

// ✅ 异步校验(如检查用户名是否已存在)
base.custom(
  async (value) => {
    const exists = await checkUsernameExists(value)
    return !exists
  },
  '用户名已被注册'
)

4. 保持消息一致性

// ✅ 使用 i18n,支持多语言
message: t(ValidationI18nKeys.email)

// ❌ 避免硬编码
message: '请输入有效的邮箱'

🤔 总结与思考

本文介绍的架构方案核心思想是 配置驱动 + 关注点分离

  • 配置层:集中管理正则、范围、i18n key,便于维护
  • 国际化层:文案统一管理,支持多语言
  • Composable 层:将配置转化为可用的校验规则,提供清晰的 API
  • 业务组件:声明式使用,专注业务逻辑

这套方案在我们的物流 SaaS 项目中经过实战检验,显著提升了:

  • 📈 开发效率:新增表单校验从"复制粘贴"变成"一行代码"
  • 📈 代码质量:消除重复代码,统一校验行为
  • 📈 可维护性:需求变更时修改一处即可
  • 📈 团队协作:新成员快速上手,API 清晰直观

当然,这套方案也有其适用边界。对于简单项目,直接使用 Element Plus 的内置校验可能更轻量。但当你的项目有以下特征时,不妨考虑引入类似架构:

  • 🏢 中大型项目,表单众多
  • 🌍 需要国际化支持
  • 🔄 业务规则可能频繁变更
  • 👥 多人协作,需要统一规范

希望本文对你有所启发!如果你有更好的实践方案,欢迎交流讨论。


📚 参考资源


本文基于 Vue 3.5 + TypeScript 5 + Element Plus 2.9 编写,完整代码已在生产环境验证。