Vue3的emit到底怎么用?看完从入门到踩坑都懂了
你好,我是一个做了4年前端开发的Vue爱好者,Vue3刚出来的时候就跟着文档啃,还踩过不少小坑——比如事件名写错、忘了在setup里声明、自定义事件传参搞混了,今天就整理成一篇接地气的问答,把emit从最基础的用法、场景到容易出错的地方,甚至Vue3.4+更新的新功能都讲清楚,新手看完直接上手,老手也能补补细节。
什么是Vue3的emit?为什么要用它?
首先得搞懂父子组件的关系:在Vue里,组件是层层嵌套的,比如你做一个登录页(父组件),里面放个输入手机号的弹窗(子组件),父组件可以通过props把数据“塞给”子组件(比如弹窗的默认提示语、父组件存的手机号草稿),但反过来呢?子组件不能直接修改props传过来的值——这是Vue官方要求的单向数据流规则,目的是防止数据流向混乱,比如多个子组件用同一个父组件的prop,随便改谁都不知道是谁动的。
那子组件想给父组件“传消息”或者“带点数据回去”怎么办?这时候emit就派上用场了!简单说,emit就是子组件的“传声筒+快递员”:它会在子组件里触发一个自定义事件,父组件监听这个事件,就能收到子组件“喊”的话或者“寄”的东西。
举个最直观的小例子想象一下:你是爸爸(父组件),给儿子(子组件)买了个冰淇淋(props传的默认值“草莓味”),儿子吃了两口觉得太甜,想换巧克力的,但不能直接把爸爸手里的冰淇淋换成别的(不能改props),只能喊“爸!换巧克力味!”(子组件emit自定义事件change-flavor),爸爸听到喊声(父组件监听@change-flavor),就会把冰淇淋换成巧克力(修改父组件自己的状态)。
Vue3的emit和Vue2有啥不一样?
很多刚从Vue2转过来的开发者会懵,因为写法确实变了不少,核心区别有这几个:
声明方式变了
Vue2里你可以直接用this.$emit(),不需要提前声明自定义事件(虽然官方后来推荐在emits选项里列出来,但很多人嫌麻烦省了);但Vue3里,不管是Options API还是Composition API(包括<script setup>语法糖,这个现在是主流吧),提前声明自定义事件都是官方强制推荐甚至语法层面有优化的——提前声明能让代码更规范,编辑器还能给你做事件名的自动补全,防止手滑写错字母。
语法糖用起来更顺手
Vue3.2正式推出了<script setup>语法糖,这也是现在Vue3开发的首选写法,不用写export default,不用写setup函数,连引入的组件都不用注册,直接用就行,emit在里面的写法也简化了很多。
事件验证功能更强了
Vue2的emits选项只能列事件名,或者写个简单的验证函数,但那个验证函数是在触发之后才执行的;Vue3的emits选项可以放在<script setup>的defineEmits宏里,验证函数在触发之前就会运行,提前拦截不符合要求的参数,而且更灵活——可以用数组列名,也可以用对象做详细的验证规则。
主流写法:用<script setup>怎么实现emit?
现在大家写Vue3基本都用<script setup>,所以重点讲这个,Options API的写法后面会简单提一句,万一你维护旧项目或者习惯用Options呢。
第一步:用defineEmits声明自定义事件
defineEmits是<script setup>里的内置宏,不需要引入,直接调用就行,它有两种参数写法:
写法1:数组形式(最简单,适合不需要验证的场景)
比如刚才的冰淇淋例子,子组件Son.vue想触发change-flavor和close两个事件,就可以这样写:
// Son.vue <script setup> // 数组里放自定义事件名,注意事件名用短横线或者驼峰?后面讲 const emits = defineEmits(['change-flavor', 'close']) </script>
写法2:对象形式(适合需要验证参数的场景)
如果子组件触发change-flavor的时候必须带一个参数(比如新口味的字符串),而且这个参数不能是空字符串,也不能是除了“草莓”“巧克力”“香草”之外的口味,就可以用对象形式:
// Son.vue
<script setup>
const emits = defineEmits({
// 不需要验证的事件可以写null或者undefined
close: null,
// 需要验证的事件写一个函数,参数是事件触发时传的所有数据
// 第一个参数是传的第一个值,第二个是第二个...以此类推
'change-flavor': (newFlavor) => {
// 验证通过返回true,不通过返回false
// 不通过的话Vue会在控制台给你一个警告,帮你排查问题
if (!newFlavor) {
console.warn('换口味事件必须带新口味参数!')
return false
}
const validFlavors = ['草莓', '巧克力', '香草']
if (!validFlavors.includes(newFlavor)) {
console.warn(`不支持${newFlavor}这个口味哦,只能选草莓、巧克力、香草!`)
return false
}
return true
}
})
</script>
这里要注意,对象形式里的事件名建议用引号括起来,尤其是短横线形式的,不然编辑器可能会报错说有语法错误;驼峰的话可以不用,但后面监听的时候要注意对应关系,这个后面单独说。
第二步:在子组件里触发emit
声明完之后,就可以用刚才定义的emits变量触发事件了,触发的时候可以传任意数量的参数,父组件都能收到,比如刚才的Son.vue,加一个按钮来换口味:
// Son.vue
<template>
<div class="son">
<p>当前冰淇淋口味:{{ props.flavor }}</p>
<!-- 按钮点击触发changeFlavor函数 -->
<button @click="changeFlavor('巧克力')">换巧克力味</button>
<button @click="emits('close')">关闭弹窗</button>
</div>
</template>
<script setup>
// 这里顺便提一下props的语法糖defineProps,因为刚才例子用到了
const props = defineProps({
flavor: {
type: String,
default: '草莓'
}
})
const emits = defineEmits(['change-flavor', 'close'])
// 写一个换口味的函数
const changeFlavor = (newFlavor) => {
// 触发change-flavor事件,带参数newFlavor
emits('change-flavor', newFlavor)
}
</script>
这里的emits变量其实是个函数,直接调用就行,第一个参数是事件名,后面跟着的就是要传给父组件的参数。
第三步:在父组件里监听事件并处理
父组件里监听事件的写法和监听原生事件差不多,用@事件名或者v-on:事件名,然后绑定一个处理函数就行,处理函数的参数就是子组件emit传过来的值,比如刚才的父组件Dad.vue:
// Dad.vue
<template>
<div class="dad">
<h1>爸爸的冰淇淋摊</h1>
<button @click="showSon = true">打开儿子的冰淇淋窗口</button>
<!-- 监听close和change-flavor事件 -->
<Son
v-if="showSon"
:flavor="currentFlavor"
@close="showSon = false"
@change-flavor="handleChangeFlavor"
/>
</div>
</template>
<script setup>
// 引入子组件,不需要注册,直接用
import Son from './Son.vue'
import { ref } from 'vue'
// 定义状态
const showSon = ref(false)
const currentFlavor = ref('草莓')
// 处理换口味事件的函数,参数newFlavor就是子组件传过来的
const handleChangeFlavor = (newFlavor) => {
currentFlavor.value = newFlavor
console.log('儿子选了新口味:', newFlavor)
}
</script>
这样一套流程就跑通了!儿子不能直接改爸爸的currentFlavor,但可以通过emit喊爸爸改,爸爸收到消息就会更新自己的状态,然后通过props把新状态传给儿子,单向数据流就保持住了。
Options API的写法是什么样的?
刚才说了万一维护旧项目或者习惯用Options的情况,这里简单补一句,核心逻辑是一样的,只是写法不同:
// Son.vue(Options API)
<template>
<div class="son">
<p>当前冰淇淋口味:{{ flavor }}</p>
<button @click="changeFlavor('巧克力')">换巧克力味</button>
<button @click="$emit('close')">关闭弹窗</button>
</div>
</template>
<script>
export default {
name: 'Son',
// 声明props
props: {
flavor: {
type: String,
default: '草莓'
}
},
// 声明自定义事件,和Vue3的defineEmits参数一样
emits: ['change-flavor', 'close'],
methods: {
changeFlavor(newFlavor) {
this.$emit('change-flavor', newFlavor)
}
}
}
</script>
父组件的写法和<script setup>完全一样,这里就不重复了。
新手必踩的5个emit坑,我已经帮你踩过了
啃文档的时候觉得emit很简单,但实际开发中很容易踩坑,我整理了4年前端生涯里见过最多的5个,你看完就能避开90%的问题。
坑1:事件名的大小写搞混了
这是最常见的坑!很多新手会搞不清短横线、驼峰、帕斯卡的对应关系,这里给你一个官方明确的规则,记牢就行:
- 触发事件时的写法(子组件):短横线或者驼峰都可以,但建议和声明时保持一致,短横线形式更符合Vue的命名习惯,尤其是在DOM模板里(比如在
<template>里直接写@change-flavor)。 - 监听事件时的写法(父组件):
- 如果是在Vue的单文件组件(SFC)的
<template>里,短横线或者驼峰都可以,Vue会自动转换,比如你子组件触发的是changeFlavor,父组件监听@change-flavor也能收到; - 但如果是在DOM模板里(比如直接在HTML文件里写Vue代码,不是SFC),因为HTML不区分大小写,所以只能用短横线形式,不然监听会失效!
- 不管是SFC还是DOM模板,帕斯卡形式(首字母大写)的事件名都不推荐,监听起来很麻烦。
- 如果是在Vue的单文件组件(SFC)的
举个例子验证一下规则:
// Son.vue(触发短横线)
emits('change-flavor', '巧克力')
// Dad.vue(监听驼峰,SFC里可以,DOM模板不行)
<Son @changeFlavor="handleChange" />
// Son.vue(触发驼峰)
emits('changeFlavor', '巧克力')
// Dad.vue(监听短横线,两种模板都可以)
<Son @change-flavor="handleChange" />
所以最稳妥的做法是:不管子组件触发还是父组件监听,都统一用短横线形式,这样绝对不会出错。
坑2:在<script setup>里忘了用defineEmits声明直接调用$emit
在Vue2里你可以直接用this.$emit(),但在<script setup>里没有this!而且内置宏也只有defineProps、defineEmits、defineExpose、defineOptions、defineModel这些,没有$emit,所以如果你在<script setup>里直接写$emit('change-flavor'),编辑器会直接报错说$emit未定义,浏览器控制台也会提示错误。
解决方法很简单,就是刚才讲的:先用defineEmits声明事件,得到emits变量,再用emits变量触发。
坑3:事件验证函数里没有返回值,或者返回了false但没看控制台警告
刚才讲对象形式的defineEmits时提到了验证函数,验证通过要返回true,不通过返回false,但很多新手会忘了写return语句,或者返回了false但没注意看控制台的黄色警告,以为验证没生效。
这里再强调一遍:验证函数必须显式返回true或false,返回undefined或者其他值的话,Vue会默认验证不通过?不对不对,刚才记错了,查了一下官方最新的更新——哦对了,Vue3.2之前的版本如果验证函数没返回true,就算验证不通过,会在控制台警告;但Vue3.2+之后,只有返回false才会警告,返回undefined或者其他非false值都会默认验证通过,不过不管哪个版本,显式返回true/false都是更规范的做法,而且方便排查问题。
还有,验证函数只是给你一个警告,不会阻止事件的触发!也就是说,即使你返回了false,父组件还是会收到这个事件,只是控制台会有提示,所以如果你的业务逻辑里必须阻止不符合要求的事件触发,不能只靠验证函数,还要在子组件的触发函数里加判断。
坑4:自定义事件名和原生事件名冲突了
这也是一个容易踩的坑!比如你做一个子组件Button.vue,想触发一个click自定义事件,父组件里监听@click——这时候父组件收到的到底是子组件的自定义事件,还是按钮本身的原生click事件?
答案是:如果子组件的根元素是原生元素(比如div、button、input),而且父组件监听的事件名和根元素的原生事件名一样,那么父组件会同时收到自定义事件和原生事件! 这会导致处理函数被调用两次,很麻烦。
解决方法有两个:
- 尽量避免自定义事件名和原生事件名冲突:比如把click改成
button-click或者submit-btn; - 如果必须用原生事件名,可以在子组件里用
$attrs的inheritAttrs: false来禁止根元素继承父组件的属性和事件,然后手动把需要的属性和事件绑到根元素上,这个稍微复杂一点,后面讲defineExpose的时候可能会提一下?不对,inheritAttrs在<script setup>里要用defineOptions宏来设置,// Button.vue <template> <!-- 手动绑定需要的原生事件和属性,比如class --> <button :class="attrs.class" @click="handleNativeClick"> 点击我 </button> </template>
坑5:传多个参数的时候父组件的处理函数只接收到一个
刚才说了子组件触发emit的时候可以传任意数量的参数,那父组件怎么接收呢?
- 如果你只传一个参数,父组件的处理函数的第一个参数就是这个值;
- 如果你传多个参数,父组件的处理函数需要用剩余参数(...args) 来接收,或者直接写多个形参,不然只能接收到第一个参数。
举个例子:
// Son.vue(传多个参数:新口味、儿子的名字、当前时间)
emits('change-flavor', '巧克力', '小明', new Date().toLocaleString())
// Dad.vue(处理函数只写一个形参)
const handleChangeFlavor = (newFlavor) => {
console.log(newFlavor) // 只能打印出'巧克力'
}
// Dad.vue(处理函数用剩余参数)
const handleChangeFlavor = (...args) => {
console.log(args) // 打印出['巧克力', '小明', '当前时间字符串']
// 或者解构出来:
const [flavor, name, time] = args
console.log(flavor, name, time)
}
// Dad.vue(处理函数直接写多个形参)
const handleChangeFlavor = (flavor, name, time) => {
console.log(flavor, name, time) // 和解构出来的效果一样
}
这个坑其实不是Vue3特有的,Vue2里也是这样,但很多新手还是会忘。
Vue3.4+更新的超好用的新功能:defineModel
刚才讲了props和emit的单向数据流:父组件传prop给子组件,子组件不能改,只能emit事件让父组件改,但如果是表单类的子组件,比如输入框、开关、复选框,每次都要写defineProps、defineEmits、const emits = defineEmits(['update:modelValue'])、emits('update:modelValue', newValue),是不是觉得有点啰嗦?
Vue3.4正式推出了defineModel宏(之前是实验性功能,需要在vite.config.js或者vue.config.js里配置features: { defineModel: true },现在3.4+直接就能用了),这个宏可以帮你简化双向绑定的代码,让你在子组件里像用ref一样直接修改父组件传过来的绑定值,同时还能保持单向数据流的规则(底层还是用的props和emit,只是Vue帮你封装了)。
defineModel的基本用法
举个例子,做一个自定义输入框的子组件Input.vue:
// Input.vue
<template>
<div class="input-wrapper">
<label>{{ label }}</label>
<!-- 直接用modelValue这个ref,绑定到input的value和input事件上 -->
<input type="text" :value="modelValue" @input="handleInput" />
</div>
</template>
<script setup>
// 这里的modelValue就是默认的绑定值,相当于之前的defineProps(['modelValue']) + defineEmits(['update:modelValue'])
// 可以传一个对象进去,设置验证规则、默认值等,和defineProps的参数一样
const modelValue = defineModel({
type: String,
default: ''
})
const props = defineProps({
label: {
type: String,
default: '请输入内容'
}
})
const handleInput = (e) => {
// 直接修改modelValue.value!Vue会自动帮你触发update:modelValue事件,把新值传给父组件
modelValue.value = e.target.value
}
</script>
父组件里的写法和原生input的双向绑定一样,用v-model就行:
// Dad.vue
<template>
<div class="dad">
<h1>爸爸的自定义输入框</h1>
<p>当前输入的内容:{{ inputContent }}</p>
<!-- 直接用v-model绑定 -->
<Input v-model="inputContent" label="请输入儿子的名字" />
</div>
</template>
<script setup>
import Input from './Input.vue'
import { ref } from 'vue'
const inputContent = ref('')
</script>
是不是比之前的写法简单多了?!
defineModel的进阶用法:自定义绑定名
刚才的例子用的是默认的绑定名modelValue,如果父组件想绑定多个值怎么办?比如做一个自定义的开关组件,既要绑定开关状态,又要绑定开关的颜色?这时候可以给defineModel传一个参数,自定义绑定名。
举个例子,做一个自定义开关组件Switch.vue:
// Switch.vue
<template>
<div
class="switch"
:class="{ 'switch--on': isOn, 'switch--blue': color === 'blue', 'switch--red': color === 'red' }"
@click="toggleSwitch"
>
<div class="switch__dot"></div>
</div>
</template>
<script setup>
// 自定义第一个绑定名:isOn,第二个绑定名:color
const isOn = defineModel('isOn', {
type: Boolean,
default: false
})
const color = defineModel('color', {
type: String,
default: 'blue'
})
const toggleSwitch = () => {
isOn.value = !isOn.value
// 随便换个颜色玩
color.value = color.value === 'blue' ? 'red' : 'blue'
}
</script>
<style scoped>
.switch {
width: 60px;
height: 30px;
border-radius: 15px;
background-color: #ccc;
position: relative;
cursor: pointer;
transition: background-color 0.3s;
}
.switch--on {
background-color: #409eff;
}
.switch--blue.switch--on {
background-color: #409eff;
}
.switch--red.switch--on {
background-color: #f56c6c;
}
.switch__dot {
width: 26px;
height: 26px;
border-radius: 50%;
background-color: #fff;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.switch--on .switch__dot {
left: 32px;
}
</style>
父组件里的写法用v-model:绑定名就行:
// Dad.vue
<template>
<div class="dad">
<h1>爸爸的自定义开关</h1>
<p>开关状态:{{ switchIsOn }}</p>
<p>开关颜色:{{ switchColor }}</p>
<!-- 用v-model:绑定名绑定多个值 -->
<Switch v-model:isOn="switchIsOn" v-model:color="switchColor" />
</div>
</template>
<script setup>
import Switch from './Switch.vue'
import { ref } from 'vue'
const switchIsOn = ref(false)
const switchColor = ref('blue')
</script>
这个功能真的太香了,尤其是做表单组件库的时候,能省很多代码!
总结一下
今天我们从什么是Vue3的emit、为什么要用它,讲到了主流的<script setup>写法、Options API的写法,然后整理了新手必踩的5个坑,最后讲了Vue3.4+更新的超好用的defineModel宏。
再核心回顾一遍:
- emit是子组件给父组件传消息/数据的方式,遵循单向数据流规则;
<script setup>里用defineEmits声明事件,用emits变量触发;- 事件名统一用短横线形式,避免大小写问题;
- 避免自定义事件名和原生事件名冲突;
- 传多个参数用剩余参数或者多个形参;
- Vue3.4+用defineModel简化双向绑定代码。
希望这篇文章能帮到你,如果还有什么不懂的地方,或者你踩过其他emit的坑,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


