本篇定位 :在掌握了数据声明和渲染之后,我们需要让页面"活"起来------响应用户的点击、输入、提交等交互操作。本篇将系统讲解 Vue 中的事件处理机制(
v-on)和表单双向绑定(v-model),它们是构建交互式应用的核心工具。读完本篇,你将能够处理任何复杂的用户交互场景。
一、事件处理:v-on 指令
1.1 基本用法
v-on 指令用于监听 DOM 事件,并在事件触发时执行相应的 JavaScript 代码。它是 Vue 中最常用的指令之一。
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
const message = ref('Hello')
function handleClick() {
count.value++
}
function handleGreet(name: string) {
message.value = `Hello, ${name}!`
}
function handleEventWithParams(event: Event, extra: string) {
console.log('事件对象:', event)
console.log('额外参数:', extra)
}
</script>
<template>
<!-- 完整写法:v-on:事件名="方法名" -->
<button v-on:click="handleClick">点击次数:{{ count }}</button>
<!-- 简写(最常用):@事件名="方法名" -->
<button @click="handleClick">+1</button>
<!-- 调用带参数的方法 -->
<button @click="handleGreet('Vue')">打招呼</button>
<!-- 直接编写内联 JavaScript 表达式 -->
<button @click="count++">内联表达式</button>
<!-- 同时传递事件对象和自定义参数 -->
<button @click="handleEventWithParams($event, 'extra')">
传递事件对象
</button>
</template>
核心特征:
v-on的简写是@(语法糖),绝大多数场景使用简写- 可以绑定任何原生 DOM 事件:
@click、@input、@change、@submit、@keydown、@scroll等 - 事件处理函数可以是方法名 、内联表达式 或方法调用(带参数)
1.2 事件处理方法 vs 内联表达式
方式一:方法名(无参数)
vue
<template>
<!-- 直接使用方法名,Vue 会自动传入事件对象 -->
<button @click="handleClick">点击</button>
</template>
<script setup>
function handleClick(event: Event) {
// event 是原生 DOM 事件对象
console.log(event.target)
}
</script>
方式二:方法调用(带参数)
vue
<template>
<!-- 显式调用方法,可以传递自定义参数 -->
<button @click="handleClickWithParams('参数1', $event)">点击</button>
</template>
<script setup>
function handleClickWithParams(param: string, event: Event) {
console.log(param) // '参数1'
console.log(event) // 原生事件对象
}
</script>
方式三:内联表达式(简单逻辑)
vue
<template>
<!-- 适合简单的单行逻辑 -->
<button @click="count++">增加</button>
<button @click="isActive = !isActive">切换</button>
<button @click="name = 'Vue'; age = 10">批量更新</button>
</template>
1.3 访问原生事件对象
在事件处理中,经常需要访问原生 DOM 事件对象(Event),有三种方式:
vue
<script setup>
// 方式 1:方法名方式,Vue 自动传入 event
function handleClick(event: MouseEvent) {
console.log('点击位置:', event.clientX, event.clientY)
console.log('目标元素:', event.target)
}
// 方式 2:使用 $event 显式传递
function handleClickWithParams(message: string, event: MouseEvent) {
console.log(message, event)
}
// 方式 3:内联箭头函数(较少使用)
const handleInline = (event: MouseEvent) => {
console.log(event)
}
</script>
<template>
<!-- 方式 1:自动传入 -->
<button @click="handleClick">点击</button>
<!-- 方式 2:使用 $event -->
<button @click="handleClickWithParams('Hello', $event)">点击</button>
<!-- 方式 3:内联箭头函数 -->
<button @click="(e) => handleInline(e)">点击</button>
</template>
💡 关键点 :
$event是 Vue 提供的特殊变量,在模板中代表原生 DOM 事件对象。
1.4 事件修饰符
Vue 提供了事件修饰符 ,用于简化常见的事件处理操作。修饰符用点 . 表示,可以链式使用。
① .stop ------ 阻止事件冒泡
vue
<template>
<!-- 阻止事件冒泡到父元素 -->
<div @click="parentClick">
<button @click.stop="childClick">点击我(不会冒泡)</button>
</div>
<!-- 等价于:event.stopPropagation() -->
</template>
② .prevent ------ 阻止默认行为
vue
<template>
<!-- 阻止表单提交的默认行为(页面刷新) -->
<form @submit.prevent="handleSubmit">
<input type="text" />
<button type="submit">提交</button>
</form>
<!-- 阻止链接跳转 -->
<a href="https://example.com" @click.prevent="handleLinkClick">链接</a>
<!-- 等价于:event.preventDefault() -->
</template>
③ .once ------ 事件只触发一次
vue
<template>
<!-- 按钮只能点击一次 -->
<button @click.once="handleOnce">只能点一次</button>
</template>
④ .self ------ 只有事件目标自身触发时才执行
vue
<template>
<!-- 只有点击 div 本身(而非子元素)时才触发 -->
<div @click.self="handleSelf">
<button>点击我不会触发 handleSelf</button>
</div>
</template>
⑤ .capture ------ 使用捕获模式
vue
<template>
<!-- 在捕获阶段触发,而非冒泡阶段 -->
<div @click.capture="handleCapture">
<button @click="handleBubble">点击</button>
</div>
<!-- 执行顺序:handleCapture(捕获)→ handleBubble(冒泡) -->
</template>
⑥ .passive ------ 提升滚动性能
vue
<template>
<!-- 告诉浏览器不会调用 preventDefault(),提升滚动性能 -->
<div @scroll.passive="handleScroll">长内容</div>
</template>
⑦ 修饰符链式组合
vue
<template>
<!-- 组合使用:阻止冒泡 + 阻止默认行为 -->
<a href="#" @click.stop.prevent="handleClick">链接</a>
<!-- 组合使用:阻止冒泡 + 只触发一次 -->
<button @click.stop.once="handleClick">按钮</button>
</template>
1.5 按键修饰符
在监听键盘事件时,Vue 提供了按键修饰符来简化按键判断:
vue
<template>
<!-- 只有按下 Enter 键时才触发 -->
<input @keydown.enter="handleSubmit" />
<!-- 只有按下 Escape 键时才触发 -->
<input @keydown.esc="handleCancel" />
<!-- 只有按下 PageDown 键时才触发 -->
<input @keydown.page-down="handlePageDown" />
<!-- 组合按键:Ctrl + Enter -->
<input @keydown.ctrl.enter="handleSubmit" />
<!-- 组合按键:Alt + S -->
<input @keydown.alt.s="handleSave" />
</template>
Vue 内置的按键别名:
| 修饰符 | 对应按键 |
|---|---|
.enter |
Enter 键 |
.tab |
Tab 键 |
.delete |
Delete 或 Backspace 键 |
.esc |
Escape 键 |
.space |
Space 键 |
.up |
上箭头 |
.down |
下箭头 |
.left |
左箭头 |
.right |
右箭头 |
.page-up / .page-down |
PageUp / PageDown |
.home / .end |
Home / End |
系统修饰键 (ctrl、alt、shift、meta):
vue
<template>
<!-- Ctrl + Click -->
<button @click.ctrl="handleCtrlClick">Ctrl + 点击</button>
<!-- Shift + Enter -->
<input @keydown.shift.enter="handleShiftEnter" />
<!-- Alt + 鼠标点击 -->
<button @click.alt="handleAltClick">Alt + 点击</button>
<!-- 精确按键:只当 Ctrl 按下且没有其他按键时触发 -->
<button @click.ctrl.exact="handleExact">纯 Ctrl + 点击</button>
</template>
1.6 鼠标修饰符
vue
<template>
<!-- 左键点击(默认) -->
<button @click.left="handleLeft">左键</button>
<!-- 右键点击 -->
<button @click.right="handleRight">右键</button>
<!-- 中键点击 -->
<button @click.middle="handleMiddle">中键</button>
</template>
1.7 自定义事件的传递
当在组件上使用 v-on 时,它监听的是自定义事件 (由子组件通过 emit 触发),而非原生 DOM 事件:
vue
<!-- 父组件 -->
<template>
<!-- 监听子组件触发的自定义事件 -->
<MyButton @custom-event="handleCustomEvent" />
</template>
<!-- 子组件 MyButton.vue -->
<script setup>
// 定义事件
const emit = defineEmits(['custom-event'])
function handleClick() {
// 触发自定义事件,可携带数据
emit('custom-event', { message: '来自子组件的消息' })
}
</script>
<template>
<button @click="handleClick">点击触发事件</button>
</template>
二、双向绑定:v-model 指令
v-model 是 Vue 中最强大的指令之一,它在表单输入元素 上创建了双向数据绑定------数据变化驱动视图更新,视图变化自动回写数据。
2.1 v-model 的基本用法
vue
<script setup>
import { ref } from 'vue'
// 绑定各种类型的表单数据
const username = ref('')
const password = ref('')
const age = ref(18)
const isAgreed = ref(false)
const gender = ref('male')
const interest = ref<string[]>([])
const selectedOption = ref('')
const comment = ref('')
</script>
<template>
<!-- 文本输入框 -->
<input v-model="username" placeholder="用户名" />
<!-- 密码输入框 -->
<input v-model="password" type="password" placeholder="密码" />
<!-- 数字输入框 -->
<input v-model.number="age" type="number" min="0" max="150" />
<!-- 复选框(单个) -->
<input v-model="isAgreed" type="checkbox" /> 我已阅读并同意
<!-- 复选框组(多个) -->
<label><input v-model="interest" type="checkbox" value="reading" /> 阅读</label>
<label><input v-model="interest" type="checkbox" value="coding" /> 编程</label>
<label><input v-model="interest" type="checkbox" value="gaming" /> 游戏</label>
<!-- 单选框组 -->
<label><input v-model="gender" type="radio" value="male" /> 男</label>
<label><input v-model="gender" type="radio" value="female" /> 女</label>
<!-- 下拉选择框(单选) -->
<select v-model="selectedOption">
<option value="">请选择</option>
<option value="option1">选项一</option>
<option value="option2">选项二</option>
</select>
<!-- 下拉选择框(多选) -->
<select v-model="interest" multiple>
<option value="reading">阅读</option>
<option value="coding">编程</option>
<option value="gaming">游戏</option>
</select>
<!-- 文本域 -->
<textarea v-model="comment" placeholder="请输入评论..."></textarea>
</template>
2.2 v-model 的修饰符
① .lazy ------ 改为 change 事件触发
默认情况下,v-model 在每次 input 事件后同步数据(实时绑定)。使用 .lazy 修饰符后,改为在 change 事件(失去焦点或回车)后同步:
vue
<template>
<!-- 默认:每次输入都同步 -->
<input v-model="text" />
<!-- .lazy:失去焦点时才同步 -->
<input v-model.lazy="text" />
</template>
② .number ------ 自动转为数字
vue
<template>
<!-- 自动将输入值转为数字类型 -->
<input v-model.number="age" type="number" />
<!-- age 的值是 number 类型,而非 string -->
</template>
③ .trim ------ 自动去除首尾空格
vue
<template>
<!-- 自动去除输入值的首尾空格 -->
<input v-model.trim="username" placeholder="用户名" />
<!-- " 张三 " → "张三" -->
</template>
修饰符链式使用:
vue
<template>
<!-- 组合使用:lazy + trim -->
<input v-model.lazy.trim="username" />
<!-- 组合使用:number + trim(先 trim 再转 number) -->
<input v-model.number.trim="age" type="number" />
</template>
2.3 v-model 的底层原理
v-model 本质上是一个语法糖,它展开为以下两步操作:
① 绑定 value 属性 (通过 :value 或 :checked)
② 监听 input 事件 (通过 @input 或 @change)
vue
<!-- v-model 的语法糖展开 -->
<!-- 文本输入框 -->
<input v-model="username" />
<!-- 等价于 -->
<input :value="username" @input="username = $event.target.value" />
<!-- 复选框 -->
<input v-model="isAgreed" type="checkbox" />
<!-- 等价于 -->
<input :checked="isAgreed" type="checkbox" @change="isAgreed = $event.target.checked" />
<!-- 单选框 -->
<input v-model="gender" type="radio" value="male" />
<!-- 等价于 -->
<input
:checked="gender === 'male'"
type="radio"
value="male"
@change="gender = $event.target.value"
/>
2.4 在自定义组件上使用 v-model
v-model 不仅可以用在原生表单元素上,也可以用在自定义组件上:
vue
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const message = ref('Hello')
</script>
<template>
<!-- 在自定义组件上使用 v-model -->
<CustomInput v-model="message" />
<!-- 展开后的写法 -->
<CustomInput
:modelValue="message"
@update:modelValue="message = $event"
/>
</template>
vue
<!-- 子组件:CustomInput.vue -->
<script setup lang="ts">
// 定义 props,接收父组件的值
defineProps<{
modelValue: string
}>()
// 定义事件,通知父组件更新
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
// 触发更新事件
emit('update:modelValue', target.value)
}
</script>
<template>
<!-- 绑定值 + 监听输入事件 -->
<input
:value="modelValue"
@input="handleInput"
class="custom-input"
/>
</template>
简写方式(推荐):
vue
<script setup>
// 更简洁的写法
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
2.5 多个 v-model 绑定(Vue 3.4+)
Vue 3.4 引入了多个 v-model 绑定 的能力,允许为每个 v-model 指定名称:
vue
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const firstName = ref('张')
const lastName = ref('三')
const email = ref('zhangsan@example.com')
</script>
<template>
<!-- 多个 v-model 绑定 -->
<UserForm
v-model:first-name="firstName"
v-model:last-name="lastName"
v-model:email="email"
/>
</template>
vue
<!-- 子组件:UserForm.vue -->
<script setup lang="ts">
// 为每个 v-model 定义 props 和 emits
defineProps<{
firstName: string
lastName: string
email: string
}>()
const emit = defineEmits<{
(e: 'update:firstName', value: string): void
(e: 'update:lastName', value: string): void
(e: 'update:email', value: string): void
}>()
</script>
<template>
<div>
<input :value="firstName" @input="emit('update:firstName', $event.target.value)" placeholder="姓" />
<input :value="lastName" @input="emit('update:lastName', $event.target.value)" placeholder="名" />
<input :value="email" @input="emit('update:email', $event.target.value)" placeholder="邮箱" />
</div>
</template>
2.6 v-model 的修饰符扩展
在自定义组件上也可以使用 .modifier 修饰符:
vue
<!-- 父组件 -->
<template>
<!-- 使用自定义修饰符 .capitalize -->
<CustomInput v-model.capitalize="text" />
</template>
vue
<!-- 子组件:CustomInput.vue -->
<script setup>
const props = defineProps<{
modelValue: string
modelModifiers?: { capitalize?: boolean }
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function handleInput(event: Event) {
let value = (event.target as HTMLInputElement).value
// 如果有 capitalize 修饰符,将首字母大写
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>
三、综合实战:完整的用户注册表单
让我们综合运用所有事件处理和表单绑定知识,构建一个完整的注册表单:
vue
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
// ---------- 表单数据类型 ----------
interface RegisterForm {
username: string
email: string
password: string
confirmPassword: string
age: number | null
gender: 'male' | 'female' | ''
interests: string[]
agreeTerms: boolean
}
// ---------- 表单状态 ----------
const form = reactive<RegisterForm>({
username: '',
email: '',
password: '',
confirmPassword: '',
age: null,
gender: '',
interests: [],
agreeTerms: false
})
// ---------- UI 状态 ----------
const isSubmitting = ref(false)
const submitSuccess = ref(false)
const submitError = ref('')
const showPassword = ref(false)
const showConfirmPassword = ref(false)
const validationErrors = ref<Record<string, string>>({})
const touched = ref<Record<string, boolean>>({})
// ---------- 表单验证规则 ----------
const validateUsername = (value: string): string => {
if (!value.trim()) return '用户名不能为空'
if (value.length < 3) return '用户名至少 3 个字符'
if (value.length > 20) return '用户名最多 20 个字符'
if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) {
return '用户名只能包含字母、数字、下划线或中文'
}
return ''
}
const validateEmail = (value: string): string => {
if (!value.trim()) return '邮箱不能为空'
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) {
return '请输入正确的邮箱格式'
}
return ''
}
const validatePassword = (value: string): string => {
if (!value) return '密码不能为空'
if (value.length < 6) return '密码至少 6 个字符'
if (value.length > 30) return '密码最多 30 个字符'
return ''
}
const validateConfirmPassword = (value: string): string => {
if (!value) return '请确认密码'
if (value !== form.password) return '两次密码输入不一致'
return ''
}
const validateAge = (value: number | null): string => {
if (value === null || value === 0) return '请输入年龄'
if (value < 1) return '年龄必须大于 0'
if (value > 150) return '年龄不能超过 150'
return ''
}
const validateGender = (value: string): string => {
if (!value) return '请选择性别'
return ''
}
const validateInterests = (value: string[]): string => {
if (value.length === 0) return '请至少选择一项兴趣爱好'
return ''
}
const validateTerms = (value: boolean): string => {
if (!value) return '请阅读并同意用户协议'
return ''
}
// ---------- 验证整个表单 ----------
function validateForm(): boolean {
const errors: Record<string, string> = {}
// 执行所有验证
const usernameError = validateUsername(form.username)
if (usernameError) errors.username = usernameError
const emailError = validateEmail(form.email)
if (emailError) errors.email = emailError
const passwordError = validatePassword(form.password)
if (passwordError) errors.password = passwordError
const confirmError = validateConfirmPassword(form.confirmPassword)
if (confirmError) errors.confirmPassword = confirmError
const ageError = validateAge(form.age)
if (ageError) errors.age = ageError
const genderError = validateGender(form.gender)
if (genderError) errors.gender = genderError
const interestsError = validateInterests(form.interests)
if (interestsError) errors.interests = interestsError
const termsError = validateTerms(form.agreeTerms)
if (termsError) errors.agreeTerms = termsError
validationErrors.value = errors
return Object.keys(errors).length === 0
}
// ---------- 单个字段验证(失焦时) ----------
function validateField(field: keyof RegisterForm) {
touched.value[field] = true
const errors: Record<string, string> = {}
const fieldValue = form[field]
switch (field) {
case 'username':
const usernameError = validateUsername(fieldValue as string)
if (usernameError) errors.username = usernameError
break
case 'email':
const emailError = validateEmail(fieldValue as string)
if (emailError) errors.email = emailError
break
case 'password':
const passwordError = validatePassword(fieldValue as string)
if (passwordError) errors.password = passwordError
break
case 'confirmPassword':
const confirmError = validateConfirmPassword(fieldValue as string)
if (confirmError) errors.confirmPassword = confirmError
break
case 'age':
const ageError = validateAge(fieldValue as number | null)
if (ageError) errors.age = ageError
break
case 'gender':
const genderError = validateGender(fieldValue as string)
if (genderError) errors.gender = genderError
break
case 'interests':
const interestsError = validateInterests(fieldValue as string[])
if (interestsError) errors.interests = interestsError
break
case 'agreeTerms':
const termsError = validateTerms(fieldValue as boolean)
if (termsError) errors.agreeTerms = termsError
break
}
validationErrors.value = { ...validationErrors.value, ...errors }
}
// ---------- 表单提交 ----------
async function handleSubmit() {
// 标记所有字段为已触碰
Object.keys(form).forEach(key => {
touched.value[key as keyof RegisterForm] = true
})
// 验证表单
if (!validateForm()) {
submitError.value = '请修正表单中的错误'
return
}
isSubmitting.value = true
submitError.value = ''
try {
// 模拟 API 请求
await new Promise(resolve => setTimeout(resolve, 1500))
console.log('表单提交数据:', {
...form,
password: '***隐藏***',
confirmPassword: '***隐藏***'
})
submitSuccess.value = true
submitError.value = ''
} catch (error) {
submitError.value = '提交失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
// ---------- 重置表单 ----------
function resetForm() {
form.username = ''
form.email = ''
form.password = ''
form.confirmPassword = ''
form.age = null
form.gender = ''
form.interests = []
form.agreeTerms = false
validationErrors.value = {}
touched.value = {}
submitSuccess.value = false
submitError.value = ''
}
// ---------- 计算属性 ----------
const isFormValid = computed(() => {
// 简化版:检查是否有错误
return Object.keys(validationErrors.value).length === 0
})
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return { level: 0, label: '未设置', color: '#e2e8f0' }
let score = 0
if (pwd.length >= 6) score++
if (pwd.length >= 12) score++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
if (/\d/.test(pwd)) score++
if (/[^a-zA-Z0-9]/.test(pwd)) score++
const levels = [
{ level: 1, label: '弱', color: '#fc8181' },
{ level: 2, label: '弱', color: '#fc8181' },
{ level: 3, label: '中等', color: '#f6ad55' },
{ level: 4, label: '强', color: '#68d391' },
{ level: 5, label: '强', color: '#48bb78' }
]
return levels[Math.min(score, 5) - 1] || levels[0]
})
// ---------- 密码匹配实时反馈 ----------
watch(() => form.password, () => {
if (touched.value.confirmPassword) {
validateField('confirmPassword')
}
})
</script>
<template>
<div class="register-container">
<div class="register-card">
<h1>📝 用户注册</h1>
<p class="subtitle">请填写以下信息完成注册</p>
<!-- 提交成功 -->
<div v-if="submitSuccess" class="alert-success">
✅ 注册成功!欢迎加入我们。
<button @click="resetForm" class="btn-reset">再注册一个</button>
</div>
<!-- 提交错误 -->
<div v-if="submitError" class="alert-error">
❌ {{ submitError }}
</div>
<form @submit.prevent="handleSubmit" class="register-form">
<!-- 用户名 -->
<div class="form-group">
<label class="form-label">
用户名 <span class="required">*</span>
</label>
<input
v-model.trim="form.username"
@blur="validateField('username')"
type="text"
placeholder="请输入用户名(3-20 个字符)"
class="form-input"
:class="{ error: touched.username && validationErrors.username }"
/>
<span v-if="touched.username && validationErrors.username" class="error-message">
{{ validationErrors.username }}
</span>
<span class="hint">支持字母、数字、下划线或中文</span>
</div>
<!-- 邮箱 -->
<div class="form-group">
<label class="form-label">
邮箱 <span class="required">*</span>
</label>
<input
v-model.trim="form.email"
@blur="validateField('email')"
type="email"
placeholder="请输入邮箱地址"
class="form-input"
:class="{ error: touched.email && validationErrors.email }"
/>
<span v-if="touched.email && validationErrors.email" class="error-message">
{{ validationErrors.email }}
</span>
</div>
<!-- 密码 -->
<div class="form-group">
<label class="form-label">
密码 <span class="required">*</span>
</label>
<div class="password-wrapper">
<input
v-model="form.password"
@blur="validateField('password')"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码(至少 6 个字符)"
class="form-input"
:class="{ error: touched.password && validationErrors.password }"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="password-toggle"
>
{{ showPassword ? '🙈' : '👁️' }}
</button>
</div>
<span v-if="touched.password && validationErrors.password" class="error-message">
{{ validationErrors.password }}
</span>
<div v-if="form.password" class="password-strength">
<span>密码强度:</span>
<span
class="strength-bar"
:style="{
width: (passwordStrength.level / 5 * 100) + '%',
background: passwordStrength.color
}"
></span>
<span :style="{ color: passwordStrength.color }">
{{ passwordStrength.label }}
</span>
</div>
</div>
<!-- 确认密码 -->
<div class="form-group">
<label class="form-label">
确认密码 <span class="required">*</span>
</label>
<div class="password-wrapper">
<input
v-model="form.confirmPassword"
@blur="validateField('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
class="form-input"
:class="{ error: touched.confirmPassword && validationErrors.confirmPassword }"
/>
<button
type="button"
@click="showConfirmPassword = !showConfirmPassword"
class="password-toggle"
>
{{ showConfirmPassword ? '🙈' : '👁️' }}
</button>
</div>
<span v-if="touched.confirmPassword && validationErrors.confirmPassword" class="error-message">
{{ validationErrors.confirmPassword }}
</span>
<span v-else-if="form.confirmPassword && form.password === form.confirmPassword" class="hint-success">
✅ 密码匹配
</span>
</div>
<!-- 年龄 -->
<div class="form-group">
<label class="form-label">
年龄 <span class="required">*</span>
</label>
<input
v-model.number="form.age"
@blur="validateField('age')"
type="number"
placeholder="请输入年龄(1-150)"
class="form-input"
:class="{ error: touched.age && validationErrors.age }"
min="1"
max="150"
/>
<span v-if="touched.age && validationErrors.age" class="error-message">
{{ validationErrors.age }}
</span>
</div>
<!-- 性别 -->
<div class="form-group">
<label class="form-label">
性别 <span class="required">*</span>
</label>
<div class="radio-group">
<label class="radio-label">
<input v-model="form.gender" @change="validateField('gender')" type="radio" value="male" />
<span class="radio-text">👨 男</span>
</label>
<label class="radio-label">
<input v-model="form.gender" @change="validateField('gender')" type="radio" value="female" />
<span class="radio-text">👩 女</span>
</label>
<label class="radio-label">
<input v-model="form.gender" @change="validateField('gender')" type="radio" value="other" />
<span class="radio-text">🌈 其他</span>
</label>
</div>
<span v-if="touched.gender && validationErrors.gender" class="error-message">
{{ validationErrors.gender }}
</span>
</div>
<!-- 兴趣爱好 -->
<div class="form-group">
<label class="form-label">
兴趣爱好 <span class="required">*</span>
</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="reading"
/>
📚 阅读
</label>
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="coding"
/>
💻 编程
</label>
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="gaming"
/>
🎮 游戏
</label>
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="sports"
/>
⚽ 运动
</label>
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="music"
/>
🎵 音乐
</label>
<label class="checkbox-label">
<input
v-model="form.interests"
@change="validateField('interests')"
type="checkbox"
value="art"
/>
🎨 艺术
</label>
</div>
<span v-if="touched.interests && validationErrors.interests" class="error-message">
{{ validationErrors.interests }}
</span>
<span class="hint">已选 {{ form.interests.length }} 项</span>
</div>
<!-- 用户协议 -->
<div class="form-group">
<label class="checkbox-label terms-label">
<input
v-model="form.agreeTerms"
@change="validateField('agreeTerms')"
type="checkbox"
/>
<span>
我已阅读并同意 <a href="#" @click.prevent="showTerms = true">《用户协议》</a>
和 <a href="#" @click.prevent="showPrivacy = true">《隐私政策》</a>
<span class="required">*</span>
</span>
</label>
<span v-if="touched.agreeTerms && validationErrors.agreeTerms" class="error-message">
{{ validationErrors.agreeTerms }}
</span>
</div>
<!-- 提交按钮 -->
<div class="form-actions">
<button
type="submit"
class="btn-submit"
:disabled="isSubmitting"
>
<span v-if="isSubmitting">⏳ 提交中...</span>
<span v-else>📤 注册</span>
</button>
<button type="button" @click="resetForm" class="btn-reset">
🔄 重置
</button>
</div>
<!-- 表单概览 -->
<div class="form-overview">
<h4>📊 表单概览</h4>
<div class="overview-grid">
<div class="overview-item">
<span class="label">用户名</span>
<span class="value">{{ form.username || '未填写' }}</span>
</div>
<div class="overview-item">
<span class="label">邮箱</span>
<span class="value">{{ form.email || '未填写' }}</span>
</div>
<div class="overview-item">
<span class="label">年龄</span>
<span class="value">{{ form.age || '未填写' }}</span>
</div>
<div class="overview-item">
<span class="label">性别</span>
<span class="value">{{ form.gender || '未选择' }}</span>
</div>
<div class="overview-item">
<span class="label">兴趣爱好</span>
<span class="value">{{ form.interests.length ? form.interests.join('、') : '未选择' }}</span>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.register-container {
min-height: 100vh;
background: linear-gradient(135deg, #f6f9fc 0%, #e9f2f9 100%);
padding: 40px 20px;
display: flex;
align-items: center;
justify-content: center;
}
.register-card {
max-width: 700px;
width: 100%;
background: white;
border-radius: 20px;
padding: 40px 45px;
box-shadow: 0 20px 60px rgba(0,0,0,0.08);
}
h1 {
font-size: 28px;
color: #1a202c;
margin-bottom: 4px;
}
.subtitle {
color: #718096;
font-size: 14px;
margin-bottom: 30px;
}
.alert-success {
background: #f0fff4;
border: 1px solid #c6f6d5;
color: #276749;
padding: 16px 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.alert-error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
}
.register-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #2d3748;
}
.required {
color: #fc8181;
}
.form-input {
padding: 10px 14px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.2s;
width: 100%;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #4299e1;
}
.form-input.error {
border-color: #fc8181;
}
.form-input.error:focus {
border-color: #fc8181;
box-shadow: 0 0 0 3px rgba(252, 129, 129, 0.2);
}
.password-wrapper {
position: relative;
width: 100%;
}
.password-wrapper .form-input {
padding-right: 44px;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px;
opacity: 0.6;
transition: opacity 0.2s;
}
.password-toggle:hover {
opacity: 1;
}
.error-message {
color: #fc8181;
font-size: 13px;
margin-top: 2px;
}
.hint {
color: #a0aec0;
font-size: 12px;
}
.hint-success {
color: #48bb78;
font-size: 13px;
}
.password-strength {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #4a5568;
margin-top: 4px;
}
.strength-bar {
height: 4px;
border-radius: 2px;
transition: all 0.3s;
background: #e2e8f0;
flex: 1;
max-width: 200px;
}
.radio-group, .checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 4px 0;
}
.radio-label, .checkbox-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: #2d3748;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.radio-label:hover, .checkbox-label:hover {
background: #f7fafc;
}
.radio-label input, .checkbox-label input {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4299e1;
}
.radio-text {
user-select: none;
}
.terms-label {
padding: 8px 0;
}
.terms-label a {
color: #4299e1;
text-decoration: none;
font-weight: 500;
}
.terms-label a:hover {
text-decoration: underline;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn-submit {
flex: 1;
padding: 12px 24px;
background: #4299e1;
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover:not(:disabled) {
background: #3182ce;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-reset {
padding: 12px 24px;
background: #e2e8f0;
color: #4a5568;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-reset:hover {
background: #cbd5e0;
}
.form-overview {
margin-top: 20px;
padding: 16px 20px;
background: #f7fafc;
border-radius: 10px;
}
.form-overview h4 {
font-size: 14px;
color: #4a5568;
margin-bottom: 12px;
}
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
}
.overview-item {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
border-bottom: 1px solid #edf2f7;
}
.overview-item .label {
color: #718096;
}
.overview-item .value {
color: #2d3748;
font-weight: 500;
}
@media (max-width: 600px) {
.register-card {
padding: 24px 16px;
}
.overview-grid {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>
四、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| v-on 基础 | @事件名="处理函数" |
简写 @ 替代 v-on: |
| 事件对象 | 自动传入或使用 $event |
方法名方式自动传入 event |
| 事件修饰符 | .stop、.prevent、.once 等 |
可链式组合使用 |
| 按键修饰符 | .enter、.esc、.ctrl 等 |
系统修饰键支持组合 |
| v-model 基础 | 表单元素的双向绑定 | 语法糖::value + @input |
| v-model 修饰符 | .lazy、.number、.trim |
按需使用,可组合 |
| 自定义组件 v-model | modelValue + update:modelValue |
Vue 3 支持多个 v-model |
| 表单验证 | 失焦验证 + 提交验证 | 结合 touched 状态显示错误 |
五、思考与练习
-
基础练习:在注册表单的基础上,增加以下功能:
- 添加一个"显示全部"按钮,切换显示/隐藏所有密码字段
- 为用户名添加实时可用性检测(输入时显示"用户名可用/已被占用")
- 添加确认提交前的二次确认对话框
-
陷阱排查:以下代码存在什么问题?如何修正?
vue<template> <form @submit="handleSubmit"> <input v-model.number="age" type="text" /> <button @click="submit">提交</button> <div @click="handleDivClick"> <button @click.stop="handleBtnClick">点击</button> </div> </form> </template> -
自定义组件 :创建一个
CustomSelect组件,它接受v-model绑定,支持单选和通过.multiple修饰符启用多选模式。 -
深度思考 :
v-model在自定义组件上的实现原理是什么?为什么 Vue 3 支持多个v-model绑定后,对组件封装带来了哪些便利?
下篇预告
第 7 篇我们将进入组件核心篇,系统学习 Vue 的组件化开发:
- 组件的注册与使用
- Props 的数据传递与类型校验
- 组件通信的基础模式
- 单文件组件的完整结构
准备好开启 Vue 组件化开发的旅程!🚀