表单处理
双向绑定的计算属性
基本用法
使用计算属性的 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.7Vue 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>