Vue 3表單驗證Composable實戰:構建型別安全動態表單的5個核心模式

前端工程

表單驗證的痛點:為什麼需要自建Composable

2026年,Vue 3 Composition API已成為表單處理的標準範式,但驗證邏輯分散在各元件中、非同步驗證防抖處理複雜、動態表單欄位增刪難以管理、跨欄位聯動驗證無從下手、錯誤訊息狀態管理混亂——這些問題讓表單開發成為前端最頭痛的環節。自建表單驗證Composable,結合Zod Schema實現型別安全,是解決這些痛點的最佳路徑。

痛點 具體表現 影響
驗證邏輯分散 每個表單元件重複編寫驗證規則 程式碼冗餘、規則不一致
非同步驗證複雜 使用者名稱查重、手機驗證碼需防抖 請求風暴、使用者體驗差
動態表單難處理 欄位動態增刪、條件顯示隱藏 驗證狀態不同步
跨欄位聯動 密碼確認、日期範圍校驗 驗證邏輯耦合嚴重
錯誤狀態混亂 touched/dirty/valid狀態分散管理 UI回饋不準確

核心概念速覽

概念 說明
Composable Vue 3組合式函式,封裝可複用的響應式邏輯
表單驗證 對使用者輸入進行規則校驗,確保資料合法
Zod Schema TypeScript優先的Schema宣告式驗證函式庫,推斷型別
非同步驗證 需要請求後端的驗證(如唯一性檢查),需防抖
動態欄位 執行時增刪的表單欄位,需動態管理驗證規則
錯誤狀態 每個欄位的錯誤訊息、是否已觸碰、是否髒資料
髒檢查 判斷欄位值是否被使用者修改過(dirty)
提交狀態 isSubmitting/isSubmitSuccessful等提交過程狀態

5大挑戰深度分析

挑戰1:驗證邏輯複用

傳統方案將驗證規則寫在元件內,無法跨元件共享。相同欄位的驗證規則(如手機號、信箱)在每個表單中重複定義,修改時容易遺漏。

挑戰2:非同步驗證防抖

使用者名稱查重、信箱唯一性檢查等非同步驗證,如果不加防抖,每次輸入都觸發請求。需要區分「正在驗證中」和「驗證失敗」狀態,且防抖期間不能阻止使用者繼續輸入。

挑戰3:動態欄位增刪

動態表單(如標籤列表、地址多條)需要執行時增刪欄位,每個欄位獨立驗證。刪除欄位時需清理其驗證狀態,新增欄位時需初始化驗證規則。

挑戰4:跨欄位聯動驗證

密碼確認、日期範圍、金額上下限等跨欄位驗證,需要同時存取多個欄位值。驗證觸發時機也需協調——任一欄位變化都應重新校驗關聯欄位。

挑戰5:表單狀態管理

一個表單欄位涉及value、errors、touched、dirty、isValidating等多個狀態,手動管理極易遺漏。需要統一的響應式狀態容器,自動派生計算屬性。


模式1:useValidation基礎驗證Composable

import { ref, reactive, computed, type Ref } from 'vue'

type Validator = (value: any) => string | true

interface FieldState {
  value: Ref<any>
  errors: string[]
  touched: boolean
  dirty: boolean
}

interface ValidationRule {
  validator: Validator
  message: string
}

export function useValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: Record<keyof T, ValidationRule[]>
) {
  const fields = reactive<Record<string, FieldState>>({})
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: ref(initialValues[key]),
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = (key: keyof T): boolean => {
    const field = fields[key as string]
    if (!field) return true

    const fieldRules = rules[key] || []
    const errors: string[] = []

    for (const rule of fieldRules) {
      const result = rule.validator(field.value.value)
      if (result !== true) {
        errors.push(rule.message)
      }
    }

    field.errors = errors
    return errors.length === 0
  }

  const validateAll = (): boolean => {
    let allValid = true
    for (const key in fields) {
      if (!validateField(key as keyof T)) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.touched = true
    }
  }

  const markDirty = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.dirty = field.value.value !== initialValues[key]
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    )
  })

  const isDirty = computed(() => {
    return Object.values(fields).some((field) => field.dirty)
  })

  const getValues = (): T => {
    const values = {} as T
    for (const key in fields) {
      values[key as keyof T] = fields[key].value.value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      const field = fields[key]
      field.value.value = initialValues[key]
      field.errors = []
      field.touched = false
      field.dirty = false
    }
  }

  return {
    fields,
    isValidating,
    isValid,
    isDirty,
    validateField,
    validateAll,
    touchField,
    markDirty,
    getValues,
    resetForm
  }
}

模式2:Zod Schema驅動的型別安全驗證

import { ref, reactive, computed } from 'vue'
import { z, type ZodType, type ZodError } from 'zod'

interface ZodFieldState {
  value: any
  errors: string[]
  touched: boolean
  dirty: boolean
}

export function useZodForm<T extends ZodType<any>>(
  schema: T,
  initialValues: z.infer<T>
) {
  type FormValues = z.infer<T>

  const fields = reactive<Record<string, ZodFieldState>>({})
  const formErrors = ref<string[]>([])
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: initialValues[key],
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const field = fields[key]
    if (!field) return true

    try {
      const fieldSchema = schema.shape[key]
      if (fieldSchema) {
        await fieldSchema.parseAsync(field.value)
      }
      field.errors = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      field.errors = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    isValidating.value = true
    formErrors.value = []

    try {
      const values = getValues()
      await schema.parseAsync(values)
      for (const key in fields) {
        fields[key].errors = []
      }
      isValidating.value = false
      return true
    } catch (error) {
      const zodError = error as ZodError
      for (const issue of zodError.errors) {
        const path = issue.path[0]?.toString()
        if (path && fields[path]) {
          if (!fields[path].errors.includes(issue.message)) {
            fields[path].errors.push(issue.message)
          }
        } else {
          formErrors.value.push(issue.message)
        }
      }
      isValidating.value = false
      return false
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    ) && formErrors.value.length === 0
  })

  const getValues = (): FormValues => {
    const values = {} as FormValues
    for (const key in fields) {
      (values as any)[key] = fields[key].value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      fields[key].value = initialValues[key]
      fields[key].errors = []
      fields[key].touched = false
      fields[key].dirty = false
    }
    formErrors.value = []
  }

  return {
    fields,
    formErrors,
    isValidating,
    isValid,
    validateField,
    validateAll,
    getValues,
    resetForm
  }
}

const userSchema = z.object({
  username: z.string().min(3, '使用者名稱至少3個字元').max(20, '使用者名稱最多20個字元'),
  email: z.string().email('請輸入有效的信箱地址'),
  password: z.string().min(8, '密碼至少8個字元').regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    '密碼需包含大小寫字母和數字'
  )
})

type UserForm = z.infer<typeof userSchema>

模式3:非同步驗證與防抖

import { ref, watch, type Ref } from 'vue'

interface AsyncValidationOptions {
  debounceMs?: number
  validateOnChange?: boolean
}

export function useAsyncValidation(
  fieldValue: Ref<string>,
  asyncValidator: (value: string) => Promise<string | null>,
  options: AsyncValidationOptions = {}
) {
  const { debounceMs = 500, validateOnChange = true } = options

  const isValidating = ref(false)
  const asyncError = ref<string | null>(null)
  let debounceTimer: ReturnType<typeof setTimeout> | null = null

  const executeValidation = async (value: string): Promise<boolean> => {
    if (!value) {
      asyncError.value = null
      return true
    }

    isValidating.value = true
    try {
      const error = await asyncValidator(value)
      asyncError.value = error
      return error === null
    } catch (err) {
      asyncError.value = '驗證請求失敗,請重試'
      return false
    } finally {
      isValidating.value = false
    }
  }

  const debouncedValidate = (value: string) => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    debounceTimer = setTimeout(() => {
      executeValidation(value)
    }, debounceMs)
  }

  if (validateOnChange) {
    watch(fieldValue, (newValue) => {
      asyncError.value = null
      debouncedValidate(newValue)
    })
  }

  const validate = async (): Promise<boolean> => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    return executeValidation(fieldValue.value)
  }

  const reset = () => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    asyncError.value = null
    isValidating.value = false
  }

  return {
    isValidating,
    asyncError,
    validate,
    reset
  }
}

const checkUsernameUnique = async (username: string): Promise<string | null> => {
  const response = await fetch(`/api/check-username?username=${username}`)
  const data = await response.json()
  return data.exists ? '該使用者名稱已被註冊' : null
}

模式4:動態表單欄位管理

import { ref, reactive, computed } from 'vue'
import type { ZodType, ZodError } from 'zod'

interface DynamicFieldConfig {
  key: string
  label: string
  schema: ZodType<any>
  defaultValue: any
}

export function useDynamicForm() {
  const fieldConfigs = reactive<Map<string, DynamicFieldConfig>>(new Map())
  const fieldValues = reactive<Record<string, any>>({})
  const fieldErrors = reactive<Record<string, string[]>>({})
  const fieldTouched = reactive<Record<string, boolean>>({})

  const addField = (config: DynamicFieldConfig) => {
    fieldConfigs.set(config.key, config)
    fieldValues[config.key] = config.defaultValue
    fieldErrors[config.key] = []
    fieldTouched[config.key] = false
  }

  const removeField = (key: string) => {
    fieldConfigs.delete(key)
    delete fieldValues[key]
    delete fieldErrors[key]
    delete fieldTouched[key]
  }

  const addFieldGroup = (configs: DynamicFieldConfig[]) => {
    for (const config of configs) {
      addField(config)
    }
  }

  const removeFieldGroup = (keys: string[]) => {
    for (const key of keys) {
      removeField(key)
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const config = fieldConfigs.get(key)
    if (!config) return true

    try {
      await config.schema.parseAsync(fieldValues[key])
      fieldErrors[key] = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      fieldErrors[key] = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    let allValid = true
    for (const key of fieldConfigs.keys()) {
      if (!(await validateField(key))) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: string) => {
    fieldTouched[key] = true
  }

  const isValid = computed(() => {
    return Object.values(fieldErrors).every(
      (errors) => errors.length === 0
    )
  })

  const fieldCount = computed(() => fieldConfigs.size)

  const resetAll = () => {
    for (const [key, config] of fieldConfigs) {
      fieldValues[key] = config.defaultValue
      fieldErrors[key] = []
      fieldTouched[key] = false
    }
  }

  return {
    fieldConfigs,
    fieldValues,
    fieldErrors,
    fieldTouched,
    isValid,
    fieldCount,
    addField,
    removeField,
    addFieldGroup,
    removeFieldGroup,
    validateField,
    validateAll,
    touchField,
    resetAll
  }
}

模式5:端到端表單提交與錯誤處理

import { ref, computed, type Ref } from 'vue'
import type { ZodType } from 'zod'

interface SubmitOptions<T> {
  schema: ZodType<T>
  onSubmit: (values: T) => Promise<void>
  onSuccess?: () => void
  onError?: (error: unknown) => void
}

interface FormSubmitState {
  isSubmitting: boolean
  isSubmitSuccessful: boolean
  submitCount: number
  serverErrors: Record<string, string[]>
  rootError: string | null
}

export function useFormSubmit<T extends Record<string, any>>(
  getValues: () => T,
  options: SubmitOptions<T>
) {
  const state = ref<FormSubmitState>({
    isSubmitting: false,
    isSubmitSuccessful: false,
    submitCount: 0,
    serverErrors: {},
    rootError: null
  })

  const handleSubmit = async () => {
    state.value.isSubmitting = true
    state.value.rootError = null
    state.value.serverErrors = {}
    state.value.submitCount++

    try {
      const values = getValues()
      const validated = await options.schema.parseAsync(values)

      await options.onSubmit(validated)

      state.value.isSubmitSuccessful = true
      options.onSuccess?.()
    } catch (error: unknown) {
      state.value.isSubmitSuccessful = false

      if (error && typeof error === 'object' && 'errors' in error) {
        const zodLike = error as { errors: Array<{ path: (string | number)[]; message: string }> }
        for (const issue of zodLike.errors) {
          const path = issue.path[0]?.toString()
          if (path) {
            if (!state.value.serverErrors[path]) {
              state.value.serverErrors[path] = []
            }
            state.value.serverErrors[path].push(issue.message)
          } else {
            state.value.rootError = issue.message
          }
        }
      } else if (error instanceof Error) {
        state.value.rootError = error.message
      } else {
        state.value.rootError = '提交失敗,請稍後重試'
      }

      options.onError?.(error)
    } finally {
      state.value.isSubmitting = false
    }
  }

  const canSubmit = computed(() => {
    return !state.value.isSubmitting
  })

  const clearServerErrors = () => {
    state.value.serverErrors = {}
    state.value.rootError = null
  }

  const resetSubmitState = () => {
    state.value = {
      isSubmitting: false,
      isSubmitSuccessful: false,
      submitCount: 0,
      serverErrors: {},
      rootError: null
    }
  }

  return {
    submitState: state,
    canSubmit,
    handleSubmit,
    clearServerErrors,
    resetSubmitState
  }
}

避坑指南

場景 錯誤做法 正確做法
驗證觸發時機 ❌ 僅在提交時驗證所有欄位 ✅ blur時驗證單個欄位,提交時驗證全部
非同步防抖 ❌ 每次input都發請求 ✅ 使用debounce,500ms內無新輸入才發請求
動態欄位清理 ❌ 刪除欄位只刪UI不刪驗證狀態 ✅ 同步清理fieldValues/fieldErrors/fieldTouched
跨欄位驗證 ❌ 在單個欄位驗證中硬編碼其他欄位引用 ✅ 使用Zod的refine/superRefine宣告式聯動
錯誤訊息覆蓋 ❌ 伺服器錯誤直接覆蓋客戶端驗證錯誤 ✅ 區分clientErrors和serverErrors,優先展示客戶端錯誤

報錯排查

錯誤訊息 原因 解決方案
Cannot read properties of undefined (reading 'value') fields[key]未初始化 確保所有欄位在useValidation中註冊了初始值
z.parseAsync is not a function 傳入了非Zod Schema物件 檢查schema參數是否為z.object()等Zod型別
Validate function returned undefined 驗證函式未回傳true或錯誤訊息 Validator必須回傳true(通過)或string(錯誤訊息)
Maximum call stack size exceeded watch循環觸發驗證 新增條件判斷避免驗證結果再次觸發watch
debounce is not cleared on unmount 元件卸載時未清理計時器 在onUnmounted中clearTimeout所有debounceTimer
Async validation result is stale 防抖期間值已變化但舊請求回傳 使用abort controller或比對當前值與請求值
Field errors not cleared after reset resetForm未清理errors陣列 確保resetForm中遍歷所有欄位重置errors為[]
Type 'string' is not assignable to type Zod推斷型別與表單值型別不匹配 使用z.infer<typeof schema>確保型別一致
Cross-field validation not triggered 只驗證了當前欄位未觸發關聯欄位 密碼確認等場景需watch兩個欄位並聯動validate
Server errors not showing in UI serverErrors和clientErrors合併邏輯錯誤 統一錯誤展示入口,合併時去重並保持順序

進階優化技巧

1. 驗證結果快取

import { ref, type Ref } from 'vue'

interface CacheEntry {
  value: string
  result: string | null
  timestamp: number
}

export function useValidationCache(
  validator: (value: string) => Promise<string | null>,
  ttlMs = 30000
) {
  const cache = new Map<string, CacheEntry>()

  const validate = async (value: string): Promise<string | null> => {
    const cached = cache.get(value)
    if (cached && Date.now() - cached.timestamp < ttlMs) {
      return cached.result
    }

    const result = await validator(value)
    cache.set(value, { value, result, timestamp: Date.now() })
    return result
  }

  const invalidate = (value?: string) => {
    if (value) {
      cache.delete(value)
    } else {
      cache.clear()
    }
  }

  return { validate, invalidate }
}

2. 表單狀態持久化

import { watch, onMounted } from 'vue'
import type { Ref } from 'vue'

export function useFormPersistence(
  formKey: string,
  fieldValues: Ref<Record<string, any>>,
  options: { storage?: 'localStorage' | 'sessionStorage'; debounceMs?: number } = {}
) {
  const { storage = 'localStorage', debounceMs = 1000 } = options
  const storageKey = `form-draft:${formKey}`
  let timer: ReturnType<typeof setTimeout> | null = null

  const save = () => {
    const store = window[storage]
    store.setItem(storageKey, JSON.stringify(fieldValues.value))
  }

  const restore = () => {
    const store = window[storage]
    const saved = store.getItem(storageKey)
    if (saved) {
      try {
        const parsed = JSON.parse(saved)
        Object.assign(fieldValues.value, parsed)
      } catch {}
    }
  }

  const clear = () => {
    const store = window[storage]
    store.removeItem(storageKey)
  }

  onMounted(restore)

  watch(
    fieldValues,
    () => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(save, debounceMs)
    },
    { deep: true }
  )

  return { restore, clear }
}

3. 條件驗證規則

import { z } from 'zod'

export function conditionalSchema<T>(
  condition: () => boolean,
  trueSchema: z.ZodType<T>,
  falseSchema: z.ZodType<T>
) {
  return z.custom<T>((value) => {
    const schema = condition() ? trueSchema : falseSchema
    return schema.safeParse(value).success
  })
}

const formSchema = z.object({
  userType: z.enum(['personal', 'enterprise']),
  companyName: conditionalSchema(
    () => true,
    z.string().min(2, '企業名稱至少2個字元'),
    z.string().optional()
  ),
  idNumber: conditionalSchema(
    () => true,
    z.string().regex(/^\d{18}$/, '請輸入18位身分證字號'),
    z.string().regex(/^[A-Z0-9]{10}$/, '請輸入統一社會信用代碼')
  )
})

對比分析

維度 自建Composable VeeValidate FormKit Vuelidate
學習曲線 中(需理解響應式原理) 高(API豐富但概念多) 中(宣告式配置) 低(裝飾器風格)
包體積 <5KB(按需實現) ~13KB(核心) ~32KB(完整) ~6KB
TypeScript支援 原生(Zod推斷) 良好(需泛型配置) 良好 一般
非同步驗證 自訂防抖+快取 內建debounce 內建rules 需手動實現
動態欄位 自由控制 useFieldArray schema驅動 $model綁定
跨欄位驗證 Zod refine yup.refine 規則配置 $each+$v
狀態管理 細粒度reactive useForm統一管理 node樹 $v物件
自訂靈活度 極高 中(受API約束)
Vue 3相容 原生 v4原生 原生 @vuelidate/core
適用場景 客製化需求強 複雜表單專案 快速開發 簡單驗證

總結展望

Vue 3表單驗證Composable的核心在於將驗證邏輯從元件中抽離,透過響應式狀態管理實現宣告式驗證。5個核心模式——基礎驗證Composable、Zod Schema型別安全、非同步驗證防抖、動態欄位管理、端到端提交處理——覆蓋了從簡單到複雜的完整表單場景。

2026年,Zod已成為TypeScript生態的Schema驗證標準,與Vue 3 Composable的結合讓表單開發真正實現了型別安全。未來,隨著Vue Vapor Mode的推進,表單驗證的效能將進一步提升。建議從useValidation基礎模式入手,逐步引入Zod Schema和非同步驗證,最終構建適合團隊的自建表單驗證方案。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#Vue3表单验证#Composable#表单校验#异步验证#动态表单#2026#前端工程