Vue3——第 6 篇:事件处理与表单绑定——v-on 与 v-model

本篇定位 :在掌握了数据声明和渲染之后,我们需要让页面"活"起来------响应用户的点击、输入、提交等交互操作。本篇将系统讲解 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 状态显示错误

五、思考与练习

  1. 基础练习:在注册表单的基础上,增加以下功能:

    • 添加一个"显示全部"按钮,切换显示/隐藏所有密码字段
    • 为用户名添加实时可用性检测(输入时显示"用户名可用/已被占用")
    • 添加确认提交前的二次确认对话框
  2. 陷阱排查:以下代码存在什么问题?如何修正?

    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>
  3. 自定义组件 :创建一个 CustomSelect 组件,它接受 v-model 绑定,支持单选和通过 .multiple 修饰符启用多选模式。

  4. 深度思考 :v-model 在自定义组件上的实现原理是什么?为什么 Vue 3 支持多个 v-model 绑定后,对组件封装带来了哪些便利?

下篇预告

第 7 篇我们将进入组件核心篇,系统学习 Vue 的组件化开发:

  • 组件的注册与使用
  • Props 的数据传递与类型校验
  • 组件通信的基础模式
  • 单文件组件的完整结构

准备好开启 Vue 组件化开发的旅程!🚀

相关推荐
恋猫de小郭44 分钟前
Flutter 的文字为什么比 SwiftUI 更细?问题主要在 Skia 的 256 项查找表里
android·前端·flutter
lexiangqicheng1 小时前
前端高阶面试核心通关指南:深入解析Vue3与React18底层原理及性能优化
前端·面试·性能优化
怕浪猫1 小时前
前端对接聊天 API:我用 React 做了一个比 ChatGPT 还好看的界面
前端·后端·面试
程序员小孔.1 小时前
基于 Spring Cloud + Vue 的 JEEWMS 开源仓储管理平台设计与实现
前端·javascript·vue.js·spring boot·spring cloud·开源
IT_陈寒1 小时前
深入理解Python:核心原理与最佳实践
前端·人工智能·后端
创世虚拟世界1 小时前
把 3D 虚拟世界拉到本地:vibe coding 的手感,是从看见自己那一行改动开始的
javascript·数据库·人工智能·3d·gitee·虚拟现实
我喜欢就喜欢1 小时前
Chrome 插件开发实战指南
前端·chrome
夕阳_醉了1 小时前
花了一天的时间,地板式扫盲了vue3所有API盲点
前端·javascript·vue.js
LinDaiuuj1 小时前
Figma Specs 高效交付与开发协作指南
前端·figma