事件处理与表单输入绑定
数据驱动视图,事件连接用户。会处理点击、输入、键盘和提交,才能把静态页面变成真正可用的应用。
在上一篇文章中,我们使用条件渲染和列表渲染搭建了动态页面。本文继续完善交互能力,系统学习 Vue 事件处理与表单输入绑定,并通过一个注册表单把知识串联起来。
写作约定:本系列以 Vue 3
<script setup>为主要写法。示例默认使用 Vue 3.2+,重点关注实际开发中的事件参数、修饰符选择、表单状态和常见陷阱。
一、事件处理:让用户操作驱动状态
Vue 使用v-on监听 DOM 事件,缩写为@。事件发生后,我们通常会修改响应式数据,视图再自动更新。
1.1 基本用法
<template> <button v-on:click="count++">完整写法:{{ count }}</button> <button @click="increment">简写:{{ count }}</button> </template> <script setup> import { ref } from 'vue' const count = ref(0) const increment = () => { count.value++ } </script>count++属于内联事件处理器,适合简单逻辑;increment属于方法事件处理器,更适合复用和测试。
1.2 传递自定义参数
<template> <button @click="add(2)">每次加 2</button> <button @click="add(5)">每次加 5</button> <p>当前计数:{{ count }}</p> </template> <script setup> import { ref } from 'vue' const count = ref(0) const add = (step) => { count.value += step } </script>方法名后面直接跟括号即可传参。需要同时传自定义参数和原生事件对象时,使用$event:
<template> <button @click="record('save', $event)">保存</button> </template> <script setup> const record = (action, event) => { console.log('操作类型:', action) console.log('事件对象:', event) console.log('触发元素:', event.currentTarget) } </script>$event是 Vue 提供的原生 DOM 事件对象。只在需要事件信息时传递,不必在每个方法中机械接收。
1.3 内联访问事件对象
<template> <input @input="keyword = $event.target.value" placeholder="输入关键词"> <p>当前关键词:{{ keyword }}</p> </template> <script setup> import { ref } from 'vue' const keyword = ref('') </script>在简单场景下,这种写法能直接表达意图。但逻辑变复杂后,应抽成方法,避免模板充斥 JavaScript 表达式。
二、事件修饰符:少写重复的事件代码
Vue 提供事件修饰符处理常见需求,如阻止冒泡、阻止默认行为和只触发一次。
2.1 stop 与 prevent
<template> <div @click="handleOuter"> <button @click.stop="handleInner">阻止点击冒泡</button> </div> <form @submit.prevent="handleSubmit"> <button type="submit">阻止表单默认提交</button> </form> </template> <script setup> const handleOuter = () => console.log('外层点击') const handleInner = () => console.log('内层点击') const handleSubmit = () => console.log('处理提交') </script>.stop:调用event.stopPropagation(),阻止事件继续冒泡。.prevent:调用event.preventDefault(),阻止浏览器默认行为。
不要看到表单就一律使用.prevent。如果需要浏览器原生校验、原生跳转或默认下载行为,应保留默认动作。
2.2 capture、self 与 once
<template> <div @click.capture="handleCapture"> <button @click.self="handleSelf">只在元素本身触发</button> <button @click.once="handleOnce">只执行一次</button> </div> </template> <script setup> const handleCapture = () => console.log('捕获阶段处理') const handleSelf = () => console.log('只处理自身点击') const handleOnce = () => console.log('只执行一次') </script>.capture:在捕获阶段监听,而不是默认的冒泡阶段。.self:只有event.target是当前元素本身时才触发,不包括子元素。.once:事件处理器最多执行一次。
2.3 passive:改善滚动性能
<template> <div @scroll.passive="handleScroll" class="scroll-area"> <p v-for="n in 50" :key="n">第 {{ n }} 行内容</p> </div> </template> <script setup> const handleScroll = () => { // 只读取滚动位置,不调用 preventDefault } </script> <style scoped> .scroll-area { height: 180px; overflow-y: auto; } </style>.passive表示监听器不会调用preventDefault,浏览器可以并行处理默认滚动行为。它适合scroll、touchstart、touchmove等事件,但不适合需要阻止默认行为的场景。
2.4 修饰符可以组合
<template> <form @submit.stop.prevent="handleSubmit"> <button type="submit">同时阻止冒泡和默认提交</button> </form> </template>修饰符顺序会影响生成的代码,但常见组合通常不需要特别记忆。更重要的是明确每个修饰符的业务目的,避免无意义叠加。
三、按键与鼠标修饰符
3.1 常用按键修饰符
<template> <input @keyup.enter="submit" placeholder="回车提交"> <input @keyup.esc="cancel" placeholder="Esc 取消"> <input @keyup.delete="remove" placeholder="Delete 删除"> <input @keyup.space="toggle" placeholder="空格切换"> <input @keyup.up="moveUp" placeholder="上方向键"> <input @keyup.down="moveDown" placeholder="下方向键"> <input @keyup.left="moveLeft" placeholder="左方向键"> <input @keyup.right="moveRight" placeholder="右方向键"> </template> <script setup> const submit = () => console.log('提交') const cancel = () => console.log('取消') const remove = () => console.log('删除') const toggle = () => console.log('切换') const moveUp = () => console.log('向上') const moveDown = () => console.log('向下') const moveLeft = () => console.log('向左') const moveRight = () => console.log('向右') </script>tab也可以使用,但它会改变焦点位置,应结合可访问性谨慎处理。
3.2 系统修饰键与精确匹配
<template> <button @click.ctrl="handleCtrl">Ctrl + 点击</button> <button @click.alt="handleAlt">Alt + 点击</button> <button @click.shift="handleShift">Shift + 点击</button> <button @click.meta="handleMeta">Command / Windows + 点击</button> <button @click.ctrl.exact="handleCtrlOnly">只按 Ctrl,不能有其他修饰键</button> <button @click.exact="handlePlainClick">不能按任何系统修饰键</button> </template> <script setup> const handleCtrl = () => console.log('Ctrl 点击') const handleAlt = () => console.log('Alt 点击') const handleShift = () => console.log('Shift 点击') const handleMeta = () => console.log('Meta 点击') const handleCtrlOnly = () => console.log('仅 Ctrl 点击') const handlePlainClick = () => console.log('普通点击') </script>.exact用于精确控制组合键,避免用户同时按下其他修饰键时误触发。
3.3 鼠标按钮修饰符
<template> <button @click.left="handleLeft">左键</button> <button @click.right="handleRight">右键</button> <button @click.middle="handleMiddle">中键</button> </template>需要完全自定义键盘判断时,也可以直接读取event.key:
<template> <input @keydown="handleKeydown" placeholder="输入内容"> </template> <script setup> const handleKeydown = (event) => { if (event.key === 'Enter' && !event.isComposing) { console.log('确认输入') } } </script>event.isComposing可用于避免中文输入法组词过程中按回车选词时误提交。
四、v-model:表单与状态的双向绑定
v-model是表单输入绑定的核心。它本质上同时完成属性绑定和事件监听,让输入内容自动同步到响应式数据。
4.1 文本与多行文本
<template> <input v-model="title" placeholder="文章标题"> <textarea v-model="summary" placeholder="文章摘要"></textarea> <p>标题:{{ title }}</p> <p>摘要:{{ summary }}</p> </template> <script setup> import { ref } from 'vue' const title = ref('') const summary = ref('') </script>在文本输入框中,v-model近似等价于:
<template> <input :value="title" @input="title = $event.target.value"> </template>理解这一点后,就能知道什么时候可以使用v-model,什么时候需要手动拆分:value和@input。
4.2 单个复选框与多个复选框
单个复选框通常绑定布尔值,多个复选框通常绑定数组:
<template> <label> <input type="checkbox" v-model="accept"> 接受用户协议 </label> <p>是否接受:{{ accept }}</p> <fieldset> <legend>选择技能</legend> <label><input type="checkbox" value="Vue" v-model="skills">Vue</label> <label><input type="checkbox" value="React" v-model="skills">React</label> <label><input type="checkbox" value="TypeScript" v-model="skills">TypeScript</label> </fieldset> <p>已选择:{{ skills }}</p> </template> <script setup> import { ref } from 'vue' const accept = ref(false) const skills = ref([]) </script>多个复选框的value应保持稳定且能表达业务含义,初始化时使用数组。
4.3 单选框
<template> <label><input type="radio" value="user" v-model="role">普通用户</label> <label><input type="radio" value="editor" v-model="role">编辑</label> <label><input type="radio" value="admin" v-model="role">管理员</label> <p>当前角色:{{ role }}</p> </template> <script setup> import { ref } from 'vue' const role = ref('user') </script>单选框共享同一个v-model,选中项的value会同步到该变量。
4.4 下拉选择框
<template> <select v-model="city"> <option value="">请选择城市</option> <option value="jinan">济南</option> <option value="qingdao">青岛</option> <option value="yantai">烟台</option> </select> <select v-model="languages" multiple size="3"> <option value="javascript">JavaScript</option> <option value="typescript">TypeScript</option> <option value="java">Java</option> </select> <p>城市:{{ city }}</p> <p>语言:{{ languages }}</p> </template> <script setup> import { ref } from 'vue' const city = ref('') const languages = ref([]) </script>多选select同样绑定数组。没有默认选中项时,单选下拉框可绑定空字符串。
五、v-model 修饰符
5.1 lazy:从 input 改为 change 同步
<template> <input v-model="keyword" placeholder="默认每次输入都同步"> <input v-model.lazy="keyword" placeholder="失焦或回车时同步"> </template> <script setup> import { ref } from 'vue' const keyword = ref('') </script>.lazy适合不希望每次按键都触发复杂计算的输入框。是否需要它取决于交互和性能,而不是简单地认为它“更省性能”。
5.2 number:尽量转换为数字
<template> <input v-model.number="age" type="number" placeholder="年龄"> <input v-model.number="price" type="number" placeholder="价格"> <p>年龄类型:{{ typeof age }}</p> <p>价格类型:{{ typeof price }}</p> </template> <script setup> import { ref } from 'vue' const age = ref(18) const price = ref(99) </script>.number会尝试把输入转换为数字;无法解析为有效数字时保留原始字符串,因此需要数值计算或校验时仍应检查。
5.3 trim:自动去除首尾空格
<template> <input v-model.trim="username" placeholder="用户名"> <p>长度:{{ username.length }}</p> </template> <script setup> import { ref } from 'vue' const username = ref('') </script>.trim只去除首尾空格,不会删除字符串中间的空格。修饰符也可以组合:
<template> <input v-model.lazy.number.trim="amount" type="number" placeholder="金额"> </template>六、表单处理的推荐结构
实际表单通常使用reactive管理一组字段,使用计算属性处理派生状态和校验结果。
<script setup> import { computed, reactive, ref } from 'vue' const form = reactive({ username: '', email: '', role: 'user', skills: [], agree: false }) const submitted = ref(false) const canSubmit = computed(() => { return form.username.trim() && form.email.includes('@') && form.skills.length > 0 && form.agree }) const handleSubmit = () => { if (!canSubmit.value) return submitted.value = true } </script>推荐区分三类数据:
| 类型 | 示例 | 推荐管理方式 |
|---|---|---|
| 表单原始值 | 用户名、邮箱、角色、是否同意 | reactive(form) |
| 派生状态 | 是否可提交、密码强度、过滤结果 | computed |
| 页面状态 | 是否提交中、成功或失败提示、是否展示密码 | ref |
不要在模板中重复编写复杂校验表达式,也不要把“提交中”这类页面状态混进表单原始值。
七、综合实战:用户注册表单
下面实现一个包含文本、邮箱、密码、单选、多选、下拉框和协议确认的注册表单。它包含提交拦截、字段校验、错误提示和重置功能。
<template> <form @submit.prevent="handleSubmit" class="register-form"> <h2>用户注册</h2> <div class="field"> <label for="username">用户名</label> <input id="username" v-model.trim="form.username" @blur="touch('username')" placeholder="至少 3 个字符"> <p v-if="errors.username" class="error">{{ errors.username }}</p> </div> <div class="field"> <label for="email">邮箱</label> <input id="email" v-model.trim="form.email" @blur="touch('email')" type="email" placeholder="name@example.com"> <p v-if="errors.email" class="error">{{ errors.email }}</p> </div> <div class="field"> <label for="password">密码</label> <input id="password" v-model="form.password" @blur="touch('password')" type="password" placeholder="至少 8 个字符"> <p v-if="errors.password" class="error">{{ errors.password }}</p> </div> <div class="field"> <label for="confirmPassword">确认密码</label> <input id="confirmPassword" v-model="form.confirmPassword" @blur="touch('confirmPassword')" type="password" placeholder="再次输入密码"> <p v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</p> </div> <div class="field"> <label for="role">角色</label> <select id="role" v-model="form.role"> <option value="user">普通用户</option> <option value="editor">内容编辑</option> <option value="reviewer">审核人员</option> </select> </div> <fieldset> <legend>关注技术</legend> <label><input type="checkbox" value="vue" v-model="form.skills">Vue</label> <label><input type="checkbox" value="typescript" v-model="form.skills">TypeScript</label> <label><input type="checkbox" value="java" v-model="form.skills">Java</label> <p v-if="errors.skills" class="error">{{ errors.skills }}</p> </fieldset> <div class="field"> <label for="bio">个人简介</label> <textarea id="bio" v-model.trim="form.bio" placeholder="可选,最多 100 字"></textarea> </div> <label> <input type="checkbox" v-model="form.agree"> 我已阅读并同意用户协议 <span v-if="errors.agree" class="error">{{ errors.agree }}</span> </label> <div class="actions"> <button type="button" @click="resetForm">重置</button> <button type="submit" :disabled="submitting">{{ submitting ? '提交中...' : '注册' }}</button> </div> <p v-if="successMessage" class="success">{{ successMessage }}</p> </form> </template> <script setup> import { computed, reactive, ref } from 'vue' const createInitialForm = () => ({ username: '', email: '', password: '', confirmPassword: '', role: 'user', skills: [], bio: '', agree: false }) const form = reactive(createInitialForm()) const touched = reactive({}) const submitting = ref(false) const successMessage = ref('') const touch = (field) => { touched[field] = true } const errors = computed(() => { const result = {} if (touched.username && form.username.length < 3) { result.username = '用户名至少 3 个字符' } if (touched.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { result.email = '请输入有效邮箱地址' } if (touched.password && form.password.length < 8) { result.password = '密码至少 8 个字符' } if (touched.confirmPassword && form.confirmPassword !== form.password) { result.confirmPassword = '两次输入的密码不一致' } if (touched.skills && form.skills.length === 0) { result.skills = '至少选择一项技术' } if (touched.agree && !form.agree) { result.agree = '请先同意用户协议' } return result }) const isValid = computed(() => { return form.username.length >= 3 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) && form.password.length >= 8 && form.confirmPassword === form.password && form.skills.length > 0 && form.agree }) const handleSubmit = async () => { const requiredFields = ['username', 'email', 'password', 'confirmPassword', 'skills', 'agree'] requiredFields.forEach(touch) successMessage.value = '' if (!isValid.value || submitting.value) return submitting.value = true await new Promise(resolve => setTimeout(resolve, 500)) successMessage.value = `注册成功,欢迎 ${form.username}!` submitting.value = false } const resetForm = () => { Object.assign(form, createInitialForm()) Object.keys(touched).forEach(key => delete touched[key]) successMessage.value = '' } </script> <style scoped> .register-form { max-width: 520px; margin: 24px auto; display: grid; gap: 16px; } .field { display: grid; gap: 6px; } input, select, textarea { padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 6px; } fieldset { display: flex; gap: 12px; border: 0; padding: 0; } .actions { display: flex; gap: 12px; } .error { margin: 0; color: #dc2626; font-size: 13px; } .success { color: #059669; } </style>这个示例有几个值得注意的点:
touch()只负责记录字段是否失焦,避免首次打开页面就显示全部错误。errors负责展示,isValid负责最终提交判断,两者职责不同。- 提交前统一标记相关字段,确保未失焦字段也能显示错误。
resetForm()同时重置表单值、失焦状态和提示信息,避免旧状态残留。
八、常见问题自查
| 问题 | 推荐处理 |
|---|---|
| 点击子元素也触发父级事件 | 使用.stop或.self,按实际结构选择 |
| 表单点击后页面刷新 | 使用@submit.prevent并自行处理提交 |
| 回车选词时误提交 | 检查event.isComposing |
| 多选框无法绑定 | 初始值使用数组,选项提供稳定value |
| 数字计算出现字符串拼接 | 使用.number并检查解析结果 |
| 页面打开就显示全部错误 | 失焦后再提示,提交时统一校验 |
| 重置后仍有错误提示 | 同时重置表单值、失焦状态和页面提示 |
| 滚动事件卡顿 | 不需要阻止默认行为时考虑.passive |
九、总结
事件处理与表单绑定是 Vue 交互开发的基础。本文最重要的几点是:
@事件名是v-on的简写,自定义参数与$event可以一起使用。- 事件修饰符应服务于明确业务目的,常见选择包括
.stop、.prevent、.once和.passive。 - 处理回车时注意中文输入法组词状态,必要时检查
event.isComposing。 v-model同时完成属性绑定和事件监听,复选框与多选下拉框通常绑定数组。.lazy、.number、.trim分别控制同步时机、数字转换和首尾空格。- 复杂表单应拆分原始值、派生状态和页面状态,校验逻辑不要堆在模板中。
下一站预告
下一篇《Class与Style绑定》将学习如何根据状态动态管理类名、样式和组件外观,让表单和列表不仅可用,也拥有清晰的状态反馈。
作者:洋洋技术笔记
发布日期:2026-11-03
系列:Vue.js从入门到精通 - 第6篇
原文链接:洋洋的技术笔记