自定义事件与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:titleupdate: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。维护旧项目时应先确认版本,不要混用两套约定。
十六、常见问题自查
十七、总结
自定义事件与 v-model 是组件通信的完整协作方式:
Props 向下传值,自定义事件向上通知,父组件负责修改状态。
defineEmits声明组件公开事件,支持参数和事件校验。单个参数可直接传递,复杂载荷优先使用对象。
$event表示第一个事件参数,多参数场景推荐方法处理。v-model是 Props 与更新事件的统一约定,不是直接修改 Props。默认绑定使用
modelValue和update:modelValue。Vue 3.4+ 可使用
defineModel,低版本使用手动 Props + 事件。多个独立值使用命名
v-model,复杂表单则采用本地草稿和提交事件。复选框、单选框与选择器要根据控件读取正确的值。
弹窗显示状态与业务确认应分开处理,TypeScript 可进一步约束事件载荷。
下一站预告
下一篇《插槽Slot详解》将学习默认插槽、具名插槽、作用域插槽和插槽 fallback 内容,让组件不仅通过数据复用,也能通过内容组合适应更多页面结构。
作者:洋洋技术笔记 发布日期:2026-11-11 系列:Vue.js从入门到精通 - 第10篇
原文链接:洋洋的技术笔记
评论区