孟凡的博客blog.mengfanyaoapp.com
课程资料库›Vue 3 + Element Plus 前端开发›第6次 · 事件处理与表单双向绑定:v-on 与 v-model
模块总览

第6次 · 事件处理与表单双向绑定:v-on 与 v-model

2026-09-12 · Vue 3 + Element Plus 前端开发 · 课程笔记
课堂练习资料 06-事件处理与表单双向绑定v-on与v-model.zip
下载 ZIP 压缩包

第6次 · 事件处理与表单双向绑定:v-on 与 v-model

第1/7页 · 先知道今天要做什么

到上一课为止,页面只会“看”,不会“动”——所有数据都是你在代码里写好的。今天补上另一半:用户点一下、输一行、勾一个框,数据就跟着变。这件事靠两个东西完成:v-on(也叫 @)负责“听事件”,v-model 负责“双向绑定”。

v-model 是 Vue 里性价比最高的一个语法糖:一行代码顶掉“读值 + 监听输入 + 写回数据”三件事。今天要把它在文本框、下拉框、单选、多选、开关上的用法全部过一遍,这是后面所有表单类页面的地基。

今天的场景

一个“课程报名表”。用户填姓名、手机号、班级、报名方向(可多选)、期望课时、是否接收通知。你在旁边实时把收集到的数据以 JSON 形式打印出来。这样每次输入,你都能立刻看到“数据到底变成了什么”,而不是靠猜。

下课时拿出这三样结果

做到什么 在哪里看 完成的证据
会用 v-on / @ 绑定事件 按钮与表单 点按钮能执行函数,函数里能拿到事件对象,也能收到自定义参数。
会用 v-model 处理各种表单控件 报名表 输入框、下拉、单选、多选、开关都能改到数据,且右侧 JSON 实时同步。
会用三个常用修饰符 输入与提交 .trim 去掉首尾空格、.number 把输入转成数字、.lazy 让数据在失焦时才更新。

你只需要带着这些基础来

会 ref、computed、v-if、v-for;第 5 课的工程还能跑。

本课时 45 分钟怎么上

时间 老师带着做 学生留下什么
0—5 分钟 提问:不用 v-model,怎么把一个输入框的值存下来? 明白“取事件 → 读 value → 写回变量”三步很啰嗦。
5—18 分钟 讲 v-on:绑定方式、传参、事件对象、常用修饰符。 笔记里写下 .stop 和 .prevent 各挡什么。
18—34 分钟 讲 v-model:在各类控件上的写法,多选框为什么绑数组,三个修饰符。 能说出 v-model 展开成哪两件事。
34—42 分钟 粘贴本课 App.vue,逐个控件操作,看右侧 JSON 变化。 所有控件都能改到数据。
42—45 分钟 抽查:问“多选框为什么必须绑数组”。 全员提交。

从哪里开始

复制第 5 课的工程,改名 lesson-vue-06。不需要新增依赖。

今天先掌握到这里

今天不拆分组件,所有逻辑都在一个 App.vue 里。props / emits 是第 7 课的内容。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第2/7页 · 把新词说清楚

新词与本课含义

名字 用熟悉的话理解 本课里长什么样
v-on 给元素挂一个“监听器”,事件发生时执行一段代码。 v-on:click="fn",简写 @click="fn"
事件对象 浏览器传给你的“这次事件的详情”:点在哪、按了哪个键。 @click="fn($event)" 里的 $event
修饰符 挂在指令后面的小后缀,用来免写常见的 preventDefault、stopPropagation。 @click.stop、@submit.prevent
v-model 双向绑定:数据 → 界面,界面 → 数据,自动来回。 <el-input v-model="form.name" />
语法糖 看起来是一个写法,实际上会被翻译成几行代码。 v-model 会被展开成“绑定值 + 监听更新”
受控 / 非受控 界面上的值由数据说了算(受控),还是由浏览器自己管(非受控)。 v-model 就是让控件“受数据控制”

v-on 的四种常见写法

<!-- 1. 直接写一句表达式 -->
<button @click="count++">加一</button>

<!-- 2. 调一个函数(不传参) -->
<button @click="reset">重置</button>

<!-- 3. 调函数并传参 -->
<button @click="say('你好')">打招呼</button>

<!-- 4. 既要自定义参数,又要事件对象:用 $event 显式传进去 -->
<button @click="say('你好', $event)">打招呼并看事件</button>

一个必须记住的细节:写 @click="say('你好')" 时括号里的内容在事件发生时才会执行。而如果你写 @click="say"(不带括号),Vue 会把函数本身交给它,事件发生时自动调用并自动把事件对象作为第一个参数传进去。

常用事件修饰符

修饰符 作用 典型场景
.stop 阻止事件继续向上冒泡 卡片整体可点击,里面的按钮不想触发卡片
.prevent 阻止浏览器默认行为 表单提交时不让页面刷新
.once 只触发一次 “立即领取”按钮
.self 只有点在元素自身上才触发 点击遮罩关闭弹窗,但点弹窗内部不关
.passive 提升滚动流畅度,告诉浏览器“我不会阻止默认行为” 滚动容器

v-model 到底做了什么

<input v-model="name" />

等价于:

<input :value="name" @input="name = $event.target.value" />

: 那半句负责“数据 → 界面”,@ 那半句负责“界面 → 数据”。这就是“双向”的来源。理解了这一点,后面组件上的 v-model 就不神秘了。

不同控件,v-model 绑的类型不一样

控件 绑定的数据类型 说明
文本框 / 多行文本 字符串 默认。
复选框(单个) 布尔值 勾选是 true。
复选框(一组) 数组 勾中的那些值组成的数组。
单选框 字符串 / 数字 选中项的值。
下拉框(单选) 字符串 / 数字 选中项的值。
下拉框(多选) 数组 需要加 multiple。
开关 布尔值 true / false。
数字输入框 数字 Element Plus 的 el-input-number 直接给数字。

这是最容易出错的地方:多选一定要绑数组,否则 Vue 不知道该往里放什么。

三个常用修饰符

修饰符 作用 例子
.trim 自动去掉首尾空格 <el-input v-model.trim="form.name" />
.number 把输入转成数字类型 <el-input v-model.number="form.age" />
.lazy 输入时不更新,失去焦点或回车时才更新 适合“搜索”这类不想每次按键都请求的输入

用 Element Plus 的组件时,这三个修饰符都可以照常写。

只看这几行:绑定与取值的对比

<script setup>
import { ref } from 'vue'
const name = ref('')
const way = 'v-model'
</script>

<template>
  <!-- 啰嗦版:手动取、手动写 -->
  <input :value="name" @input="name = $event.target.value" />

  <!-- 同一件事,v-model 一行搞定 -->
  <input v-model="name" />

  <p>当前值:{{ name }}</p>
</template>

先口头预测

如果 v-model 绑的是一个数组,而控件是一个普通文本框,会发生什么?

页面能显示(数组会被转成字符串),但一输入就出错或行为异常,因为文本框只能写回字符串,Vue 不会把它变成数组。绑定类型必须和控件匹配,这是本课要养成的第一直觉。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第3/7页 · 先看一个小例子

完整代码:App.vue(课程报名表)

<script setup>
import { ref, reactive, computed } from 'vue'
import {
  ElCard, ElForm, ElFormItem, ElInput, ElInputNumber, ElSelect, ElOption,
  ElRadioGroup, ElRadio, ElCheckboxGroup, ElCheckbox, ElSwitch, ElSlider,
  ElButton, ElTag, ElDivider, ElMessage
} from 'element-plus'
import 'element-plus/dist/index.css'

const form = reactive({
  name: '',
  phone: '',
  cls: '',
  gender: '保密',
  directions: [],          // 多选 → 数组
  hours: 30,               // 数字
  notify: true,
  remark: ''
})

const directionOptions = ['Vue 前端', 'Java 后端', 'Python 数据分析', '数据库运维', '测试开发']

// 实时预览:把当前数据以 JSON 形式显示出来
const preview = computed(() => JSON.stringify(form, null, 2))

// 用 v-model.trim / v-model.number / v-model.lazy 的示范项
const searchKey = ref('')
const age = ref(null)
const lazyText = ref('')

const canSubmit = computed(() =>
  form.name.trim().length >= 2 && /^1\d{10}$/.test(form.phone)
)

function submit() {
  if (!canSubmit.value) {
    ElMessage.warning('请填写姓名(≥2 字)和 11 位手机号')
    return
  }
  ElMessage.success('报名已提交:' + form.name)
  console.log('提交的数据:', JSON.parse(JSON.stringify(form)))
}

function reset() {
  form.name = ''
  form.phone = ''
  form.cls = ''
  form.gender = '保密'
  form.directions = []
  form.hours = 30
  form.notify = true
  form.remark = ''
  ElMessage.info('已重置')
}
</script>

<template>
  <div class="page">
    <h1 class="title">课程报名表</h1>

    <div class="layout">
      <!-- 左侧:表单 -->
      <el-card class="panel">
        <template #header>填写信息(每改一处,右侧 JSON 立刻变化)</template>

        <el-form label-width="96px" @submit.prevent>
          <el-form-item label="姓名">
            <el-input v-model.trim="form.name" placeholder="首尾空格会被自动去掉" clearable />
          </el-form-item>

          <el-form-item label="手机号">
            <el-input v-model.trim="form.phone" placeholder="11 位手机号" maxlength="11" clearable />
          </el-form-item>

          <el-form-item label="班级">
            <el-select v-model="form.cls" placeholder="请选择" style="width: 100%">
              <el-option label="计算机1班" value="计算机1班" />
              <el-option label="计算机2班" value="计算机2班" />
              <el-option label="计算机3班" value="计算机3班" />
            </el-select>
          </el-form-item>

          <el-form-item label="性别">
            <el-radio-group v-model="form.gender">
              <el-radio value="男">男</el-radio>
              <el-radio value="女">女</el-radio>
              <el-radio value="保密">保密</el-radio>
            </el-radio-group>
          </el-form-item>

          <el-form-item label="报名方向">
            <!-- 多选:必须绑数组 -->
            <el-checkbox-group v-model="form.directions">
              <el-checkbox v-for="d in directionOptions" :key="d" :value="d">{{ d }}</el-checkbox>
            </el-checkbox-group>
          </el-form-item>

          <el-form-item label="期望课时">
            <el-input-number v-model="form.hours" :min="1" :max="120" />
            <el-slider v-model="form.hours" :min="1" :max="120" style="width: 200px; margin-left: 16px" />
          </el-form-item>

          <el-form-item label="接收通知">
            <el-switch v-model="form.notify" />
          </el-form-item>

          <el-form-item label="备注">
            <el-input v-model="form.remark" type="textarea" :rows="3" show-word-limit maxlength="100" />
          </el-form-item>

          <el-form-item>
            <el-button type="primary" :disabled="!canSubmit" @click="submit">提交报名</el-button>
            <el-button @click="reset">重置</el-button>
          </el-form-item>
        </el-form>
      </el-card>

      <!-- 右侧:实时数据预览 + 修饰符演示 -->
      <div class="side">
        <el-card class="panel">
          <template #header>实时数据(form)</template>
          <pre class="json">{{ preview }}</pre>
          <el-tag v-if="canSubmit" type="success" size="small">校验通过,可提交</el-tag>
          <el-tag v-else type="danger" size="small">姓名或手机号还没填对</el-tag>
        </el-card>

        <el-card class="panel">
          <template #header>三个修饰符的对比实验</template>
          <p class="hint">1)<b>.trim</b>:下面输入框首尾打空格,看数值是否被去掉</p>
          <el-input v-model.trim="searchKey" placeholder="  前后加空格试试  " />
          <p class="now">当前值:<code>"{{ searchKey }}"</code></p>

          <el-divider />

          <p class="hint">2)<b>.number</b>:用普通输入框输入数字,看类型是不是数字</p>
          <el-input v-model.number="age" placeholder="输入 18" />
          <p class="now">值:{{ age }} 类型:<code>{{ typeof age }}</code></p>

          <el-divider />

          <p class="hint">3)<b>.lazy</b>:输入时不更新,失焦(点别处)或回车才更新</p>
          <el-input v-model.lazy="lazyText" placeholder="边打字边看下面变化" />
          <p class="now">已同步的值:<code>"{{ lazyText }}"</code></p>
        </el-card>
      </div>
    </div>
  </div>
</template>

<style>
html, body, #app {
  margin: 0; padding: 0; max-width: none; width: auto; height: auto;
  display: block; place-items: initial; text-align: left; background: #f5f7fa;
}
</style>

<style scoped>
.page { max-width: 1120px; margin: 28px auto; padding: 0 16px; }
.title { font-size: 22px; color: #303133; margin: 0 0 18px; }
.layout { display: grid; grid-template-columns: 1fr 380px; gap: 16px; align-items: start; }
.panel { margin-bottom: 16px; }
.json {
  background: #1f2430; color: #a9dc76; padding: 12px; border-radius: 6px;
  font-size: 12px; line-height: 1.6; margin: 0 0 10px; overflow: auto; max-height: 320px;
}
.hint { font-size: 13px; color: #606266; margin: 0 0 8px; }
.now { font-size: 13px; color: #303133; margin: 8px 0 0; }
code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; color: #c7254e; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>

预期看见什么

  1. 左边一列表单项,右边两张卡片:一张实时显示 form 的 JSON,一张做三个修饰符的对比实验。
  2. 在姓名框里敲字,右侧 JSON 里 name 的值同步变化。
  3. 勾选“报名方向”里的两项,JSON 里 directions 变成 ["Vue 前端", "Java 后端"] 这样一个数组。
  4. 拖动“期望课时”的滑块,数字框和 JSON 一起变。
  5. 姓名填两个字以上、手机号填 11 位,右下角标签从红灯变绿灯,提交按钮从灰变蓝。
  6. 在 .lazy 那个框里打字,下面的“已同步的值”不动;点一下页面别处,值才一次跳过去。

先猜一猜,读完代码再回答

<el-button type="primary" :disabled="!canSubmit" @click="submit">提交报名</el-button>
<el-form @submit.prevent>

这里为什么写了 @submit.prevent,虽然按钮没写 type="submit"?

因为原生 <form> 在按回车时会尝试提交并刷新整页。Element Plus 的 el-form 内部就是一个真实 <form>,如果用户在输入框里按回车,页面会刷新,所有状态丢失。加上 .prevent 就挡住了这个默认行为——这正是 .prevent 修饰符的标准用途。这也是为什么 .prevent 和 .stop 要背下来。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第4/7页 · 一步一步做出来

第1步:先感受没有 v-model 的麻烦

<script setup>
import { ref } from 'vue'
const name = ref('')
</script>

<template>
  <input :value="name" @input="e => name = e.target.value" />
  <p>你输入了:{{ name }}</p>
</template>

能跑,但要写两件事。预期:功能正常,但代码啰嗦。

第2步:换成 v-model

<input v-model="name" />

预期:行为完全一样,代码少一半。这就是语法糖的价值。

第3步:看清 v-model 展开成了什么

在 <script setup> 里加一句打印:

const name = ref('')
console.log('初始值:', name.value)

然后在输入框里打字,观察页面下面那行文字实时跟着变。这一步的目的是建立“数据变了界面就变、界面变了数据就变”的直觉。

第4步:玩一次事件对象

<button @click="show($event)">点我,看事件对象</button>

<script setup>
function show(e) {
  console.log('点在了哪里:', e.target.tagName, e.clientX, e.clientY)
}
</script>

打开 Console,点按钮。预期:能看到事件对象里的坐标、目标元素等信息。

第5步:体验修饰符的差别

先看不加修饰符的情况:

<div class="outer" @click="ElMessage.info('外层被点了')">
  <button @click="ElMessage.info('按钮被点了')">点我</button>
</div>

点按钮。预期:两条提示都会弹(按钮的事件冒到了外层)。

改成 <button @click.stop="...">。预期:只弹一条。

第6步:整段替换为本课完整版

把第 3 页的完整 App.vue 覆盖 src/App.vue。逐个控件操作一遍,每操作一次就对照右侧 JSON,确认:

  • 文本框 → 字符串
  • 多选框组 → 数组
  • 开关 → 布尔值
  • 数字框 → 数字

本课文件速查

位置 本次作用
form(reactive) 整个表单的数据源,字段与控件一一对应。
form.directions 唯一一个数组,绑定到 el-checkbox-group。
canSubmit computed 出的“能否提交”,控制按钮禁用状态。
@submit.prevent 挡住表单的默认刷新行为。
.trim / .number / .lazy 三个修饰符的对比实验区。

完成修改后检查

操作或输入 预期观察
在姓名框输入 张三 JSON 里存的是 张三(空格被去掉)。
勾选 3 个报名方向 directions 是长度为 3 的数组。
关闭“接收通知” JSON 里 notify 变成 false。
姓名只填 1 个字 提交按钮是禁用的。
在 .lazy 框里打字后按 Tab 值才更新。
在任意输入框按回车 页面不刷新。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第5/7页 · 换成自己的代码

你的任务

  1. 换成自己的表单:把报名表改成你真实会用到的一张表(例如“图书馆借书登记”或“社团活动报名”),保留至少 6 个字段。
  2. 多选换成数组:至少保留一处必须绑数组的控件(多选框组或 multiple 下拉),并在 JSON 里确认它确实是数组。
  3. 加一个“实时字数”:备注框下方显示“已输入 X / 100”。
  4. 加一个联动:当“接收通知”关闭时,把“手机号”输入框设为不可编辑(el-input 上加 :disabled="!form.notify"),并在旁边显示原因说明。
  5. 换一个修饰符实验:把 .lazy 那一组换成 @keyup.enter 的使用——在搜索框里按回车才执行“搜索”,并打印搜索关键字。

按这张操作单完成

步骤 改哪里 运行后应该看到什么
1 换表单 form 对象与模板 6 个自己的字段,JSON 实时同步。
2 数组绑定 多选控件 JSON 里显示成数组。
3 实时字数 备注下方加一行 打字时计数增加,到 100 封顶。
4 联动禁用 手机号 :disabled 关掉开关后手机号框变灰且不可输入。
5 回车搜索 @keyup.enter 只有按回车才触发搜索打印。

最低完成标准:先把这三项做实

检查项 通过条件
双向绑定 页面上至少 5 个控件能真正改到数据(在 JSON / 预览里能看到变化)。
类型正确 多选是数组、开关是布尔、数字框是数字,能在界面上验证。
事件与修饰符 至少用到一个修饰符(.trim / .number / .lazy / .stop / .prevent / @keyup.enter)。

在本地写练习记录

  1. 写出 v-model 展开后的等价写法(:value + @input)。
  2. 记录三个修饰符各自的实验现象。
  3. 写下你的表单里哪个字段绑的是数组、为什么。

最后交什么

提交 src/App.vue 与 练习记录.md。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第6/7页 · 出错时从哪里查

把现象和检查动作对上

看到什么现象 / 报错 先检查哪里 修好后怎样确认
输入框能打字,但数据没变 v-model 拼错了(例如写成 v-modle);或绑的是一个不可写的值(如 computed 的只读值)。 拼写正确后 JSON 跟着变。
多选框勾选时报错或值变成字符串 绑的不是数组。多选框组必须绑数组。 改成数组后正常。
数字框得到的却是字符串 用了 el-input 而不是 el-input-number,或忘了 .number。 用 typeof 打印确认类型。
表单里的回车刷新了页面 没写 @submit.prevent。 加上后不再刷新。
点按钮时外层的点击也触发了 需要 .stop。 加上后只触发一次。
.lazy 好像“没反应” .lazy 是失焦才更新,这是预期行为。 点页面别处或按回车后再看。
修改表单后页面不更新 用 reactive 的对象整体替换了(例如 form = {...}),丢掉了响应性。 改成逐字段赋值,或用 Object.assign。

排错口诀

改了没反应,先看 v-model 拼写; 多选绑数组,开关绑布尔,数字用 number; 冒泡串了加 .stop,刷新页面加 .prevent; .lazy 要失焦,不是坏了是没走。

不要用这些办法“修复”

  1. 不要因为数据不更新就在 @input 里再加一份手动赋值——两套逻辑同时改一个数据,早晚冲突。
  2. 不要为了绕过类型问题把数字字段直接当字符串用,后面算总和时会得到 "1" + "2" = "12"。
  3. 不要给 el-input-number 再套 .number,它本来就是数字,多余的修饰符只会让人困惑。

需要老师帮忙时交这些

出问题那个控件的完整标签代码、<script setup> 里对应的数据定义、你敲了什么、JSON 里实际变成了什么、你期望变成什么。

老师巡视时只问三句话

  1. v-model 展开成哪两件事?(能答出 :value + @input)
  2. 你这个多选框绑的是什么类型?怎么验证的?(能指出在 JSON 里看到是数组)
  3. 这里为什么加了修饰符?不加会怎样?(能说出具体后果)

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档


第7/7页 · 用自己的话讲一遍

回到第 3 页:你刚才猜对了吗?

为什么没写 type="submit" 也要加 @submit.prevent?

参考解释:el-form 内部渲染的是真实 <form> 元素。在表单的输入框里按回车,浏览器默认会“提交表单”,默认动作就是刷新页面。加 .prevent 是拦住浏览器的默认动作,跟按钮是不是 submit 无关。

回答本课的两个问题

问题 参考解释
v-model 是语法糖,它到底做了哪两件事? 一是把数据的值绑定给控件(:value),二是监听控件的输入事件并把新值写回数据(@input)。
为什么多选框必须绑数组? 因为“多选”的结果天生可能不只一个值,只有数组能同时装下若干个选项。

五个必须能答上来的问题

考考自己 可以这样通俗回答
@click="fn" 和 @click="fn()" 的区别? 前者自动把事件对象作为第一个参数传进去;后者按你写的参数来传,想要事件对象要写 $event。
.stop 和 .prevent 分别挡什么? .stop 挡事件继续往外冒;.prevent 挡浏览器的默认动作。
.lazy 有什么用? 让 v-model 不在每次输入时更新,改在失焦或回车时更新,适合搜索框这类不想频繁触发的场景。
.trim 和 .number 分别怎么用? 前者去首尾空格,后者把输入转成数字类型,都直接写在 v-model 后面。
表单数据为什么常用 reactive 而不是一堆 ref? 字段多的时候,一个对象更整洁,模板里写 form.name 也比 name.value 直观。

一分钟现场演示指南

  1. 展示双向:在姓名框打字,指着右侧 JSON 说“数据跟着界面走”。
  2. 展示多选:勾两个方向,指出 JSON 里变成了数组,“这是多选的关键”。
  3. 展示修饰符:在 .lazy 框打字不动、失焦才动;在 .trim 框输入前后空格,指出值被清理。
  4. 收尾一句:“事件负责听,v-model 负责来回同步,两个一起才让页面真正能操作。”

交作业前最后检查

  • src/App.vue 与 练习记录.md 已保存。
  • 至少 5 个控件能改到数据,且类型正确。
  • 输入框里按回车页面不刷新。
  • Console 无红色报错。

课后再做一个小对照

把本课报名表里最复杂的一个字段(例如 directions 多选),改成用 @change 手动维护数组的写法:<el-checkbox-group @change="val => form.directions = val">。两种写法都能用,写下一句话总结:什么情况下你会选择不用 v-model(提示:当“界面值”和“数据值”需要不一致时,例如只格式化不改原值)。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(事件处理 / 表单输入绑定章节)· 组件依据 Element Plus 2.8 官方文档