Code前端首页关于Code前端联系我们

Vue3的emit到底怎么用?看完从入门到踩坑都懂了

terry 59分钟前 阅读数 18 #Vue

你好,我是一个做了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模板,帕斯卡形式(首字母大写)的事件名都不推荐,监听起来很麻烦。

举个例子验证一下规则:

// 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),而且父组件监听的事件名和根元素的原生事件名一样,那么父组件会同时收到自定义事件和原生事件! 这会导致处理函数被调用两次,很麻烦。

解决方法有两个:

  1. 尽量避免自定义事件名和原生事件名冲突:比如把click改成button-click或者submit-btn;
  2. 如果必须用原生事件名,可以在子组件里用$attrs的inheritAttrs: false来禁止根元素继承父组件的属性和事件,然后手动把需要的属性和事件绑到根元素上,这个稍微复杂一点,后面讲defineExpose的时候可能会提一下?不对,inheritAttrs在<script setup>里要用defineOptions宏来设置,
    // Button.vue
    <template>
    <!-- 手动绑定需要的原生事件和属性,比如class -->
    <button :class="attrs.class" @click="handleNativeClick">
     点击我
    </button>
    </template>
``` 这样父组件监听`@click`就只会收到子组件的自定义事件了,不会收到原生事件。

坑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宏。

再核心回顾一遍:

  1. emit是子组件给父组件传消息/数据的方式,遵循单向数据流规则;
  2. <script setup>里用defineEmits声明事件,用emits变量触发;
  3. 事件名统一用短横线形式,避免大小写问题;
  4. 避免自定义事件名和原生事件名冲突;
  5. 传多个参数用剩余参数或者多个形参;
  6. Vue3.4+用defineModel简化双向绑定代码。

希望这篇文章能帮到你,如果还有什么不懂的地方,或者你踩过其他emit的坑,欢迎在评论区留言讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

上一篇:或者用pnpm
热门