升级 Vue3 遇到的"灵异事件"
之前做老项目 Vue2 升 Vue3 的重构,本来以为就是改改 API、换换语法糖的体力活,结果在自测的时候撞见了一个挺邪门的问题。
有个封装好的基础按钮组件,父组件绑了个 @click 处理业务逻辑。在 Vue2 里跑得好好的,一到 Vue3 环境,点一下按钮,控制台硬生生打印出两条日志------事件触发了两次。
当时我第一反应是:难道事件冒泡没拦住?加了 .stop 修饰符,没用。检查父组件是不是绑了两次,也没有。最后排查了一圈,发现锅居然出在子组件的 emit 上。
今天就来盘一盘这个 Vue3 升级中极易踩坑的"双击"问题,顺便把底层的逻辑给大家捋清楚。
案发现场:到底是谁在偷偷发事件?
我们先来看看出问题的子组件代码,这是非常典型的 Vue2 遗留写法:
html
<!-- 子组件 MyButton.vue -->
<template>
<button class="my-btn" @click="handleClick">
<slot />
</button>
</template>
<script setup>
// 注意这里:没有声明 click 事件
const emit = defineEmits([])
const handleClick = () => {
// 手动触发 click 事件
emit('click')
}
</script>
父组件就是很普通的监听:
html
<MyButton @click="doSomething">点我</MyButton>
在 Vue2 中,如果你没有在 emits 选项里声明 click,父组件的 @click 会被当作原生事件,绑定到子组件的根元素(也就是那个 <button>)上。子组件内部 this.$emit('click') 触发的是自定义事件。两者井水不犯河水,通常我们靠手动 emit 来接管逻辑。
但在 Vue3 里,情况变了。Vue3 引入了更强大的 Fallthrough Attributes(透传 Attributes) 机制。
当子组件没有 显式声明 click 事件(也就是 defineEmits 里没写 'click')时,Vue3 会认为父组件传的 @click 是个原生 DOM 事件,于是直接把它透传绑定到子组件的根节点 <button> 上。
这时候,当你点击按钮,奇妙的事情发生了:
- 第一次触发 :你点击了
<button>,原生 click 事件触发,透传绑定的父组件doSomething执行。 - 第二次触发 :
<button>上的@click="handleClick"触发,执行了emit('click')。因为父组件也监听了自定义的click,doSomething又被执行了一次。
这就是为什么事件会"成精"触发两次。本质上,是原生事件透传 和手动自定义事件撞车了。
怎么破?两行代码搞定
搞清楚了原理,解决起来就非常简单了。这里给大家两个方案,按需取用。
方案一:删掉子组件的手动 emit(推荐)
既然 Vue3 已经贴心地帮你把原生事件透传到根节点了,对于这种纯包装类的 UI 组件,我们完全没必要再手动去 $emit 一次。
直接把子组件里手动触发事件的代码删掉,让它安安静静地做个透传容器:
html
<template>
<!-- 删掉 @click="handleClick",直接让原生 click 透传 -->
<button class="my-btn">
<slot />
</button>
</template>
<script setup>
// 连 handleClick 和 emit 都可以省了,主打一个极简
</script>
这是最优雅、最符合 Vue3 直觉的做法。代码更少,逻辑更清晰,性能也稍微好那么一丢丢。
方案二:显式声明 emit(适用于需要拦截处理的场景)
如果你在子组件里确实需要做一些拦截逻辑(比如加个防抖、校验个权限,或者修改事件参数),不能直接透传,那就必须显式声明 click 事件。
html
<template>
<button class="my-btn" @click="handleClick">
<slot />
</button>
</template>
<script setup>
// 关键:在 defineEmits 中显式声明 'click'
const emit = defineEmits(['click'])
const handleClick = (e) => {
// 加点自定义逻辑
console.log('拦截一下')
emit('click', e)
}
</script>
一旦你在 defineEmits 里声明了 click,Vue3 就会恍然大悟:"哦,原来这是个自定义事件,不是原生 DOM 事件。" 于是它不再 把父组件的 @click 透传到根节点,从而避免了两次触发的问题。
避坑指南:给 Vue3 升级人的几句掏心窝子话
说实话,从 Vue2 迁移到 Vue3,很多这种"暗坑"都是因为底层机制的微调引起的。借着这个 click 事件的问题,我给大家总结几条实操建议:
- 养成声明 emits 的好习惯 :在 Vue3 中,强烈建议把所有子组件向外抛出的事件,都在
defineEmits里明明白白地写出来。这不仅是为了防止透传冲突,更是为了组件的 TS 类型推导和代码可读性。 - 警惕多根节点组件 :如果你的子组件有多个根节点(比如外面套了个
<div>,里面是个<button>),Vue3 是无法自动透传 Attributes 的,这时候控制台会报 warning。你需要手动使用v-bind="$attrs"来指定透传目标。 - 别太依赖"黑魔法" :Vue2 时代我们习惯了不声明 emits 也能随便
$emit,但在 Vue3 里,这种隐式行为往往是 bug 的温床。把组件的 Props 和 Emits 当作组件的 API 契约,严格定义,后期维护能少掉很多头发。
技术升级从来不是简单的 npm install,底层思维也得跟着迭代。希望这篇复盘能帮你在升级 Vue3 的路上少踩一个坑。