事件处理与表单输入绑定
数据驱动视图,事件连接用户。会处理点击、输入、键盘和提交,才能把静态页面变成真正可用的应用。
在上一篇文章中,我们使用条件渲染和列表渲染搭建了动态页面。本文继续完善交互能力,系统学习 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>推荐区分三类数据:
不要在模板中重复编写复杂校验表达式,也不要把“提交中”这类页面状态混进表单原始值。
七、综合实战:用户注册表单
下面实现一个包含文本、邮箱、密码、单选、多选、下拉框和协议确认的注册表单。它包含提交拦截、字段校验、错误提示和重置功能。
<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()同时重置表单值、失焦状态和提示信息,避免旧状态残留。
八、常见问题自查
九、总结
事件处理与表单绑定是 Vue 交互开发的基础。本文最重要的几点是:
@事件名是v-on的简写,自定义参数与$event可以一起使用。事件修饰符应服务于明确业务目的,常见选择包括
.stop、.prevent、.once和.passive。处理回车时注意中文输入法组词状态,必要时检查
event.isComposing。v-model同时完成属性绑定和事件监听,复选框与多选下拉框通常绑定数组。.lazy、.number、.trim分别控制同步时机、数字转换和首尾空格。复杂表单应拆分原始值、派生状态和页面状态,校验逻辑不要堆在模板中。
下一站预告
下一篇《Class与Style绑定》将学习如何根据状态动态管理类名、样式和组件外观,让表单和列表不仅可用,也拥有清晰的状态反馈。
作者:洋洋技术笔记 发布日期:2026-11-03 系列:Vue.js从入门到精通 - 第6篇
原文链接:洋洋的技术笔记
评论区