iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 24

Day 24 表單與驗證(Vue 版)— v-model + 自訂驗證 + Toast 成功提示

  • 分享至 

  • xImage
  •  

今日目標

  • v-model 管理欄位,搭配 自訂驗證onInput/onBlur)顯示即時錯誤
  • 送出前 整體檢查、禁用送出按鈕(disabled)與 Loading 狀態
  • 強化 可存取性aria-invalidaria-describedby、錯誤訊息關聯
  • 加入 成功 Toast,讓體驗更完整

基礎觀念(白話)

  1. 即時驗證 vs. 送出驗證
    • 即時:改善輸入體驗,但要避免每字都「紅爆」→ 常用 blur 後才開始顯示 的策略。
    • 送出:最後防線,確保資料真的合法。
  2. a11y 可存取性
    • 無障礙工具靠語義與屬性理解錯誤:aria-invalid="true"aria-describedby="err-id"
  3. 表單三態
    • pristine / dirtytouched / untouchedvalid / invalid
    • 我們會用簡單旗標模擬:touched(是否觸碰)、errors(錯誤訊息)。

目錄

  1. 建立驗證工具 src/utils/validators.ts
  2. 建立 Toast 元件 src/components/Toast.vue
  3. 改寫 Contact.vue:欄位狀態、事件、錯誤顯示、送出流程
  4. (可選)換成 vee-validate / Vuelidate 的最小示例

1) 驗證工具(可重用)

src/utils/validators.ts

// 簡單可讀的規則函式,回傳字串代表錯誤訊息;null 代表通過
export function required(label = '此欄位') {
  return (v: string) => v?.trim() ? null : `${label}為必填`
}

export function minLen(n: number, label = '此欄位') {
  return (v: string) => v?.trim().length >= n ? null : `${label}至少 ${n} 個字`
}

export function maxLen(n: number, label = '此欄位') {
  return (v: string) => v?.trim().length <= n ? null : `${label}不可超過 ${n} 個字`
}

export function email(label = 'Email') {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return (v: string) => re.test(v) ? null : `${label}格式不正確`
}

// 串接多個規則,回傳第一個錯誤
export function composeValidators<T extends string>(...rules: Array<(v: string) => string | null>) {
  return (value: string) => {
    for (const rule of rules) {
      const err = rule(value)
      if (err) return err
    }
    return null
  }
}


2) Toast 元件(超輕量)

src/components/Toast.vue

<template>
  <transition name="toast-fade">
    <div v-if="open" class="toast" role="status" aria-live="polite">
      <slot>已送出!感謝你的來信。</slot>
    </div>
  </transition>
</template>

<script setup lang="ts">
import { defineProps } from 'vue'
defineProps<{ open: boolean }>()
</script>

<style scoped>
.toast {
  position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
  background: #111827; color: #fff; padding: 10px 14px; border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,.2); z-index: 1000;
}
.toast-fade-enter-active, .toast-fade-leave-active { transition: opacity .2s, transform .2s; }
.toast-fade-enter-from, .toast-fade-leave-to { opacity: 0; transform: translate(-50%, 8px); }
</style>

真的要更漂亮可以放到 shared/ui/Toast.vue,今天先用最小版。


3) 改寫 Contact.vue

功能清單

  • v-model 綁定:name, email, message, agree(是否同意隱私條款)
  • touchederrors:blur 後才顯示錯誤;送出時全檢
  • aria-invalidaria-describedby<small class="error"> 關聯
  • 送出時 pending = true,模擬 await fetch('/api'),成功後彈出 Toast、reset

src/components/Contact.vue

<template>
  <section id="contact" class="container section" aria-labelledby="contact-title">
    <h2 id="contact-title">聯絡我</h2>

    <form novalidate @submit.prevent="onSubmit">
      <!-- 姓名 -->
      <div class="field">
        <label for="name">姓名</label>
        <input
          id="name" name="name" type="text" placeholder="王小明"
          v-model.trim="form.name"
          @blur="touch('name')"
          :aria-invalid="Boolean(showError('name'))"
          :aria-describedby="showError('name') ? 'err-name' : undefined"
        />
        <small v-if="showError('name')" id="err-name" class="error">{{ errors.name }}</small>
      </div>

      <!-- Email -->
      <div class="field">
        <label for="email">Email</label>
        <input
          id="email" name="email" type="email" placeholder="name@example.com"
          v-model.trim="form.email"
          @blur="touch('email')"
          :aria-invalid="Boolean(showError('email'))"
          :aria-describedby="showError('email') ? 'err-email' : undefined"
        />
        <small v-if="showError('email')" id="err-email" class="error">{{ errors.email }}</small>
      </div>

      <!-- 訊息 -->
      <div class="field">
        <label for="message">訊息</label>
        <textarea
          id="message" name="message" rows="4" placeholder="想合作的內容…(至少 10 個字)"
          v-model.trim="form.message"
          @blur="touch('message')"
          :aria-invalid="Boolean(showError('message'))"
          :aria-describedby="showError('message') ? 'err-message' : 'hint-message'"
        />
        <small id="hint-message" class="muted">已輸入 {{ form.message.length }} 字</small>
        <small v-if="showError('message')" id="err-message" class="error">{{ errors.message }}</small>
      </div>

      <!-- 條款 -->
      <div class="field">
        <label>
          <input type="checkbox" v-model="form.agree" @blur="touch('agree')" />
          我已閱讀並同意隱私權條款
        </label>
        <small v-if="showError('agree')" id="err-agree" class="error">{{ errors.agree }}</small>
      </div>

      <div class="actions">
        <button class="btn" type="submit" :disabled="!canSubmit || pending">
          {{ pending ? '送出中…' : '送出' }}
        </button>
        <button class="btn btn-outline" type="button" @click="reset" :disabled="pending">清除</button>
      </div>
    </form>

    <!-- 側邊補充 -->
    <aside class="contact-aside">
      <h3>其他聯絡方式</h3>
      <address>
        Email:<a href="mailto:hotdanton08@hotmail.com">hotdanton08@hotmail.com</a><br />
        GitHub:<a href="https://github.com/你的帳號" target="_blank">github.com/你的帳號</a><br />
        LinkedIn:<a href="https://linkedin.com/in/你的帳號" target="_blank">linkedin.com/in/你的帳號</a>
      </address>
    </aside>

    <!-- 成功提示 -->
    <Toast :open="toastOpen">表單已送出,感謝你的來信!</Toast>
  </section>
</template>

<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import Toast from './Toast.vue'
import { required, minLen, maxLen, email as emailRule, composeValidators } from '@/utils/validators'

type Fields = 'name' | 'email' | 'message' | 'agree'

const form = reactive({
  name: '',
  email: '',
  message: '',
  agree: false
})

const touched = reactive<Record<Fields, boolean>>({
  name: false, email: false, message: false, agree: false
})

const errors = reactive<Record<Fields, string | null>>({
  name: null, email: null, message: null, agree: null
})

// 規則
const validateName = composeValidators(
  required('姓名'), minLen(2, '姓名'), maxLen(20, '姓名')
)
const validateEmail = composeValidators(
  required('Email'), emailRule('Email')
)
const validateMessage = composeValidators(
  required('訊息'), minLen(10, '訊息'), maxLen(500, '訊息')
)
const validateAgree = (v: boolean) => v ? null : '請勾選同意條款'

// 單欄位驗證
function validateField(field: Fields) {
  switch (field) {
    case 'name':    errors.name    = validateName(form.name); break
    case 'email':   errors.email   = validateEmail(form.email); break
    case 'message': errors.message = validateMessage(form.message); break
    case 'agree':   errors.agree   = validateAgree(form.agree); break
  }
}

// blur 後才顯示錯誤
function touch(field: Fields) {
  touched[field] = true
  validateField(field)
}

// 是否顯示錯誤(已觸碰或送出後)
const submitted = ref(false)
function showError(field: Fields) {
  return (touched[field] || submitted.value) && errors[field]
}

// 任何輸入變動時,若已 touched 就即時驗證
function autoValidate(field: Fields) {
  if (touched[field]) validateField(field)
}

// 監看 v-model(最簡版:在 input/textarea 綁 @input 呼叫 autoValidate;此處用 computed 讓可提交狀態更新)
const allValid = computed(() => {
  // 先跑一輪驗證
  (['name','email','message','agree'] as Fields[]).forEach(validateField)
  return !errors.name && !errors.email && !errors.message && !errors.agree
})

const canSubmit = computed(() => allValid.value)

const pending = ref(false)
const toastOpen = ref(false)

async function onSubmit() {
  submitted.value = true
  ;(['name','email','message','agree'] as Fields[]).forEach(validateField)
  if (!allValid.value) return

  pending.value = true
  try {
    // 模擬呼叫 API
    await new Promise(r => setTimeout(r, 900))
    toastOpen.value = true
    reset()
    // 自動關閉 toast
    setTimeout(() => (toastOpen.value = false), 2000)
  } catch (e) {
    // 可在此顯示錯誤 toast 或訊息
    console.error(e)
  } finally {
    pending.value = false
  }
}

function reset() {
  form.name = ''
  form.email = ''
  form.message = ''
  form.agree = false
  ;(['name','email','message','agree'] as Fields[]).forEach(f => {
    touched[f] = false
    errors[f] = null
  })
  submitted.value = false
}
</script>

小技巧

  • 顯示錯誤條件(touched[field] || submitted) && errors[field] 可避免「一開始就滿天紅字」。
  • 送出按鈕disabled="!canSubmit || pending",避免重複送出。
  • a11y:欄位錯誤時設定 aria-invalid="true" 並用 aria-describedby 指向錯誤 <small>id

4) (可選)使用套件的最小示例

vee-validate(邏輯集中、可重用)

npm i vee-validate yup

<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  name: yup.string().required().min(2).max(20),
  email: yup.string().required().email(),
  message: yup.string().required().min(10).max(500),
  agree: yup.boolean().oneOf([true], '請勾選同意條款')
})

const { handleSubmit, isSubmitting } = useForm({ validationSchema: schema })
const { value: name, errorMessage: nameErr } = useField<string>('name')
const { value: email, errorMessage: emailErr } = useField<string>('email')
const { value: message, errorMessage: messageErr } = useField<string>('message')
const { value: agree, errorMessage: agreeErr } = useField<boolean>('agree')

const submit = handleSubmit(async (vals) => {
  await new Promise(r => setTimeout(r, 800))
  alert('OK')
})
</script>

套件優點:規則重用、表單狀態齊全、跨頁一致;缺點:多學一層抽象。小專案先用原生法就很好,日後再抽換。


成果檢查清單

  • 輸入欄位 失焦 後才顯示錯誤,重新輸入時即時消失
  • 送出前 整體檢查,不合法時不送;合法時顯示 Toast
  • 送出時按鈕進入 Loading 並禁用
  • 欄位有 aria-invalid、錯誤訊息與欄位以 aria-describedby 關聯

小心踩雷(誤用 → 正確)

  1. 一開始就顯示錯誤
    • ❌ 直接在模板判斷 errors.xxx
    • ✅ 加 touchedsubmitted 狀態控制時機
  2. 只做即時、不做送出檢查
    • ❌ 使用者快速跳過欄位仍能送出
    • ✅ 送出前跑全欄位驗證
  3. 沒有 a11y 屬性
    • ❌ 只有紅字但無 aria-invalid
    • ✅ 加上 aria-invalid + aria-describedby
  4. 多處重複規則
    • ❌ 每個欄位都手刻正規式
    • utils/validators.ts 集中維護

下一步(Day 25 預告)

把 Projects 改成 非同步載入fetch/axios)並加入 Loading / Error簡易快取;詳情頁在未載入時能先抓資料、找不到 slug 顯示 404/提示。


上一篇
Day 23 Vue Router – 作品詳情頁與路由導覽
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言