Vue3 表单输入 v-model 指令详解

Vue3 表单输入 v-model 指令详解

核心概念理解

什么是 v-model?

v-model 是 Vue 的双向数据绑定指令,它实现了:

  • 数据 → 视图:当数据变化时,视图自动更新
  • 视图 → 数据:当用户输入时,数据自动更新

基本工作原理

vue 复制代码
<!-- v-model 是语法糖,等价于: -->
<input :value="message" @input="message = $event.target.value">
或
<input :value="text"  @input="event => message = event.target.value">

<!-- 简写为: -->
<input v-model="message">

基础用法

1. 文本输入框

vue 复制代码
<template>
  <div class="form-demo">
    <h2>基础表单输入</h2>
    
    <!-- 文本输入 -->
    <div class="input-group">
      <label>姓名:</label>
      <input 
        v-model="name" 
        type="text" 
        placeholder="请输入姓名"
        class="form-input"
      >
      <p>输入的姓名: {{ name }}</p>
    </div>
    
    <!-- 多行文本 -->
    <div class="input-group">
      <label>个人简介:</label>
      <textarea 
        v-model="bio" 
        placeholder="请输入个人简介"
        rows="4"
        class="form-textarea"
      ></textarea>
      <p>字数统计: {{ bio.length }}</p>
    </div>
    
    <!-- 密码输入 -->
    <div class="input-group">
      <label>密码:</label>
      <input 
        v-model="password" 
        type="password" 
        placeholder="请输入密码"
        class="form-input"
      >
      <p>密码长度: {{ password.length }}</p>
    </div>
  </div>
</template>

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

const name = ref('')
const bio = ref('')
const password = ref('')
</script>

<style>
.form-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.input-group {
  margin-bottom: 25px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.input-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
  color: #495057;
}

.form-input, .form-textarea {
  width: 100%;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.2s ease;
}

.form-input:focus, .form-textarea:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.form-textarea {
  resize: vertical;
  font-family: inherit;
}
</style>

各种表单元素的 v-model

1. 复选框 (Checkbox)

vue 复制代码
<template>
  <div class="checkbox-demo">
    <h2>复选框</h2>
    
    <!-- 单个复选框 -->
    <div class="checkbox-group">
      <h3>单个复选框</h3>
      <label class="checkbox-label">
        <input 
          v-model="isChecked" 
          type="checkbox"
          class="checkbox-input"
        >
        <span class="checkbox-text">同意用户协议</span>
      </label>
      <p>状态: {{ isChecked ? '已同意' : '未同意' }}</p>
    </div>
    
    <!-- 多个复选框 -->
    <div class="checkbox-group">
      <h3>兴趣爱好 (多选)</h3>
      <div class="checkbox-list">
        <label 
          v-for="hobby in hobbies" 
          :key="hobby.value"
          class="checkbox-label"
        >
          <input 
            v-model="selectedHobbies" 
            :value="hobby.value"
            type="checkbox"
            class="checkbox-input"
          >
          <span class="checkbox-text">{{ hobby.label }}</span>
        </label>
      </div>
      <p>选择的兴趣: {{ selectedHobbies.join(', ') }}</p>
    </div>
  </div>
</template>

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

const isChecked = ref(false)

const hobbies = ref([
  { value: 'reading', label: '阅读' },
  { value: 'music', label: '音乐' },
  { value: 'sports', label: '运动' },
  { value: 'travel', label: '旅行' },
  { value: 'cooking', label: '烹饪' }
])

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

<style>
.checkbox-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.checkbox-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
}

.checkbox-group h3 {
  margin-top: 0;
  color: #495057;
}

.checkbox-list {
  display: flex;
  flex-wrap: wrap;
  gap: 15px;
  margin: 15px 0;
}

.checkbox-label {
  display: flex;
  align-items: center;
  cursor: pointer;
  user-select: none;
}

.checkbox-input {
  margin-right: 8px;
  width: 18px;
  height: 18px;
  cursor: pointer;
}

.checkbox-text {
  font-size: 16px;
  color: #333;
}
</style>

2. 单选框 (Radio)

vue 复制代码
<template>
  <div class="radio-demo">
    <h2>单选框</h2>
    
    <!-- 性别选择 -->
    <div class="radio-group">
      <h3>性别</h3>
      <div class="radio-list">
        <label 
          v-for="gender in genders" 
          :key="gender.value"
          class="radio-label"
        >
          <input 
            v-model="selectedGender" 
            :value="gender.value"
            type="radio"
            name="gender"
            class="radio-input"
          >
          <span class="radio-text">{{ gender.label }}</span>
        </label>
      </div>
      <p>选择的性别: {{ selectedGender }}</p>
    </div>
    
    <!-- 年龄段选择 -->
    <div class="radio-group">
      <h3>年龄段</h3>
      <div class="radio-list">
        <label 
          v-for="age in ageGroups" 
          :key="age.value"
          class="radio-label"
        >
          <input 
            v-model="selectedAgeGroup" 
            :value="age.value"
            type="radio"
            name="age-group"
            class="radio-input"
          >
          <span class="radio-text">{{ age.label }}</span>
        </label>
      </div>
      <p>选择的年龄段: {{ selectedAgeGroup }}</p>
    </div>
  </div>
</template>

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

const genders = ref([
  { value: 'male', label: '男' },
  { value: 'female', label: '女' },
  { value: 'other', label: '其他' }
])

const ageGroups = ref([
  { value: '18-25', label: '18-25岁' },
  { value: '26-35', label: '26-35岁' },
  { value: '36-45', label: '36-45岁' },
  { value: '46+', label: '46岁以上' }
])

const selectedGender = ref('')
const selectedAgeGroup = ref('')
</script>

<style>
.radio-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.radio-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.radio-group h3 {
  margin-top: 0;
  color: #495057;
}

.radio-list {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  margin: 15px 0;
}

.radio-label {
  display: flex;
  align-items: center;
  cursor: pointer;
  user-select: none;
}

.radio-input {
  margin-right: 8px;
  width: 18px;
  height: 18px;
  cursor: pointer;
}

.radio-text {
  font-size: 16px;
  color: #333;
}
</style>

3. 选择框 (Select)

vue 复制代码
<template>
  <div class="select-demo">
    <h2>选择框</h2>
    
    <!-- 单选下拉框 -->
    <div class="select-group">
      <h3>城市选择</h3>
      <select v-model="selectedCity" class="form-select">
        <option value="">请选择城市</option>
        <option 
          v-for="city in cities" 
          :key="city.value" 
          :value="city.value"
        >
          {{ city.label }}
        </option>
      </select>
      <p>选择的城市: {{ selectedCity || '未选择' }}</p>
    </div>
    
    <!-- 多选下拉框 -->
    <div class="select-group">
      <h3>技能选择 (多选)</h3>
      <select 
        v-model="selectedSkills" 
        multiple
        class="form-select multiple"
        size="5"
      >
        <option 
          v-for="skill in skills" 
          :key="skill.value" 
          :value="skill.value"
        >
          {{ skill.label }}
        </option>
      </select>
      <p>选择的技能: {{ selectedSkills.join(', ') || '未选择' }}</p>
    </div>
    
    <!-- 分组选择框 -->
    <div class="select-group">
      <h3>分组选择</h3>
      <select v-model="selectedProduct" class="form-select">
        <option value="">请选择产品</option>
        <optgroup 
          v-for="category in productCategories" 
          :key="category.name"
          :label="category.name"
        >
          <option 
            v-for="product in category.products" 
            :key="product.value" 
            :value="product.value"
          >
            {{ product.label }}
          </option>
        </optgroup>
      </select>
      <p>选择的产品: {{ selectedProduct || '未选择' }}</p>
    </div>
  </div>
</template>

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

const cities = ref([
  { value: 'beijing', label: '北京' },
  { value: 'shanghai', label: '上海' },
  { value: 'guangzhou', label: '广州' },
  { value: 'shenzhen', label: '深圳' },
  { value: 'hangzhou', label: '杭州' }
])

const skills = ref([
  { value: 'javascript', label: 'JavaScript' },
  { value: 'vue', label: 'Vue.js' },
  { value: 'react', label: 'React' },
  { value: 'node', label: 'Node.js' },
  { value: 'python', label: 'Python' },
  { value: 'java', label: 'Java' }
])

const productCategories = ref([
  {
    name: '电子产品',
    products: [
      { value: 'phone', label: '手机' },
      { value: 'laptop', label: '笔记本电脑' },
      { value: 'tablet', label: '平板电脑' }
    ]
  },
  {
    name: '家用电器',
    products: [
      { value: 'tv', label: '电视机' },
      { value: 'fridge', label: '冰箱' },
      { value: 'washer', label: '洗衣机' }
    ]
  }
])

const selectedCity = ref('')
const selectedSkills = ref([])
const selectedProduct = ref('')
</script>

<style>
.select-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.select-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
}

.select-group h3 {
  margin-top: 0;
  color: #495057;
}

.form-select {
  width: 100%;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
  background-color: white;
  cursor: pointer;
}

.form-select:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.form-select.multiple {
  height: auto;
  cursor: default;
}
</style>

v-model 修饰符

1. .lazy 修饰符

vue 复制代码
<template>
  <div class="modifier-demo">
    <h2>v-model 修饰符</h2>
    
    <!-- 默认:实时更新 -->
    <div class="modifier-group">
      <h3>默认模式 (实时更新)</h3>
      <input 
        v-model="realTimeInput" 
        placeholder="输入文字实时更新"
        class="form-input"
      >
      <p>实时值: {{ realTimeInput }}</p>
    </div>
    
    <!-- .lazy:失去焦点时更新 -->
    <div class="modifier-group">
      <h3>.lazy 修饰符 (失去焦点更新)</h3>
      <input 
        v-model.lazy="lazyInput" 
        placeholder="输入完成后失去焦点才更新"
        class="form-input"
      >
      <p>延迟值: {{ lazyInput }}</p>
    </div>
  </div>
</template>

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

const realTimeInput = ref('')
const lazyInput = ref('')
</script>

<style>
.modifier-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.modifier-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.modifier-group h3 {
  margin-top: 0;
  color: #495057;
}

.form-input {
  width: 100%;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
  margin-bottom: 10px;
}

.form-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
</style>

2. .number 修饰符

vue 复制代码
<template>
  <div class="number-demo">
    <h2>.number 修饰符</h2>
    
    <!-- 不使用 .number -->
    <div class="number-group">
      <h3>普通输入</h3>
      <input 
        v-model="normalNumber" 
        type="number"
        placeholder="输入数字"
        class="form-input"
      >
      <p>值: {{ normalNumber }} (类型: {{ typeof normalNumber }})</p>
    </div>
    
    <!-- 使用 .number -->
    <div class="number-group">
      <h3>.number 修饰符</h3>
      <input 
        v-model.number="typedNumber" 
        type="number"
        placeholder="输入数字"
        class="form-input"
      >
      <p>值: {{ typedNumber }} (类型: {{ typeof typedNumber }})</p>
    </div>
    
    <!-- 计算示例 -->
    <div class="calculation-demo">
      <h3>数字计算</h3>
      <div class="calc-inputs">
        <input 
          v-model.number="num1" 
          type="number"
          placeholder="第一个数字"
          class="form-input small"
        >
        <select v-model="operator" class="form-select operator">
          <option value="+">+</option>
          <option value="-">-</option>
          <option value="*">×</option>
          <option value="/">÷</option>
        </select>
        <input 
          v-model.number="num2" 
          type="number"
          placeholder="第二个数字"
          class="form-input small"
        >
      </div>
      <p class="result">结果: {{ calculateResult }}</p>
    </div>
  </div>
</template>

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

const normalNumber = ref('')
const typedNumber = ref('')
const num1 = ref(0)
const num2 = ref(0)
const operator = ref('+')

const calculateResult = computed(() => {
  switch (operator.value) {
    case '+': return num1.value + num2.value
    case '-': return num1.value - num2.value
    case '*': return num1.value * num2.value
    case '/': return num2.value !== 0 ? num1.value / num2.value : '除数不能为0'
    default: return 0
  }
})
</script>

<style>
.number-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.number-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
}

.number-group h3 {
  margin-top: 0;
  color: #495057;
}

.calculation-demo {
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.calc-inputs {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 15px;
}

.form-input.small {
  flex: 1;
  max-width: 120px;
}

.form-select.operator {
  width: 60px;
  padding: 12px;
}

.result {
  font-size: 18px;
  font-weight: bold;
  color: #28a745;
  text-align: center;
  margin: 0;
}

.form-input, .form-select {
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
}

.form-input:focus, .form-select:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
</style>

3. .trim 修饰符

vue 复制代码
<template>
  <div class="trim-demo">
    <h2>.trim 修饰符</h2>
    
    <!-- 不使用 .trim -->
    <div class="trim-group">
      <h3>普通输入 (保留空格)</h3>
      <input 
        v-model="normalInput" 
        placeholder="输入文字 (包含空格)"
        class="form-input"
      >
      <p>值: "{{ normalInput }}"</p>
      <p>长度: {{ normalInput.length }}</p>
    </div>
    
    <!-- 使用 .trim -->
    <div class="trim-group">
      <h3>.trim 修饰符 (去除首尾空格)</h3>
      <input 
        v-model.trim="trimmedInput" 
        placeholder="输入文字 (自动去除空格)"
        class="form-input"
      >
      <p>值: "{{ trimmedInput }}"</p>
      <p>长度: {{ trimmedInput.length }}</p>
    </div>
    
    <!-- 实际应用示例 -->
    <div class="username-demo">
      <h3>用户名输入优化</h3>
      <div class="input-row">
        <input 
          v-model.trim="username" 
          placeholder="请输入用户名"
          class="form-input"
        >
        <button 
          @click="validateUsername"
          :disabled="!username"
          class="validate-btn"
        >
          验证
        </button>
      </div>
      <div v-if="username" class="validation-result">
        <p>输入的用户名: "{{ username }}"</p>
        <p>长度: {{ username.length }} 字符</p>
        <p class="status" :class="usernameStatus.class">
          {{ usernameStatus.message }}
        </p>
      </div>
    </div>
  </div>
</template>

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

const normalInput = ref('')
const trimmedInput = ref('')
const username = ref('')

const usernameStatus = computed(() => {
  if (username.value.length === 0) {
    return { message: '请输入用户名', class: 'info' }
  } else if (username.value.length < 3) {
    return { message: '用户名至少3个字符', class: 'error' }
  } else if (username.value.length > 20) {
    return { message: '用户名不能超过20个字符', class: 'error' }
  } else {
    return { message: '用户名格式正确 ✅', class: 'success' }
  }
})

const validateUsername = () => {
  alert(`验证用户名: "${username.value}"`)
}
</script>

<style>
.trim-demo {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.trim-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
}

.trim-group h3 {
  margin-top: 0;
  color: #495057;
}

.username-demo {
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.input-row {
  display: flex;
  gap: 10px;
  margin-bottom: 15px;
}

.form-input {
  flex: 1;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
}

.form-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.validate-btn {
  padding: 12px 20px;
  border: none;
  border-radius: 4px;
  background-color: #007bff;
  color: white;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s ease;
}

.validate-btn:hover:not(:disabled) {
  background-color: #0056b3;
}

.validate-btn:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}

.validation-result {
  margin-top: 15px;
  padding: 15px;
  background-color: #fff;
  border-radius: 4px;
}

.status {
  font-weight: bold;
  margin: 5px 0 0 0;
}

.status.success {
  color: #28a745;
}

.status.error {
  color: #dc3545;
}

.status.info {
  color: #17a2b8;
}
</style>

实际应用示例

1. 完整的用户注册表单

vue 复制代码
<template>
  <div class="registration-form">
    <h2>用户注册</h2>
    
    <form @submit.prevent="handleSubmit" class="form-container">
      <!-- 用户名 -->
      <div class="form-group">
        <label for="username">用户名 *</label>
        <input 
          id="username"
          v-model.trim="form.username"
          type="text"
          placeholder="请输入用户名 (3-20个字符)"
          :class="{ 'error': errors.username }"
          @blur="validateUsername"
        >
        <span v-if="errors.username" class="error-message">
          {{ errors.username }}
        </span>
      </div>
      
      <!-- 邮箱 -->
      <div class="form-group">
        <label for="email">邮箱 *</label>
        <input 
          id="email"
          v-model.trim="form.email"
          type="email"
          placeholder="请输入邮箱地址"
          :class="{ 'error': errors.email }"
          @blur="validateEmail"
        >
        <span v-if="errors.email" class="error-message">
          {{ errors.email }}
        </span>
      </div>
      
      <!-- 密码 -->
      <div class="form-group">
        <label for="password">密码 *</label>
        <input 
          id="password"
          v-model="form.password"
          type="password"
          placeholder="请输入密码 (至少6个字符)"
          :class="{ 'error': errors.password }"
          @blur="validatePassword"
        >
        <span v-if="errors.password" class="error-message">
          {{ errors.password }}
        </span>
      </div>
      
      <!-- 确认密码 -->
      <div class="form-group">
        <label for="confirmPassword">确认密码 *</label>
        <input 
          id="confirmPassword"
          v-model="form.confirmPassword"
          type="password"
          placeholder="请再次输入密码"
          :class="{ 'error': errors.confirmPassword }"
          @blur="validateConfirmPassword"
        >
        <span v-if="errors.confirmPassword" class="error-message">
          {{ errors.confirmPassword }}
        </span>
      </div>
      
      <!-- 性别 -->
      <div class="form-group">
        <label>性别</label>
        <div class="radio-group">
          <label 
            v-for="gender in genders" 
            :key="gender.value"
            class="radio-label"
          >
            <input 
              v-model="form.gender"
              :value="gender.value"
              type="radio"
              name="gender"
            >
            <span>{{ gender.label }}</span>
          </label>
        </div>
      </div>
      
      <!-- 兴趣爱好 -->
      <div class="form-group">
        <label>兴趣爱好</label>
        <div class="checkbox-group">
          <label 
            v-for="hobby in hobbies" 
            :key="hobby.value"
            class="checkbox-label"
          >
            <input 
              v-model="form.hobbies"
              :value="hobby.value"
              type="checkbox"
            >
            <span>{{ hobby.label }}</span>
          </label>
        </div>
      </div>
      
      <!-- 城市 -->
      <div class="form-group">
        <label for="city">所在城市</label>
        <select 
          id="city"
          v-model="form.city"
          class="form-select"
        >
          <option value="">请选择城市</option>
          <option 
            v-for="city in cities" 
            :key="city.value" 
            :value="city.value"
          >
            {{ city.label }}
          </option>
        </select>
      </div>
      
      <!-- 个人简介 -->
      <div class="form-group">
        <label for="bio">个人简介</label>
        <textarea 
          id="bio"
          v-model.trim="form.bio"
          placeholder="请输入个人简介"
          rows="4"
          class="form-textarea"
        ></textarea>
        <div class="char-count">
          {{ form.bio.length }}/200 字符
        </div>
      </div>
      
      <!-- 协议同意 -->
      <div class="form-group">
        <label class="checkbox-label agreement">
          <input 
            v-model="form.agree"
            type="checkbox"
            required
          >
          <span>我已阅读并同意 <a href="#" @click.prevent="showAgreement">用户协议</a></span>
        </label>
        <span v-if="errors.agree" class="error-message">
          {{ errors.agree }}
        </span>
      </div>
      
      <!-- 提交按钮 -->
      <div class="form-actions">
        <button 
          type="submit" 
          :disabled="!isFormValid"
          class="submit-btn"
        >
          注册
        </button>
        <button 
          @click="resetForm"
          type="button"
          class="reset-btn"
        >
          重置
        </button>
      </div>
    </form>
    
    <!-- 提交成功提示 -->
    <div v-if="isSubmitted" class="success-message">
      <h3>🎉 注册成功!</h3>
      <p>欢迎 {{ submittedData.username }} 加入我们!</p>
      <button @click="resetAll" class="reset-btn">重新注册</button>
    </div>
  </div>
</template>

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

const form = ref({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  gender: '',
  hobbies: [],
  city: '',
  bio: '',
  agree: false
})

const errors = ref({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  agree: ''
})

const isSubmitted = ref(false)
const submittedData = ref({})

// 选项数据
const genders = ref([
  { value: 'male', label: '男' },
  { value: 'female', label: '女' },
  { value: 'other', label: '其他' }
])

const hobbies = ref([
  { value: 'reading', label: '阅读' },
  { value: 'music', label: '音乐' },
  { value: 'sports', label: '运动' },
  { value: 'travel', label: '旅行' },
  { value: 'cooking', label: '烹饪' },
  { value: 'gaming', label: '游戏' }
])

const cities = ref([
  { value: 'beijing', label: '北京' },
  { value: 'shanghai', label: '上海' },
  { value: 'guangzhou', label: '广州' },
  { value: 'shenzhen', label: '深圳' },
  { value: 'hangzhou', label: '杭州' },
  { value: 'chengdu', label: '成都' }
])

// 验证函数
const validateUsername = () => {
  if (!form.value.username) {
    errors.value.username = '用户名不能为空'
  } else if (form.value.username.length < 3) {
    errors.value.username = '用户名至少3个字符'
  } else if (form.value.username.length > 20) {
    errors.value.username = '用户名不能超过20个字符'
  } else {
    errors.value.username = ''
  }
}

const validateEmail = () => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!form.value.email) {
    errors.value.email = '邮箱不能为空'
  } else if (!emailRegex.test(form.value.email)) {
    errors.value.email = '请输入有效的邮箱地址'
  } else {
    errors.value.email = ''
  }
}

const validatePassword = () => {
  if (!form.value.password) {
    errors.value.password = '密码不能为空'
  } else if (form.value.password.length < 6) {
    errors.value.password = '密码至少6个字符'
  } else {
    errors.value.password = ''
    validateConfirmPassword() // 验证确认密码
  }
}

const validateConfirmPassword = () => {
  if (form.value.password !== form.value.confirmPassword) {
    errors.value.confirmPassword = '两次输入的密码不一致'
  } else {
    errors.value.confirmPassword = ''
  }
}

// 计算属性:表单是否有效
const isFormValid = computed(() => {
  return (
    !errors.value.username &&
    !errors.value.email &&
    !errors.value.password &&
    !errors.value.confirmPassword &&
    !errors.value.agree &&
    form.value.username &&
    form.value.email &&
    form.value.password &&
    form.value.confirmPassword &&
    form.value.agree
  )
})

// 表单提交
const handleSubmit = () => {
  // 触发所有验证
  validateUsername()
  validateEmail()
  validatePassword()
  validateConfirmPassword()
  
  if (!form.value.agree) {
    errors.value.agree = '请同意用户协议'
  } else {
    errors.value.agree = ''
  }
  
  if (isFormValid.value) {
    isSubmitted.value = true
    submittedData.value = { ...form.value }
    console.log('表单提交数据:', form.value)
  } else {
    alert('请检查表单信息')
  }
}

// 重置表单
const resetForm = () => {
  form.value = {
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    gender: '',
    hobbies: [],
    city: '',
    bio: '',
    agree: false
  }
  // 清空错误信息
  Object.keys(errors.value).forEach(key => {
    errors.value[key] = ''
  })
}

const resetAll = () => {
  resetForm()
  isSubmitted.value = false
}

// 显示协议
const showAgreement = () => {
  alert('这里是用户协议内容...')
}
</script>

<style>
.registration-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.form-container {
  margin-bottom: 20px;
}

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

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
  color: #495057;
}

.form-input, .form-select, .form-textarea {
  width: 100%;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.2s ease;
}

.form-input:focus, .form-select:focus, .form-textarea:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.form-input.error, .form-select.error, .form-textarea.error {
  border-color: #dc3545;
}

.radio-group, .checkbox-group {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  margin-top: 10px;
}

.radio-label, .checkbox-label {
  display: flex;
  align-items: center;
  cursor: pointer;
  user-select: none;
}

.radio-label input, .checkbox-label input {
  margin-right: 8px;
  width: 18px;
  height: 18px;
  cursor: pointer;
}

.agreement {
  font-weight: normal;
}

.agreement a {
  color: #007bff;
  text-decoration: none;
}

.agreement a:hover {
  text-decoration: underline;
}

.char-count {
  text-align: right;
  font-size: 14px;
  color: #6c757d;
  margin-top: 5px;
}

.error-message {
  color: #dc3545;
  font-size: 14px;
  margin-top: 5px;
  display: block;
}

.form-actions {
  display: flex;
  gap: 15px;
  margin-top: 30px;
}

.submit-btn, .reset-btn {
  flex: 1;
  padding: 12px 20px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.submit-btn {
  background-color: #28a745;
  color: white;
}

.submit-btn:hover:not(:disabled) {
  background-color: #218838;
}

.submit-btn:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}

.reset-btn {
  background-color: #6c757d;
  color: white;
}

.reset-btn:hover {
  background-color: #5a6268;
}

.success-message {
  text-align: center;
  padding: 40px 20px;
  background-color: #d4edda;
  border: 1px solid #c3e6cb;
  border-radius: 8px;
}

.success-message h3 {
  color: #155724;
  margin-bottom: 15px;
}

.success-message p {
  color: #155724;
  margin-bottom: 20px;
}
</style>

2. 动态表单示例

vue 复制代码
<template>
  <div class="dynamic-form">
    <h2>动态表单</h2>
    
    <!-- 表单类型选择 -->
    <div class="form-selector">
      <label>选择表单类型:</label>
      <select v-model="selectedFormType" class="form-select">
        <option value="contact">联系信息</option>
        <option value="feedback">意见反馈</option>
        <option value="survey">问卷调查</option>
      </select>
    </div>
    
    <!-- 动态表单 -->
    <form @submit.prevent="submitDynamicForm" class="dynamic-form-container">
      <div 
        v-for="field in currentFormFields" 
        :key="field.name"
        class="form-group"
      >
        <label :for="field.name">{{ field.label }}</label>
        
        <!-- 文本输入 -->
        <input 
          v-if="field.type === 'text' || field.type === 'email'"
          :id="field.name"
          v-model="formData[field.name]"
          :type="field.type"
          :placeholder="field.placeholder"
          :required="field.required"
          class="form-input"
        >
        
        <!-- 文本域 -->
        <textarea 
          v-else-if="field.type === 'textarea'"
          :id="field.name"
          v-model="formData[field.name]"
          :placeholder="field.placeholder"
          :rows="field.rows || 4"
          :required="field.required"
          class="form-textarea"
        ></textarea>
        
        <!-- 选择框 -->
        <select 
          v-else-if="field.type === 'select'"
          :id="field.name"
          v-model="formData[field.name]"
          :required="field.required"
          class="form-select"
        >
          <option value="">{{ field.placeholder }}</option>
          <option 
            v-for="option in field.options" 
            :key="option.value" 
            :value="option.value"
          >
            {{ option.label }}
          </option>
        </select>
        
        <!-- 复选框 -->
        <div 
          v-else-if="field.type === 'checkbox'"
          class="checkbox-group"
        >
          <label 
            v-for="option in field.options" 
            :key="option.value"
            class="checkbox-label"
          >
            <input 
              v-model="formData[field.name]"
              :value="option.value"
              type="checkbox"
            >
            <span>{{ option.label }}</span>
          </label>
        </div>
        
        <!-- 单选框 -->
        <div 
          v-else-if="field.type === 'radio'"
          class="radio-group"
        >
          <label 
            v-for="option in field.options" 
            :key="option.value"
            class="radio-label"
          >
            <input 
              v-model="formData[field.name]"
              :value="option.value"
              type="radio"
              :name="field.name"
            >
            <span>{{ option.label }}</span>
          </label>
        </div>
      </div>
      
      <div class="form-actions">
        <button type="submit" class="submit-btn">提交</button>
        <button @click="resetDynamicForm" type="button" class="reset-btn">重置</button>
      </div>
    </form>
    
    <!-- 提交结果 -->
    <div v-if="dynamicSubmitted" class="result-display">
      <h3>提交结果:</h3>
      <pre>{{ JSON.stringify(formData, null, 2) }}</pre>
    </div>
  </div>
</template>

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

const selectedFormType = ref('contact')
const formData = ref({})
const dynamicSubmitted = ref(false)

// 表单配置
const formConfig = {
  contact: [
    { name: 'name', label: '姓名', type: 'text', placeholder: '请输入姓名', required: true },
    { name: 'email', label: '邮箱', type: 'email', placeholder: '请输入邮箱', required: true },
    { name: 'phone', label: '电话', type: 'text', placeholder: '请输入电话号码', required: false },
    { name: 'message', label: '留言', type: 'textarea', placeholder: '请输入留言内容', required: true }
  ],
  feedback: [
    { name: 'satisfaction', label: '满意度', type: 'radio', required: true, options: [
      { value: 'very-satisfied', label: '非常满意' },
      { value: 'satisfied', label: '满意' },
      { value: 'neutral', label: '一般' },
      { value: 'unsatisfied', label: '不满意' }
    ]},
    { name: 'improvements', label: '改进建议', type: 'textarea', placeholder: '请提出您的宝贵建议', required: false },
    { name: 'contact', label: '希望联系', type: 'checkbox', options: [
      { value: 'email', label: '通过邮箱联系' },
      { value: 'phone', label: '通过电话联系' }
    ]}
  ],
  survey: [
    { name: 'age', label: '年龄段', type: 'select', placeholder: '请选择年龄段', required: true, options: [
      { value: '18-25', label: '18-25岁' },
      { value: '26-35', label: '26-35岁' },
      { value: '36-45', label: '36-45岁' },
      { value: '46+', label: '46岁以上' }
    ]},
    { name: 'interests', label: '兴趣爱好', type: 'checkbox', options: [
      { value: 'technology', label: '科技' },
      { value: 'sports', label: '体育' },
      { value: 'music', label: '音乐' },
      { value: 'travel', label: '旅行' },
      { value: 'reading', label: '阅读' }
    ]},
    { name: 'comments', label: '其他意见', type: 'textarea', placeholder: '请分享您的其他想法', required: false }
  ]
}

// 计算当前表单字段
const currentFormFields = computed(() => {
  return formConfig[selectedFormType.value] || []
})

// 初始化表单数据
const initializeFormData = () => {
  const newFormData = {}
  currentFormFields.value.forEach(field => {
    if (field.type === 'checkbox') {
      newFormData[field.name] = []
    } else {
      newFormData[field.name] = ''
    }
  })
  formData.value = newFormData
}

// 监听表单类型变化
import { watch } from 'vue'
watch(selectedFormType, () => {
  initializeFormData()
  dynamicSubmitted.value = false
}, { immediate: true })

// 提交动态表单
const submitDynamicForm = () => {
  dynamicSubmitted.value = true
  console.log('动态表单提交:', formData.value)
}

// 重置动态表单
const resetDynamicForm = () => {
  initializeFormData()
  dynamicSubmitted.value = false
}
</script>

<style>
.dynamic-form {
  max-width: 700px;
  margin: 0 auto;
  padding: 20px;
}

.form-selector {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.form-selector label {
  display: block;
  margin-bottom: 10px;
  font-weight: bold;
  color: #495057;
}

.dynamic-form-container {
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
  margin-bottom: 20px;
}

.result-display {
  padding: 20px;
  background-color: #e9ecef;
  border-radius: 8px;
}

.result-display pre {
  background-color: #fff;
  padding: 15px;
  border-radius: 4px;
  overflow-x: auto;
  margin: 0;
}

.form-select, .form-input, .form-textarea {
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
  width: 100%;
}

.form-select:focus, .form-input:focus, .form-textarea:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.checkbox-group, .radio-group {
  display: flex;
  flex-wrap: wrap;
  gap: 15px;
  margin-top: 10px;
}

.submit-btn, .reset-btn {
  padding: 12px 20px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.submit-btn {
  background-color: #007bff;
  color: white;
  flex: 1;
}

.submit-btn:hover {
  background-color: #0056b3;
}

.reset-btn {
  background-color: #6c757d;
  color: white;
  flex: 1;
}

.reset-btn:hover {
  background-color: #5a6268;
}
</style>

注意事项和最佳实践

1. v-model 与不同输入类型的配合

vue 复制代码
<template>
  <div class="best-practices">
    <h2>v-model 最佳实践</h2>
    
    <!-- 1. 使用正确的输入类型 -->
    <div class="practice-group">
      <h3>✅ 正确使用输入类型</h3>
      <div class="input-row">
        <input 
          v-model="email" 
          type="email" 
          placeholder="邮箱"
          class="form-input"
        >
        <input 
          v-model.number="age" 
          type="number" 
          placeholder="年龄"
          class="form-input"
        >
      </div>
    </div>
    
    <!-- 2. 合理使用修饰符 -->
    <div class="practice-group">
      <h3>✅ 合理使用修饰符</h3>
      <div class="input-row">
        <input 
          v-model.lazy="lazyInput" 
          placeholder="失去焦点更新"
          class="form-input"
        >
        <input 
          v-model.trim="trimmedInput" 
          placeholder="去除空格"
          class="form-input"
        >
        <input 
          v-model.number="numberInput" 
          type="number"
          placeholder="数字类型"
          class="form-input"
        >
      </div>
    </div>
    
    <!-- 3. 表单验证 -->
    <div class="practice-group">
      <h3>✅ 表单验证</h3>
      <div class="input-row">
        <input 
          v-model="validatedInput"
          :class="{ error: validationError }"
          placeholder="必填项"
          class="form-input"
        >
        <button @click="validateInput" class="validate-btn">验证</button>
      </div>
      <p v-if="validationError" class="error-message">{{ validationError }}</p>
    </div>
    
    <!-- 4. 避免直接修改数组索引 -->
    <div class="practice-group">
      <h3>⚠️ 注意数组绑定</h3>
      <div class="array-demo">
        <div v-for="(item, index) in items" :key="index" class="array-item">
          <!-- ❌ 不推荐 -->
          <input v-model="items[index]" placeholder="不推荐" class="form-input small">
          
          <!-- ✅ 推荐 -->
          <input 
            :value="item" 
            @input="updateItem(index, $event.target.value)"
            placeholder="推荐"
            class="form-input small"
          >
        </div>
      </div>
    </div>
  </div>
</template>

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

const email = ref('')
const age = ref('')
const lazyInput = ref('')
const trimmedInput = ref('')
const numberInput = ref('')
const validatedInput = ref('')
const validationError = ref('')

const items = ref(['苹果', '香蕉', '橙子'])

const validateInput = () => {
  if (!validatedInput.value.trim()) {
    validationError.value = '此字段为必填项'
  } else {
    validationError.value = ''
  }
}

const updateItem = (index, value) => {
  items.value[index] = value
}
</script>

<style>
.best-practices {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.practice-group {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #fff;
  border: 1px solid #dee2e6;
  border-radius: 8px;
}

.practice-group h3 {
  margin-top: 0;
  color: #495057;
}

.input-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
}

.form-input {
  flex: 1;
  min-width: 150px;
  padding: 12px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 16px;
}

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

.form-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.form-input.small {
  min-width: 100px;
}

.validate-btn {
  padding: 12px 15px;
  border: none;
  border-radius: 4px;
  background-color: #28a745;
  color: white;
  cursor: pointer;
  white-space: nowrap;
}

.validate-btn:hover {
  background-color: #218838;
}

.error-message {
  color: #dc3545;
  font-size: 14px;
  margin-top: 5px;
}

.array-demo {
  margin-top: 15px;
}

.array-item {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 10px;
}
</style>

总结

v-model 修饰符速查表

修饰符 作用 适用场景
.lazy 失去焦点时更新 减少频繁更新的性能开销
.number 自动转换为数字 数字输入框
.trim 去除首尾空格 文本输入优化

表单元素对应的数据类型

元素类型 绑定值类型 示例
文本框 字符串 ref('')
复选框(单个) 布尔值 ref(false)
复选框(多个) 数组 ref([])
单选框 字符串/数字 ref('')
选择框(单选) 字符串/数字 ref('')
选择框(多选) 数组 ref([])

使用建议

  1. 选择合适的输入类型 :使用 type="email"type="number"
  2. 合理使用修饰符 :根据需求选择 .lazy.number.trim
  3. 做好表单验证 :结合 @blur 事件进行实时验证
  4. 注意性能 :对于大量输入使用 .lazy 修饰符
  5. 保持一致性:统一的表单样式和交互体验

记忆口诀

  • v-model:双向绑定神器
  • .trim:去除空格很贴心
  • .number:数字类型自动转
  • .lazy:失去焦点才更新
  • 表单验证:用户体验要保证
相关推荐
布列瑟农的星空7 分钟前
大话设计模式——关注点分离原则下的事件处理
前端·后端·架构
yvvvy26 分钟前
前端必懂的 Cache 缓存机制详解
前端
北海几经夏41 分钟前
React自定义Hook
前端·react.js
龙在天1 小时前
从代码到屏幕,浏览器渲染网页做了什么❓
前端
TimelessHaze1 小时前
【performance面试考点】让面试官眼前一亮的performance性能优化
前端·性能优化·trae
yes or ok1 小时前
前端工程师面试题-vue
前端·javascript·vue.js
我要成为前端高手1 小时前
给不支持摇树的三方库(phaser) tree-shake?
前端·javascript
Noxi_lumors1 小时前
VITE BALABALA require balabla not supported
前端·vite
周胜22 小时前
node-sass
前端
aloha_2 小时前
Windows 系统中,杀死占用某个端口(如 8080)的进程
前端