iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 18

Day 13 : 用咒語守護表單:VeeValidate + Yup 的即時驗證魔法

  • 分享至 

  • xImage
  •  

前言

第 1~12 天,我們一步步把「飲料點單」施法到能跑、能管理、能共享。
但真正上線的系統,錯的輸入就像走錯陣法:會讓資料歪掉、流程卡住,甚至害你加班(啊!)。
今天我們召喚兩件強力法器:

  • VeeValidate:幫你管理表單狀態與錯誤訊息的「結界」/images/emoticon/emoticon01.gif
  • Yup:把規則寫成可重用的「咒語卷軸(Schema)」

這類驗證工具的價值:避免工程師每個表單都「重新造輪子」
規則用 Schema 集中描述、錯誤由元件統一管理,一致、可測、可擴充,你就不會在不同頁面複製貼上同一段 if/else 了。

今天我們只改 OrderForm.vue,API 與其他元件維持不變。
主軸目標:

  • 必填與長度檢查(姓名、飲料、甜度、冰量)
  • 依菜單規則做跨欄位驗證(例:巧克力只能熱飲
  • 即時提示錯誤,改善 UX

安裝法器

npm i vee-validate yup

變更重點(OrderForm.vue)

思路:

  1. Yup 定義規則(可重用的 Schema)
  2. VeeValidateuseForm/useField 綁定欄位與錯誤狀態
  3. 透過 computed 依選到的飲料,動態切換可選的甜度/冰量
  4. handleSubmit 成功才 emit,並清空表單
<script setup>
import { computed } from 'vue'
import OptionGroup from './OptionGroup.vue'
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'

const emit = defineEmits(['submit'])
const props = defineProps({
  disabled: { type: Boolean, default: false },
  drinks: { type: Array, default: () => [] },
  sweetnessOptions: { type: Array, default: () => [] },
  iceOptions: { type: Array, default: () => [] },
  // 例:
  // menuRules = {
  //   '紅茶':   { allowedSweetness: ['正常甜','去糖'], allowedIce: ['正常冰','去冰'] },
  //   '綠茶':   { allowedSweetness: ['正常甜','去糖'], allowedIce: ['正常冰','去冰'] },
  //   '巧克力': { allowedSweetness: ['正常甜','少糖'], allowedIce: ['熱飲'] } // 只能熱
  // }
  menuRules: { type: Object, default: () => ({}) },
})

/** Yup:表單規則(可重用) */
const schema = computed(() =>
  yup.object({
    name: yup.string().required('姓名必填').min(2, '至少 2 個字').max(20, '最多 20 個字'),
    note: yup.string().max(50, '備註最多 50 個字').optional(),
    drink: yup.string().required('請選擇飲料'),
    sweetness: yup.string().required('請選擇甜度'),
    ice: yup.string().required('請選擇冰量'),
  }).test('menu-rule', '選項與菜單規則不符', (values) => {
    const rule = props.menuRules?.[values.drink]
    if (!rule) return true
    const okSweet = !rule.allowedSweetness?.length || rule.allowedSweetness.includes(values.sweetness)
    const okIce = !rule.allowedIce?.length || rule.allowedIce.includes(values.ice)
    return okSweet && okIce
  })
)

/** VeeValidate:表單/欄位 */
const { handleSubmit, errors, isSubmitting } = useForm({ validationSchema: schema })
const { value: name } = useField('name')
const { value: note } = useField('note')
const { value: drink } = useField('drink')
const { value: sweetness } = useField('sweetness')
const { value: ice } = useField('ice')

/** 依飲料動態產生可選甜度/冰量(顯示層面) */
const optSweetness = computed(() => {
  const rule = props.menuRules?.[drink.value]
  return rule?.allowedSweetness?.length ? rule.allowedSweetness : props.sweetnessOptions
})
const optIce = computed(() => {
  const rule = props.menuRules?.[drink.value]
  return rule?.allowedIce?.length ? rule.allowedIce : props.iceOptions
})

/** 送出 */
const onSubmit = handleSubmit((v) => {
  if (props.disabled) return
  emit('submit', v)      // 對外契約不變:仍然送出乾淨 payload
  name.value = ''
  note.value = ''
  drink.value = ''
  sweetness.value = ''
  ice.value = ''
})
</script>

<template>
  <!-- 姓名 -->
  <div :class="['block', name ? 'complete' : 'invalid']">
    <label>姓名(必填)
      <input type="text" v-model.trim="name" placeholder="請輸入你的名字" />
    </label>
    <p class="hint" v-if="errors.name">{{ errors.name }}</p>
  </div>

  <!-- 備註 -->
  <div class="block">
    <label>備註(選填)
      <textarea v-model.trim="note" placeholder="例如:三點拿、少冰"></textarea>
    </label>
    <p class="hint" v-if="errors.note">{{ errors.note }}</p>
  </div>

  <!-- 飲料 / 甜度 / 冰量 -->
  <OptionGroup label="步驟 1:選擇飲料" :options="props.drinks" v-model="drink" required />
  <p class="hint" v-if="errors.drink">{{ errors.drink }}</p>

  <OptionGroup v-if="drink"
               label="步驟 2:選擇甜度"
               :options="optSweetness"
               v-model="sweetness"
               required />
  <p class="hint" v-if="errors.sweetness">{{ errors.sweetness }}</p>

  <OptionGroup v-if="drink && sweetness"
               label="步驟 3:選擇冰量"
               :options="optIce"
               v-model="ice"
               required />
  <p class="hint" v-if="errors.ice">{{ errors.ice }}</p>

  <button :disabled="props.disabled || isSubmitting"
          class="submit enabled"
          @click="onSubmit">
    送出
  </button>

  <!-- 全表單級錯誤(如跨欄位規則) -->
  <p class="hint" v-if="errors['']">{{ errors[''] }}</p>
</template>

驗證規則(Yup)表

欄位 規則 範例說明
name required、min(2)、max(20) 姓名必填,2~20 字
note max(50) 備註最多 50 字
drink required 必選飲料
sweetness required 必選甜度
ice required 必選冰量
cross rule test() 跨欄位:依 menuRules 例:巧克力只能熱飲

跨欄位驗證的關鍵:

yup.object({ /* fields... */ }).test('menu-rule', '選項與菜單規則不符', (values) => {
  const rule = props.menuRules?.[values.drink]
  if (!rule) return true
  const okSweet = !rule.allowedSweetness?.length || rule.allowedSweetness.includes(values.sweetness)
  const okIce = !rule.allowedIce?.length || rule.allowedIce.includes(values.ice)
  return okSweet && okIce
})

為什麼選 VeeValidate + Yup?(工程師不必一直造輪子)

  • 規則集中:Schema 一處定義,頁面處處可用
  • 一致與可測:驗證邏輯可單測、可重構、不分散
  • 狀態好拿errors / isSubmitting通通幫你管
  • UX 友善:即時顯示提示、避免送出才爆錯
  • 可擴充:日後加入「客製規則」只要改 schema,不必通篇找 if/else

小提示(避免踩坑)

  • UI 限制 ≠ 資料驗證:UI(v-if、選單)只負責「引導」,真正的資料保證交給 Yup
  • 跨欄位就用 test():像「巧克力只能熱飲」這種關聯規則,很適合集中在 schema。
  • 仍保持元件單純OrderForm 專心組裝 UI 與發出 submit,規則/錯誤交由工具處理。
  • 規則可共用:若多頁面都要姓名/電話/Email 驗證,抽成共用 schema,一次維護到位

結語

今天把「輸入正確性」交給成熟法器,

你只要專注在需求與體驗,法陣就會守住資料品質。
需求至上的 Vue 魔法之旅,就是用對工具、寫對地方,

讓你和使用者都安心:每一筆訂單,都是合乎規則的好咒語。

我們今天算是把一些常用到的技巧跟工具講解完畢了

接下來就要進入到chapter3了~~

我們在一起努力唄~!!!


上一篇
Day : 12.5 Pinia Options API vs Composition API 差異比較
系列文
需求至上的 Vue 魔法之旅18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言