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和非同步驗證,最終構建適合團隊的自建表單驗證方案。
線上工具推薦
- Vue SFC Playground — 線上編寫和除錯Vue 3組合式函式
- Zod Playground — 線上測試Zod Schema驗證規則
- Regex101 — 正規表示式線上除錯,輔助驗證規則編寫
- JSON Schema Validator — Schema驗證對比工具
- ToolsKu JSON格式化 — 線上格式化API回應JSON
本站提供瀏覽器本地工具,免註冊即可試用 →