{T}

表单处理

双向绑定的计算属性

基本用法

使用计算属性的 getter 和 setter 实现双向绑定:

Vue SFC
<template>
  <form>
    <div>
      <label>用户名:</label>
      <input v-model="userName">
    </div>
    <div>
      <label>邮箱:</label>
      <input v-model="userEmail">
    </div>
  </form>
</template>

<script>
import { mapState, mapMutations } from 'vuex'

export default {
  computed: {
    userName: {
      get() {
        return this.$store.state.user.name
      },
      set(value) {
        this.$store.commit('setUserName', value)
      }
    },
    
    userEmail: {
      get() {
        return this.$store.state.user.email
      },
      set(value) {
        this.$store.commit('setUserEmail', value)
      }
    }
  }
}
</script>

使用 mapState 和 mapMutations

Vue SFC
<template>
  <form>
    <input v-model="name">
    <input v-model="email">
  </form>
</template>

<script>
import { mapState, mapMutations } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['user']),
    
    name: {
      get() {
        return this.user.name
      },
      set(value) {
        this.setUserName(value)
      }
    },
    
    email: {
      get() {
        return this.user.email
      },
      set(value) {
        this.setUserEmail(value)
      }
    }
  },
  
  methods: {
    ...mapMutations('user', ['setUserName', 'setUserEmail'])
  }
}
</script>

使用 v-model

方式一:计算属性包装

Vue SFC
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>姓名:</label>
      <input v-model="form.name" type="text">
    </div>
    
    <div class="form-group">
      <label>年龄:</label>
      <input v-model.number="form.age" type="number">
    </div>
    
    <div class="form-group">
      <label>性别:</label>
      <select v-model="form.gender">
        <option value="">请选择</option>
        <option value="male">男</option>
        <option value="female">女</option>
      </select>
    </div>
    
    <div class="form-group">
      <label>爱好:</label>
      <label v-for="hobby in hobbies" :key="hobby.value">
        <input v-model="form.hobbies" type="checkbox" :value="hobby.value">
        {{ hobby.label }}
      </label>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script>
import { mapState, mapMutations } from 'vuex'

export default {
  data() {
    return {
      hobbies: [
        { value: 'reading', label: '阅读' },
        { value: 'sports', label: '运动' },
        { value: 'music', label: '音乐' }
      ]
    }
  },
  
  computed: {
    ...mapState('user', ['userInfo']),
    
    form() {
      return {
        name: {
          get: () => this.userInfo.name,
          set: (value) => this.updateUserField({ field: 'name', value })
        },
        age: {
          get: () => this.userInfo.age,
          set: (value) => this.updateUserField({ field: 'age', value })
        },
        gender: {
          get: () => this.userInfo.gender,
          set: (value) => this.updateUserField({ field: 'gender', value })
        },
        hobbies: {
          get: () => this.userInfo.hobbies || [],
          set: (value) => this.updateUserField({ field: 'hobbies', value })
        }
      }
    }
  },
  
  methods: {
    ...mapMutations('user', ['updateUserField']),
    
    handleSubmit() {
      this.$store.dispatch('user/saveUserInfo')
    }
  }
}
</script>

方式二:本地状态 + 提交

对于复杂表单,可以先将数据保存在本地,提交时再更新到 store:

Vue SFC
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>用户名:</label>
      <input v-model="localForm.username" type="text">
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
    
    <div class="form-group">
      <label>邮箱:</label>
      <input v-model="localForm.email" type="email">
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <label>密码:</label>
      <input v-model="localForm.password" type="password">
      <span v-if="errors.password" class="error">{{ errors.password }}</span>
    </div>
    
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

<script>
import { mapState } from 'vuex'

export default {
  data() {
    return {
      localForm: {
        username: '',
        email: '',
        password: ''
      },
      errors: {}
    }
  },
  
  computed: {
    ...mapState('user', ['userInfo', 'isSubmitting'])
  },
  
  created() {
    // 从 store 初始化本地表单数据
    this.localForm = {
      username: this.userInfo.username || '',
      email: this.userInfo.email || '',
      password: ''
    }
  },
  
  methods: {
    validate() {
      this.errors = {}
      
      if (!this.localForm.username) {
        this.errors.username = '请输入用户名'
      }
      
      if (!this.localForm.email) {
        this.errors.email = '请输入邮箱'
      } else if (!this.isValidEmail(this.localForm.email)) {
        this.errors.email = '请输入有效的邮箱地址'
      }
      
      if (!this.localForm.password) {
        this.errors.password = '请输入密码'
      } else if (this.localForm.password.length < 6) {
        this.errors.password = '密码至少6位'
      }
      
      return Object.keys(this.errors).length === 0
    },
    
    isValidEmail(email) {
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
    },
    
    async handleSubmit() {
      if (!this.validate()) {
        return
      }
      
      try {
        await this.$store.dispatch('user/updateProfile', this.localForm)
        this.$emit('success')
      } catch (error) {
        this.errors.submit = error.message
      }
    }
  }
}
</script>

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

.error {
  color: red;
  font-size: 12px;
}
</style>

方式三:使用 v-model 修饰符

Vue SFC
<template>
  <form>
    <!-- .trim 自动去除首尾空格 -->
    <input v-model.trim="name" placeholder="姓名">
    
    <!-- .number 自动转换为数字 -->
    <input v-model.number="age" type="number" placeholder="年龄">
    
    <!-- .lazy 在 change 事件后同步 -->
    <textarea v-model.lazy="description" placeholder="描述"></textarea>
  </form>
</template>

<script>
export default {
  computed: {
    name: {
      get() { return this.$store.state.user.name },
      set(value) { this.$store.commit('user/setName', value) }
    },
    age: {
      get() { return this.$store.state.user.age },
      set(value) { this.$store.commit('user/setAge', value) }
    },
    description: {
      get() { return this.$store.state.user.description },
      set(value) { this.$store.commit('user/setDescription', value) }
    }
  }
}
</script>

表单验证

基础验证

Vue SFC
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group" :class="{ 'has-error': errors.username }">
      <label>用户名:</label>
      <input 
        v-model="form.username" 
        @blur="validateField('username')"
        @input="clearError('username')"
      >
      <span v-if="errors.username" class="error-message">
        {{ errors.username }}
      </span>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        username: ''
      },
      errors: {},
      touched: {}
    }
  },
  
  methods: {
    validateField(field) {
      this.touched[field] = true
      
      switch (field) {
        case 'username':
          if (!this.form.username) {
            this.errors.username = '用户名不能为空'
          } else if (this.form.username.length < 3) {
            this.errors.username = '用户名至少3个字符'
          } else {
            delete this.errors.username
          }
          break
      }
    },
    
    clearError(field) {
      if (this.touched[field]) {
        delete this.errors[field]
      }
    },
    
    validateAll() {
      this.validateField('username')
      return Object.keys(this.errors).length === 0
    },
    
    handleSubmit() {
      if (this.validateAll()) {
        this.$store.dispatch('user/updateUsername', this.form.username)
      }
    }
  }
}
</script>

使用 Vuelidate 验证库

bash
npm install vuelidate@0.7
Vue SFC
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group" :class="{ 'has-error': $v.form.username.$error }">
      <label>用户名:</label>
      <input 
        v-model="$v.form.username.$model" 
        @blur="$v.form.username.$touch()"
      >
      <span v-if="!$v.form.username.required" class="error">
        用户名不能为空
      </span>
      <span v-if="!$v.form.username.minLength" class="error">
        用户名至少3个字符
      </span>
    </div>
    
    <div class="form-group" :class="{ 'has-error': $v.form.email.$error }">
      <label>邮箱:</label>
      <input 
        v-model="$v.form.email.$model" 
        @blur="$v.form.email.$touch()"
      >
      <span v-if="!$v.form.email.required" class="error">
        邮箱不能为空
      </span>
      <span v-if="!$v.form.email.email" class="error">
        请输入有效的邮箱
      </span>
    </div>
    
    <button type="submit" :disabled="$v.$invalid">
      提交
    </button>
  </form>
</template>

<script>
import { required, minLength, email } from 'vuelidate/lib/validators'
import { mapState } from 'vuex'

export default {
  data() {
    return {
      form: {
        username: '',
        email: ''
      }
    }
  },
  
  validations: {
    form: {
      username: {
        required,
        minLength: minLength(3)
      },
      email: {
        required,
        email
      }
    }
  },
  
  computed: {
    ...mapState('user', ['userInfo'])
  },
  
  created() {
    this.form = {
      username: this.userInfo.username || '',
      email: this.userInfo.email || ''
    }
  },
  
  methods: {
    handleSubmit() {
      this.$v.$touch()
      
      if (this.$v.$invalid) {
        return
      }
      
      this.$store.dispatch('user/updateProfile', this.form)
    }
  }
}
</script>

异步验证

Vue SFC
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>用户名:</label>
      <input 
        v-model="form.username" 
        @blur="validateUsername"
        :class="{ 'is-valid': usernameValid, 'is-invalid': usernameInvalid }"
      >
      <span v-if="checkingUsername" class="checking">检查中...</span>
      <span v-if="usernameValid" class="valid">用户名可用</span>
      <span v-if="usernameInvalid" class="invalid">用户名已存在</span>
    </div>
  </form>
</template>

<script>
import { debounce } from 'lodash'

export default {
  data() {
    return {
      form: {
        username: ''
      },
      checkingUsername: false,
      usernameValid: false,
      usernameInvalid: false
    }
  },
  
  created() {
    this.validateUsername = debounce(this.validateUsername, 500)
  },
  
  methods: {
    async validateUsername() {
      if (!this.form.username) {
        this.usernameValid = false
        this.usernameInvalid = false
        return
      }
      
      this.checkingUsername = true
      
      try {
        const isAvailable = await this.$store.dispatch(
          'user/checkUsername', 
          this.form.username
        )
        
        this.usernameValid = isAvailable
        this.usernameInvalid = !isAvailable
      } catch (error) {
        console.error('验证失败:', error)
      } finally {
        this.checkingUsername = false
      }
    },
    
    handleSubmit() {
      if (this.usernameInvalid) {
        return
      }
      
      this.$store.dispatch('user/updateUsername', this.form.username)
    }
  }
}
</script>

完整表单示例

用户信息表单

Vue SFC
<template>
  <div class="user-form">
    <h2>用户信息</h2>
    
    <form @submit.prevent="handleSubmit">
      <!-- 基本信息 -->
      <fieldset>
        <legend>基本信息</legend>
        
        <div class="form-group" :class="{ 'has-error': $v.form.name.$error }">
          <label>姓名 *</label>
          <input 
            v-model.trim="$v.form.name.$model"
            type="text"
            placeholder="请输入姓名"
          >
          <span v-if="!$v.form.name.required" class="error">
            请输入姓名
          </span>
        </div>
        
        <div class="form-group">
          <label>性别</label>
          <div class="radio-group">
            <label>
              <input v-model="form.gender" type="radio" value="male">
              男
            </label>
            <label>
              <input v-model="form.gender" type="radio" value="female">
              女
            </label>
          </div>
        </div>
        
        <div class="form-group">
          <label>生日</label>
          <input v-model="form.birthday" type="date">
        </div>
      </fieldset>
      
      <!-- 联系方式 -->
      <fieldset>
        <legend>联系方式</legend>
        
        <div class="form-group" :class="{ 'has-error': $v.form.email.$error }">
          <label>邮箱 *</label>
          <input 
            v-model.trim="$v.form.email.$model"
            type="email"
            placeholder="请输入邮箱"
          >
          <span v-if="!$v.form.email.required" class="error">
            请输入邮箱
          </span>
          <span v-if="!$v.form.email.email" class="error">
            请输入有效的邮箱地址
          </span>
        </div>
        
        <div class="form-group">
          <label>手机号</label>
          <input 
            v-model.trim="form.phone"
            type="tel"
            placeholder="请输入手机号"
          >
        </div>
      </fieldset>
      
      <!-- 地址信息 -->
      <fieldset>
        <legend>地址信息</legend>
        
        <div class="form-group">
          <label>省份</label>
          <select v-model="form.province" @change="onProvinceChange">
            <option value="">请选择省份</option>
            <option v-for="p in provinces" :key="p.code" :value="p.code">
              {{ p.name }}
            </option>
          </select>
        </div>
        
        <div class="form-group">
          <label>城市</label>
          <select v-model="form.city" :disabled="!form.province">
            <option value="">请选择城市</option>
            <option v-for="c in cities" :key="c.code" :value="c.code">
              {{ c.name }}
            </option>
          </select>
        </div>
        
        <div class="form-group">
          <label>详细地址</label>
          <textarea v-model.trim="form.address" rows="3"></textarea>
        </div>
      </fieldset>
      
      <div class="form-actions">
        <button type="button" @click="resetForm">重置</button>
        <button type="submit" :disabled="isSubmitting">
          {{ isSubmitting ? '保存中...' : '保存' }}
        </button>
      </div>
    </form>
  </div>
</template>

<script>
import { required, email } from 'vuelidate/lib/validators'
import { mapState, mapActions } from 'vuex'

export default {
  name: 'UserForm',
  
  data() {
    return {
      form: {
        name: '',
        gender: 'male',
        birthday: '',
        email: '',
        phone: '',
        province: '',
        city: '',
        address: ''
      },
      provinces: [],
      cities: []
    }
  },
  
  validations: {
    form: {
      name: { required },
      email: { required, email }
    }
  },
  
  computed: {
    ...mapState('user', ['userInfo', 'isSubmitting'])
  },
  
  async created() {
    await this.loadProvinces()
    this.initForm()
  },
  
  methods: {
    ...mapActions('user', ['updateUserInfo']),
    
    initForm() {
      if (this.userInfo) {
        this.form = {
          name: this.userInfo.name || '',
          gender: this.userInfo.gender || 'male',
          birthday: this.userInfo.birthday || '',
          email: this.userInfo.email || '',
          phone: this.userInfo.phone || '',
          province: this.userInfo.province || '',
          city: this.userInfo.city || '',
          address: this.userInfo.address || ''
        }
        
        if (this.form.province) {
          this.loadCities(this.form.province)
        }
      }
    },
    
    async loadProvinces() {
      const response = await this.$store.dispatch('common/fetchProvinces')
      this.provinces = response.data
    },
    
    async loadCities(provinceCode) {
      const response = await this.$store.dispatch('common/fetchCities', provinceCode)
      this.cities = response.data
    },
    
    onProvinceChange() {
      this.form.city = ''
      this.cities = []
      
      if (this.form.province) {
        this.loadCities(this.form.province)
      }
    },
    
    resetForm() {
      this.initForm()
      this.$v.$reset()
    },
    
    async handleSubmit() {
      this.$v.$touch()
      
      if (this.$v.$invalid) {
        return
      }
      
      try {
        await this.updateUserInfo(this.form)
        this.$emit('success')
      } catch (error) {
        console.error('保存失败:', error)
      }
    }
  }
}
</script>

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

fieldset {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 16px;
  margin-bottom: 20px;
}

legend {
  padding: 0 10px;
  font-weight: bold;
}

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

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

.form-group input,
.form-group select,
.form-group textarea {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.form-group.has-error input {
  border-color: red;
}

.error {
  color: red;
  font-size: 12px;
  margin-top: 4px;
  display: block;
}

.form-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

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

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

.form-actions button[type="submit"]:disabled {
  background-color: #ccc;
}
</style>

最佳实践

1. 选择合适的绑定方式

场景推荐方式
简单表单,少量字段计算属性 getter/setter
复杂表单,大量字段本地状态 + 提交时同步
需要实时验证计算属性 + 验证库
需要撤销功能本地状态 + 重置功能

2. 表单状态管理

javascript
// store/modules/form.js
export default {
  namespaced: true,
  
  state: {
    data: {},
    dirty: false,
    submitting: false,
    errors: {}
  },
  
  mutations: {
    SET_FIELD(state, { field, value }) {
      state.data[field] = value
      state.dirty = true
    },
    
    SET_SUBMITTING(state, submitting) {
      state.submitting = submitting
    },
    
    SET_ERRORS(state, errors) {
      state.errors = errors
    },
    
    CLEAR_ERRORS(state) {
      state.errors = {}
    },
    
    RESET_FORM(state) {
      state.dirty = false
      state.errors = {}
    }
  }
}

3. 表单重置

Vue SFC
<script>
export default {
  methods: {
    resetForm() {
      // 重置为初始值
      this.form = { ...this.initialForm }
      
      // 清除验证状态
      this.$v.$reset()
      
      // 清除错误信息
      this.errors = {}
    }
  }
}
</script>

4. 表单离开确认

Vue SFC
<script>
export default {
  computed: {
    ...mapState('form', ['dirty'])
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.dirty) {
      const answer = window.confirm('表单未保存,确定要离开吗?')
      if (answer) {
        this.$store.commit('form/RESET_FORM')
        next()
      } else {
        next(false)
      }
    } else {
      next()
    }
  }
}
</script>

5. 多步骤表单

Vue SFC
<template>
  <div class="multi-step-form">
    <div class="steps">
      <div 
        v-for="(step, index) in steps" 
        :key="index"
        :class="['step', { active: currentStep === index, completed: index < currentStep }]"
      >
        {{ step.title }}
      </div>
    </div>
    
    <div class="step-content">
      <component 
        :is="currentStepComponent" 
        ref="stepForm"
        @next="nextStep"
        @prev="prevStep"
      />
    </div>
    
    <div class="step-actions">
      <button v-if="currentStep > 0" @click="prevStep">上一步</button>
      <button v-if="currentStep < steps.length - 1" @click="nextStep">下一步</button>
      <button v-else @click="submitAll">提交</button>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import Step1 from './Step1.vue'
import Step2 from './Step2.vue'
import Step3 from './Step3.vue'

export default {
  components: { Step1, Step2, Step3 },
  
  data() {
    return {
      currentStep: 0,
      steps: [
        { title: '基本信息', component: 'Step1' },
        { title: '联系方式', component: 'Step2' },
        { title: '确认提交', component: 'Step3' }
      ]
    }
  },
  
  computed: {
    ...mapState('form', ['formData']),
    currentStepComponent() {
      return this.steps[this.currentStep].component
    }
  },
  
  methods: {
    nextStep() {
      if (this.$refs.stepForm.validate()) {
        this.currentStep++
      }
    },
    
    prevStep() {
      this.currentStep--
    },
    
    async submitAll() {
      if (this.$refs.stepForm.validate()) {
        await this.$store.dispatch('form/submitAll')
        this.$emit('complete')
      }
    }
  }
}
</script>