Vue 表單處理

表單是網頁應用程式中最常見的互動元素。Vue 提供了 v-model 指令,讓表單資料綁定變得簡單直覺。

基本表單綁定

文字輸入

<script setup>
import { ref } from 'vue'

const message = ref('')
</script>

<template>
  <input v-model="message" type="text" placeholder="輸入訊息">
  <p>訊息:{{ message }}</p>
</template>

多行文字

<script setup>
import { ref } from 'vue'

const content = ref('')
</script>

<template>
  <textarea v-model="content" placeholder="輸入內容"></textarea>
  <p style="white-space: pre-line;">{{ content }}</p>
</template>
<textarea> 中,{% raw %}{{ content }}{% endraw %} 的插值方式不會作用,必須使用 v-model

核取方塊(Checkbox)

單一核取方塊

綁定布林值:

<script setup>
import { ref } from 'vue'

const checked = ref(false)
</script>

<template>
  <label>
    <input type="checkbox" v-model="checked">
    同意條款
  </label>
  <p>是否同意:{{ checked }}</p>
</template>

多選核取方塊

綁定陣列:

<script setup>
import { ref } from 'vue'

const selected = ref([])
</script>

<template>
  <label><input type="checkbox" value="vue" v-model="selected"> Vue</label>
  <label><input type="checkbox" value="react" v-model="selected"> React</label>
  <label><input type="checkbox" value="angular" v-model="selected"> Angular</label>
  <p>已選擇:{{ selected }}</p>
</template>

單選按鈕(Radio)

<script setup>
import { ref } from 'vue'

const picked = ref('')
</script>

<template>
  <label><input type="radio" value="one" v-model="picked"> 選項一</label>
  <label><input type="radio" value="two" v-model="picked"> 選項二</label>
  <label><input type="radio" value="three" v-model="picked"> 選項三</label>
  <p>選擇:{{ picked }}</p>
</template>

下拉選單(Select)

單選

<script setup>
import { ref } from 'vue'

const selected = ref('')
</script>

<template>
  <select v-model="selected">
    <option value="" disabled>請選擇</option>
    <option value="a">選項 A</option>
    <option value="b">選項 B</option>
    <option value="c">選項 C</option>
  </select>
  <p>選擇:{{ selected }}</p>
</template>

多選

<script setup>
import { ref } from 'vue'

const selected = ref([])
</script>

<template>
  <select v-model="selected" multiple>
    <option value="a">選項 A</option>
    <option value="b">選項 B</option>
    <option value="c">選項 C</option>
  </select>
  <p>選擇:{{ selected }}</p>
</template>

動態選項

<script setup>
import { ref } from 'vue'

const selected = ref('')
const options = ref([
  { value: 'tw', label: '台灣' },
  { value: 'jp', label: '日本' },
  { value: 'us', label: '美國' }
])
</script>

<template>
  <select v-model="selected">
    <option value="" disabled>請選擇國家</option>
    <option v-for="opt in options" :key="opt.value" :value="opt.value">
      {{ opt.label }}
    </option>
  </select>
</template>

v-model 修飾符

.lazy

預設情況下,v-model 會在 input 事件後同步資料。.lazy 修飾符會改為在 change 事件後才同步:

<template>
  <!-- 失去焦點或按 Enter 後才更新 -->
  <input v-model.lazy="message">
</template>

.number

自動將輸入轉為數字:

<script setup>
import { ref } from 'vue'

const age = ref(0)
</script>

<template>
  <input v-model.number="age" type="number">
  <p>年齡:{{ age }}(型別:{{ typeof age }})</p>
</template>

.trim

自動去除首尾空白:

<template>
  <input v-model.trim="message">
</template>

值綁定

自訂核取方塊的值

<script setup>
import { ref } from 'vue'

const toggle = ref('no')
</script>

<template>
  <input
    type="checkbox"
    v-model="toggle"
    true-value="yes"
    false-value="no"
  >
  <p>{{ toggle }}</p>
</template>

綁定物件

<script setup>
import { ref } from 'vue'

const selected = ref(null)

const options = [
  { id: 1, name: '選項一' },
  { id: 2, name: '選項二' },
  { id: 3, name: '選項三' }
]
</script>

<template>
  <select v-model="selected">
    <option :value="null" disabled>請選擇</option>
    <option v-for="opt in options" :key="opt.id" :value="opt">
      {{ opt.name }}
    </option>
  </select>
  <p v-if="selected">選擇的物件:{{ selected.name }}(ID: {{ selected.id }})</p>
</template>

表單驗證

基本驗證

<script setup>
import { ref, reactive, computed } from 'vue'

const form = reactive({
  name: '',
  email: '',
  password: ''
})

const errors = reactive({
  name: '',
  email: '',
  password: ''
})

const isValid = computed(() => {
  return !errors.name && !errors.email && !errors.password &&
         form.name && form.email && form.password
})

function validateName() {
  if (!form.name) {
    errors.name = '請輸入姓名'
  } else if (form.name.length < 2) {
    errors.name = '姓名至少 2 個字'
  } else {
    errors.name = ''
  }
}

function validateEmail() {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!form.email) {
    errors.email = '請輸入 Email'
  } else if (!emailRegex.test(form.email)) {
    errors.email = '請輸入有效的 Email'
  } else {
    errors.email = ''
  }
}

function validatePassword() {
  if (!form.password) {
    errors.password = '請輸入密碼'
  } else if (form.password.length < 6) {
    errors.password = '密碼至少 6 個字元'
  } else {
    errors.password = ''
  }
}

function handleSubmit() {
  validateName()
  validateEmail()
  validatePassword()

  if (isValid.value) {
    console.log('提交表單:', form)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div class="field">
      <label>姓名</label>
      <input
        v-model="form.name"
        @blur="validateName"
        :class="{ error: errors.name }"
      >
      <span class="error-msg">{{ errors.name }}</span>
    </div>

    <div class="field">
      <label>Email</label>
      <input
        v-model="form.email"
        type="email"
        @blur="validateEmail"
        :class="{ error: errors.email }"
      >
      <span class="error-msg">{{ errors.email }}</span>
    </div>

    <div class="field">
      <label>密碼</label>
      <input
        v-model="form.password"
        type="password"
        @blur="validatePassword"
        :class="{ error: errors.password }"
      >
      <span class="error-msg">{{ errors.password }}</span>
    </div>

    <button type="submit" :disabled="!isValid">送出</button>
  </form>
</template>

<style scoped>
.field {
  margin-bottom: 16px;
}

label {
  display: block;
  margin-bottom: 4px;
}

input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

input.error {
  border-color: #dc3545;
}

.error-msg {
  color: #dc3545;
  font-size: 14px;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

完整表單範例

註冊表單

<script setup>
import { reactive, computed } from 'vue'

const form = reactive({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  gender: '',
  interests: [],
  country: '',
  bio: '',
  agree: false
})

const countries = [
  { value: 'tw', label: '台灣' },
  { value: 'jp', label: '日本' },
  { value: 'us', label: '美國' },
  { value: 'other', label: '其他' }
]

const interestOptions = ['程式設計', '設計', '音樂', '運動', '閱讀']

const passwordMatch = computed(() => {
  return form.password === form.confirmPassword
})

const canSubmit = computed(() => {
  return form.username &&
         form.email &&
         form.password &&
         form.confirmPassword &&
         passwordMatch.value &&
         form.agree
})

function handleSubmit() {
  if (!canSubmit.value) return

  console.log('註冊資料:', { ...form })
  alert('註冊成功!')
}

function handleReset() {
  Object.assign(form, {
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    gender: '',
    interests: [],
    country: '',
    bio: '',
    agree: false
  })
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="register-form">
    <h2>會員註冊</h2>

    <!-- 使用者名稱 -->
    <div class="form-group">
      <label for="username">使用者名稱 *</label>
      <input
        id="username"
        v-model="form.username"
        type="text"
        required
      >
    </div>

    <!-- Email -->
    <div class="form-group">
      <label for="email">Email *</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        required
      >
    </div>

    <!-- 密碼 -->
    <div class="form-group">
      <label for="password">密碼 *</label>
      <input
        id="password"
        v-model="form.password"
        type="password"
        minlength="6"
        required
      >
    </div>

    <!-- 確認密碼 -->
    <div class="form-group">
      <label for="confirmPassword">確認密碼 *</label>
      <input
        id="confirmPassword"
        v-model="form.confirmPassword"
        type="password"
        required
      >
      <span v-if="form.confirmPassword && !passwordMatch" class="error">
        密碼不一致
      </span>
    </div>

    <!-- 性別 -->
    <div class="form-group">
      <label>性別</label>
      <div class="radio-group">
        <label>
          <input type="radio" v-model="form.gender" value="male"> 男
        </label>
        <label>
          <input type="radio" v-model="form.gender" value="female"> 女
        </label>
        <label>
          <input type="radio" v-model="form.gender" value="other"> 其他
        </label>
      </div>
    </div>

    <!-- 興趣 -->
    <div class="form-group">
      <label>興趣</label>
      <div class="checkbox-group">
        <label v-for="interest in interestOptions" :key="interest">
          <input
            type="checkbox"
            v-model="form.interests"
            :value="interest"
          >
          {{ interest }}
        </label>
      </div>
    </div>

    <!-- 國家 -->
    <div class="form-group">
      <label for="country">國家</label>
      <select id="country" v-model="form.country">
        <option value="">請選擇</option>
        <option v-for="c in countries" :key="c.value" :value="c.value">
          {{ c.label }}
        </option>
      </select>
    </div>

    <!-- 自我介紹 -->
    <div class="form-group">
      <label for="bio">自我介紹</label>
      <textarea
        id="bio"
        v-model="form.bio"
        rows="4"
        placeholder="簡單介紹一下自己..."
      ></textarea>
    </div>

    <!-- 同意條款 -->
    <div class="form-group">
      <label class="checkbox-label">
        <input type="checkbox" v-model="form.agree">
        我同意服務條款和隱私政策 *
      </label>
    </div>

    <!-- 按鈕 -->
    <div class="form-actions">
      <button type="button" @click="handleReset">重置</button>
      <button type="submit" :disabled="!canSubmit">註冊</button>
    </div>

    <!-- 預覽 -->
    <details class="preview">
      <summary>預覽表單資料</summary>
      <pre>{{ JSON.stringify(form, null, 2) }}</pre>
    </details>
  </form>
</template>

<style scoped>
.register-form {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.form-group {
  margin-bottom: 16px;
}

.form-group label {
  display: block;
  margin-bottom: 4px;
  font-weight: bold;
}

.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="password"],
.form-group select,
.form-group textarea {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.radio-group,
.checkbox-group {
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
}

.radio-group label,
.checkbox-group label,
.checkbox-label {
  font-weight: normal;
  display: flex;
  align-items: center;
  gap: 4px;
}

.form-actions {
  display: flex;
  gap: 10px;
  margin-top: 20px;
}

.form-actions button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.form-actions button[type="submit"] {
  background-color: #42b883;
  color: white;
}

.form-actions button[type="submit"]:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.form-actions button[type="button"] {
  background-color: #ddd;
}

.error {
  color: #dc3545;
  font-size: 14px;
}

.preview {
  margin-top: 20px;
  padding: 10px;
  background: #f5f5f5;
  border-radius: 4px;
}

.preview pre {
  margin: 10px 0 0;
  font-size: 12px;
}
</style>

使用驗證函式庫

對於複雜的表單驗證,建議使用專門的驗證函式庫,如:

這些函式庫提供了更完整的驗證功能,包括非同步驗證、複雜的驗證規則等。