侧边栏壁纸
博主头像
洋洋的技术笔记

行动起来,活在当下

  • 累计撰写 18 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
VUE

自定义事件与v-model

洋洋的技术笔记
2026-11-11 / 0 评论 / 0 点赞 / 3 阅读 / 0 字

自定义事件与v-model

自定义事件让子组件表达“发生了什么”,v-model 则把“当前值是什么、如何更新”整理成统一契约。二者结合,才能在保持数据边界清晰的同时,让父子组件自然协作。

上一篇文章中,我们通过 Props 把父组件的数据传给子组件,并坚持不直接修改 Props。本文继续补齐另一个方向:子组件如何向父组件发送事件、传递参数和校验事件,以及如何用 v-model 封装输入框、弹窗和可复用业务组件。

写作约定:示例以 Vue 3 <script setup> 为主。defineModel 需要 Vue 3.4+;使用 Vue 3.2 或 3.3 的项目可采用本文的手动 Props + 事件写法。为了保持单向数据流,子组件仍不直接修改 Props。


一、从 Props 到自定义事件

父子组件通信最常见的结构是:

父组件持有状态
      │ Props 向下传值
      ▼
子组件接收并展示
      │ 自定义事件向上通知
      ▼
父组件决定如何更新状态

子组件负责描述用户意图,例如“点击了删除”“弹窗已关闭”“希望保存这份草稿”。父组件决定数据如何变化,而不是让子组件越权修改父组件对象。

一个最小事件示例:

<!-- CounterButton.vue -->
<template>
  <button type="button" @click="emit('increment')">
    当前计数:{{ count }}
  </button>
</template>

<script setup>
defineProps({
  count: {
    type: Number,
    required: true
  }
})

const emit = defineEmits(['increment'])
</script>
<!-- App.vue -->
<template>
  <CounterButton :count="count" @increment="count++" />
</template>

<script setup>
import { ref } from 'vue'
import CounterButton from './components/CounterButton.vue'

const count = ref(0)
</script>

点击子组件按钮后,increment 事件通知父组件执行 count++。新的 count 再通过 Props 流向子组件。

defineEmits 是编译器宏,不需要从 vue 导入。它返回的 emit 用于在 <script setup> 中触发事件。


二、声明和触发自定义事件

2.1 数组声明

简单组件可以使用数组声明事件名:

const emit = defineEmits(['save', 'cancel', 'delete'])

数组写法明确列出组件的公开事件,避免事件名随意扩散。模板中可以直接调用:

<button type="button" @click="emit('save')">保存</button>
<button type="button" @click="emit('cancel')">取消</button>

更复杂的处理建议写成函数:

<template>
  <button type="button" @click="saveDraft">保存草稿</button>
</template>

<script setup>
const emit = defineEmits(['save'])

function saveDraft() {
  emit('save')
}
</script>

2.2 对象声明与事件校验

对象写法可以为事件添加校验函数:

const emit = defineEmits({
  save: payload => {
    return payload && typeof payload.title === 'string'
  },
  cancel: () => true
})

校验函数收到事件参数,返回 true 表示合法,返回 false 时 Vue 在开发环境给出警告。事件仍会触发,因此校验用于发现问题,不是业务权限控制。

如果保存时要求标题非空:

const emit = defineEmits({
  save: payload => {
    return typeof payload?.title === 'string'
      && payload.title.trim().length > 0
  }
})

function save() {
  emit('save', {
    title: '掌握自定义事件',
    completed: false
  })
}

事件校验应保持简单、同步、无副作用,不要在校验函数里请求接口或修改状态。


三、事件参数与父组件接收方式

3.1 不传参数

emit('close')

父组件直接执行处理函数:

<EditDialog @close="dialogVisible = false" />

3.2 传一个参数

emit('delete', taskId)

父组件处理函数会直接收到该值:

<TaskCard @delete="deleteTask" />
function deleteTask(taskId) {
  tasks.value = tasks.value.filter(task => task.id !== taskId)
}

如果在模板内联处理,可以使用 $event 表示事件参数:

<TaskCard @delete="deleteTask($event)" />

也可以直接写表达式:

<CounterButton @increment="count += $event" />

3.3 传多个参数

emit('move', taskId, targetIndex)

父组件按顺序接收:

function moveTask(taskId, targetIndex) {
  // 根据任务 ID 和目标位置调整列表
}

内联监听时,$event 只表示第一个参数。多个参数通常应交给方法处理,避免模板变得复杂。

3.4 使用对象组织复杂载荷

当参数超过两个,或未来可能扩展时,对象通常更清晰:

emit('move', {
  taskId,
  fromIndex,
  targetIndex
})

父组件可以解构:

function moveTask({ taskId, targetIndex }) {
  // 更新任务顺序
}

对象载荷能明确字段含义,也便于增加 reason、source 等信息,而不改变所有位置参数的顺序。


四、事件命名与监听方式

推荐事件名使用动词或动词短语,例如:

save、cancel、close、delete、submit
update:modelValue、update:title

update:modelValue 和 update:title 是 v-model 的约定事件,后文会重点说明。

普通业务事件可以使用 camelCase:

emit('submitForm')

模板中建议写成 kebab-case:

<UserForm @submit-form="handleSubmit" />

Vue 会处理这种命名对应关系。v-model 相关事件则保留冒号:

<BaseInput @update:model-value="value = $event" />

在单文件组件中,也可以使用 @update:modelValue。团队应统一命名风格,避免同一组件同时出现多套写法。

自定义事件不会自动冒泡到更上层组件。它只会通知直接监听该组件的父组件,因此多层嵌套时需要明确转发事件,或重新设计状态共享方式。


五、完整示例:任务卡片的完成与删除

子组件只负责展示和发出意图:

<!-- TaskCard.vue -->
<template>
  <article class="task-card">
    <h2>{{ task.title }}</h2>
    <p>{{ task.completed ? '已完成' : '待完成' }}</p>

    <div class="actions">
      <button
        v-if="!task.completed"
        type="button"
        @click="emit('complete', task.id)"
      >
        完成任务
      </button>
      <button type="button" @click="requestDelete">
        删除
      </button>
    </div>
  </article>
</template>

<script setup>
const props = defineProps({
  task: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['complete', 'delete'])

function requestDelete() {
  if (window.confirm(`确定删除“${props.task.title}”吗?`)) {
    emit('delete', props.task.id)
  }
}
</script>

父组件拥有任务列表并处理业务变化:

<!-- App.vue -->
<template>
  <main>
    <p>共 {{ tasks.length }} 个任务</p>

    <TaskCard
      v-for="task in tasks"
      :key="task.id"
      :task="task"
      @complete="completeTask"
      @delete="deleteTask"
    />
  </main>
</template>

<script setup>
import { ref } from 'vue'
import TaskCard from './components/TaskCard.vue'

const tasks = ref([
  { id: 1, title: '学习 Props', completed: true },
  { id: 2, title: '掌握自定义事件', completed: false },
  { id: 3, title: '封装 v-model 组件', completed: false }
])

function completeTask(taskId) {
  const task = tasks.value.find(item => item.id === taskId)
  if (task) {
    task.completed = true
  }
}

function deleteTask(taskId) {
  tasks.value = tasks.value.filter(task => task.id !== taskId)
}
</script>

这里没有 task.completed = true 出现在子组件中。子组件只发送 ID,父组件决定如何更新列表。


六、为什么组件需要 v-model

普通 HTML 输入元素可以写:

<input v-model="keyword">

它等价于:

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

如果封装一个 BaseInput 组件,希望父组件也这样使用:

<BaseInput v-model="keyword" />

就需要让组件实现相同契约:

  • 接收当前值;

  • 用户输入时通知父组件新值;

  • 父组件更新后,组件显示最新值。

v-model 不是让子组件随意修改 Props,而是把 Props 与更新事件约定成固定组合。


七、手动实现 v-model

7.1 默认名称:modelValue 与 update:modelValue

Vue 3 组件上的 v-model 默认使用 modelValue Prop 和 update:modelValue 事件。

<!-- BaseInput.vue -->
<template>
  <label class="base-input">
    <span>{{ label }}</span>
    <input
      :value="modelValue"
      :placeholder="placeholder"
      @input="onInput"
    >
  </label>
</template>

<script setup>
defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  label: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

function onInput(event) {
  emit('update:modelValue', event.target.value)
}
</script>
<!-- App.vue -->
<template>
  <BaseInput
    v-model="keyword"
    label="搜索任务"
    placeholder="请输入关键词"
  />
  <p>当前关键词:{{ keyword }}</p>
</template>

<script setup>
import { ref } from 'vue'
import BaseInput from './components/BaseInput.vue'

const keyword = ref('')
</script>

v-model="keyword" 在组件上相当于:

<BaseInput
  :model-value="keyword"
  @update:model-value="keyword = $event"
/>

子组件通过 :value="modelValue" 显示父组件状态,通过事件报告新值,父组件仍是状态所有者。

7.2 使用计算属性转发读写

在 <script setup> 中,也可以用计算属性统一读写逻辑:

<template>
  <input v-model="value">
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value)
})
</script>

这里没有直接修改 props.modelValue。计算属性的 setter 仍然通过事件通知父组件,因此数据流向保持清晰。


八、使用 defineModel 简化写法

Vue 3.4+ 提供 defineModel,进一步封装上述 Props 与事件:

<!-- BaseInput.vue -->
<template>
  <label>
    <span>{{ label }}</span>
    <input v-model="model" :placeholder="placeholder">
  </label>
</template>

<script setup>
const model = defineModel({
  type: String,
  default: ''
})

defineProps({
  label: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: ''
  }
})
</script>

defineModel 返回一个响应式引用。读写它时,Vue 会同步处理默认的 modelValue 和 update:modelValue。

父组件写法不变:

<BaseInput v-model="keyword" label="搜索任务" />

也可以指定名称:

const title = defineModel('title', {
  type: String,
  default: ''
})

此时父组件使用:

<EditDialog v-model:title="taskTitle" />

defineModel 是语法糖,不会改变组件通信原则。需要兼容 Vue 3.2 或 3.3 时,继续使用手动 Props + 事件即可。


九、多个 v-model 与命名绑定

一个组件需要同时编辑多个独立值时,可以使用多个命名 v-model。

<!-- UserNameEditor.vue -->
<template>
  <label>
    姓
    <input :value="firstName" @input="updateFirstName">
  </label>

  <label>
    名
    <input :value="lastName" @input="updateLastName">
  </label>
</template>

<script setup>
defineProps({
  firstName: {
    type: String,
    default: ''
  },
  lastName: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:firstName', 'update:lastName'])

function updateFirstName(event) {
  emit('update:firstName', event.target.value)
}

function updateLastName(event) {
  emit('update:lastName', event.target.value)
}
</script>

父组件分别绑定:

<UserNameEditor
  v-model:first-name="form.firstName"
  v-model:last-name="form.lastName"
/>
import { reactive } from 'vue'

const form = reactive({
  firstName: '',
  lastName: ''
})

使用 defineModel 时可以写成:

<script setup>
const firstName = defineModel('firstName', {
  type: String,
  default: ''
})

const lastName = defineModel('lastName', {
  type: String,
  default: ''
})
</script>

命名绑定适合少量、职责清晰的字段。若一个表单包含十几个字段,通常传一个表单对象并发送一次 submit,比创建十几个 v-model 更容易维护。


十、复选框、单选框与选择器

不同原生控件的 v-model 绑定属性并不相同。

10.1 复选框

单个复选框通常使用 checked:

<!-- BaseCheckbox.vue -->
<template>
  <label>
    <input
      type="checkbox"
      :checked="modelValue"
      @change="onChange"
    >
    {{ label }}
  </label>
</template>

<script setup>
defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  label: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

function onChange(event) {
  emit('update:modelValue', event.target.checked)
}
</script>
<BaseCheckbox v-model="accepted" label="同意使用条款" />

注意这里是 event.target.checked,不是 value。

10.2 单选框组

单选框通常让整个组共享一个当前值:

<!-- StatusRadioGroup.vue -->
<template>
  <fieldset>
    <legend>任务状态</legend>
    <label v-for="option in options" :key="option.value">
      <input
        type="radio"
        name="task-status"
        :value="option.value"
        :checked="modelValue === option.value"
        @change="emit('update:modelValue', option.value)"
      >
      {{ option.label }}
    </label>
  </fieldset>
</template>

<script setup>
defineProps({
  modelValue: {
    type: String,
    required: true
  },
  options: {
    type: Array,
    required: true
  }
})

const emit = defineEmits(['update:modelValue'])
</script>
<StatusRadioGroup
  v-model="status"
  :options="[
    { value: 'pending', label: '待完成' },
    { value: 'done', label: '已完成' }
  ]"
/>

10.3 下拉选择器

<!-- BaseSelect.vue -->
<template>
  <select :value="modelValue" @change="onChange">
    <option
      v-for="option in options"
      :key="option.value"
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

<script setup>
defineProps({
  modelValue: [String, Number],
  options: {
    type: Array,
    default: () => []
  }
})

const emit = defineEmits(['update:modelValue'])

function onChange(event) {
  emit('update:modelValue', event.target.value)
}
</script>

如果父组件需要数字,应在子组件明确转换,或让选项值与父组件类型保持一致,避免意外变成字符串。


十一、v-model 修饰符

11.1 常用内置修饰符

普通输入元素常用:

<input v-model.trim="keyword">
<input v-model.number="age">
<input v-model.lazy="description">
  • .trim:去除输入首尾空白;

  • .number:尝试把值转换为数字;

  • .lazy:默认输入场景下改为 change 时同步,而不是每次 input 都同步。

11.2 自定义组件修饰符

组件 v-model 也可以接收修饰符对象:

<BaseInput v-model.capitalize="nickname" />

子组件通过 modelModifiers 读取:

<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['update:modelValue'])

function onInput(event) {
  let value = event.target.value
  if (props.modelModifiers.capitalize && value) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

defineModel 可以通过第二个参数读取并处理修饰符:

const [model, modifiers] = defineModel({
  type: String,
  get(value) {
    return value
  },
  set(value) {
    return value
  }
})

命名 v-model 的修饰符 Prop 使用“名称 + Modifiers”,例如 titleModifiers。

修饰符适合表达通用的值转换规则。不要把复杂业务校验全部塞进修饰符,表单错误提示仍应通过明确的校验逻辑完成。


十二、弹窗中的 v-model

弹窗经常需要控制显示状态:

<!-- ConfirmDialog.vue -->
<template>
  <div v-if="modelValue" class="dialog-mask" @click.self="close">
    <section class="dialog" role="dialog" aria-modal="true">
      <h2>{{ title }}</h2>
      <p>{{ message }}</p>
      <button type="button" @click="close">取消</button>
      <button type="button" @click="confirm">确认</button>
    </section>
  </div>
</template>

<script setup>
defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: '操作确认'
  },
  message: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue', 'confirm'])

function close() {
  emit('update:modelValue', false)
}

function confirm() {
  emit('confirm')
  close()
}
</script>
<template>
  <button type="button" @click="dialogVisible = true">
    删除任务
  </button>

  <ConfirmDialog
    v-model="dialogVisible"
    title="删除任务"
    message="删除后无法恢复,是否继续?"
    @confirm="deleteTask"
  />
</template>

<script setup>
import { ref } from 'vue'

const dialogVisible = ref(false)

function deleteTask() {
  // 执行删除
}
</script>

弹窗可见性由父组件控制,子组件通过 update:modelValue(false) 请求关闭。确认动作则是独立的业务事件,不应被 v-model 替代。


十三、表单弹窗:本地草稿与提交事件

并非所有数据都适合每次按键都同步给父组件。编辑表单通常先修改本地草稿,只有用户点击保存时才提交。

<!-- TaskEditDialog.vue -->
<template>
  <div v-if="visible" class="dialog-mask">
    <section class="dialog">
      <h2>编辑任务</h2>

      <label>
        任务标题
        <input v-model.trim="draft.title">
      </label>

      <label>
        任务分类
        <select v-model="draft.category">
          <option value="组件基础">组件基础</option>
          <option value="组件通信">组件通信</option>
          <option value="项目实战">项目实战</option>
        </select>
      </label>

      <p v-if="errorMessage" class="error">{{ errorMessage }}</p>

      <button type="button" @click="emit('cancel')">取消</button>
      <button type="button" @click="save">保存</button>
    </section>
  </div>
</template>

<script setup>
import { reactive, ref, watch } from 'vue'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  task: {
    type: Object,
    required: true
  }
})

const emit = defineEmits({
  save: payload => {
    return typeof payload?.title === 'string'
      && payload.title.trim().length > 0
  },
  cancel: () => true
})

const draft = reactive({
  title: '',
  category: '组件基础'
})
const errorMessage = ref('')

watch(
  () => [props.visible, props.task],
  ([visible]) => {
    if (visible) {
      draft.title = props.task.title
      draft.category = props.task.category
      errorMessage.value = ''
    }
  },
  { immediate: true }
)

function save() {
  if (!draft.title.trim()) {
    errorMessage.value = '请输入任务标题'
    return
  }

  emit('save', {
    id: props.task.id,
    title: draft.title.trim(),
    category: draft.category
  })
}
</script>

父组件处理保存与取消:

<TaskEditDialog
  :visible="editing"
  :task="currentTask"
  @save="saveTask"
  @cancel="editing = false"
/>
function saveTask(updatedTask) {
  const task = tasks.value.find(item => item.id === updatedTask.id)
  if (task) {
    Object.assign(task, updatedTask)
  }
  editing.value = false
}

这里有两种事件职责:

  • save 携带完整表单数据,表示一次业务提交;

  • cancel 只表示关闭编辑流程。

如果每次输入都通过 v-model 修改父组件原始任务,点击“取消”就难以恢复原值。本地草稿更适合需要取消或确认操作的编辑场景。


十四、TypeScript 中的事件声明

TypeScript 项目可以使用类型参数声明事件载荷:

<script setup lang="ts">
const emit = defineEmits<{
  save: [payload: { id: number; title: string }]
  cancel: []
  delete: [id: number]
}>()
</script>

调用时能获得参数检查:

emit('save', {
  id: 1,
  title: '掌握 TypeScript 事件声明'
})

emit('delete', 1)
emit('cancel')

使用 defineModel 时也可以指定类型:

<script setup lang="ts">
const model = defineModel<string>({
  default: ''
})

const count = defineModel<number>('count', {
  default: 0
})
</script>

Props、事件与 v-model 一起构成组件的公开类型契约。它们越明确,组件越容易复用和重构。


十五、Options API 写法

Options API 使用 emits 选项和 this.$emit:

<script>
export default {
  props: {
    modelValue: {
      type: String,
      default: ''
    }
  },
  emits: ['update:modelValue', 'save'],
  methods: {
    onInput(event) {
      this.$emit('update:modelValue', event.target.value)
    },
    save() {
      this.$emit('save')
    }
  }
}
</script>

事件校验同样写在 emits 对象中:

export default {
  emits: {
    save: payload => Boolean(payload?.title),
    cancel: () => true
  }
}

Vue 2 的默认 v-model 使用 value 和 input,并可使用 model 选项调整;Vue 3 默认使用 modelValue 和 update:modelValue。维护旧项目时应先确认版本,不要混用两套约定。


十六、常见问题自查

问题

推荐处理

父组件收不到事件

检查事件名、监听名称和是否直接父子组件

$event 只拿到第一个参数

多参数事件交给方法处理,复杂载荷使用对象

子组件修改 Props 后难以追踪

改为发送事件,由父组件更新数据

v-model 没有更新

检查 Prop 是否为 modelValue、事件是否为 update:modelValue

命名 v-model 不生效

检查 title 与 update:title 是否对应

复选框绑定值不正确

使用 checked 和 event.target.checked

数字变成字符串

在子组件明确转换,或统一选项值类型

defineModel 无法使用

确认 Vue 版本为 3.4+,低版本使用手动写法

点击取消无法恢复表单

使用本地草稿,保存时再提交,不要每次输入都修改原始对象

弹窗确认和关闭职责混乱

v-model 控制可见性,独立事件处理业务确认

多层组件事件无法继续向上传递

在直接父组件中明确转发,或重新评估状态共享方式

事件校验没有阻止业务执行

事件校验用于开发警告,真正业务规则在处理函数中执行


十七、总结

自定义事件与 v-model 是组件通信的完整协作方式:

  1. Props 向下传值,自定义事件向上通知,父组件负责修改状态。

  2. defineEmits 声明组件公开事件,支持参数和事件校验。

  3. 单个参数可直接传递,复杂载荷优先使用对象。

  4. $event 表示第一个事件参数,多参数场景推荐方法处理。

  5. v-model 是 Props 与更新事件的统一约定,不是直接修改 Props。

  6. 默认绑定使用 modelValue 和 update:modelValue。

  7. Vue 3.4+ 可使用 defineModel,低版本使用手动 Props + 事件。

  8. 多个独立值使用命名 v-model,复杂表单则采用本地草稿和提交事件。

  9. 复选框、单选框与选择器要根据控件读取正确的值。

  10. 弹窗显示状态与业务确认应分开处理,TypeScript 可进一步约束事件载荷。

下一站预告

下一篇《插槽Slot详解》将学习默认插槽、具名插槽、作用域插槽和插槽 fallback 内容,让组件不仅通过数据复用,也能通过内容组合适应更多页面结构。


作者:洋洋技术笔记 发布日期:2026-11-11 系列:Vue.js从入门到精通 - 第10篇


原文链接:洋洋的技术笔记

0

评论区