Vue3 升级踩坑:子组件 click 事件为什么会触发两次?

升级 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> 上。

这时候,当你点击按钮,奇妙的事情发生了:

  1. 第一次触发 :你点击了 <button>,原生 click 事件触发,透传绑定的父组件 doSomething 执行。
  2. 第二次触发 :<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 的路上少踩一个坑。

相关推荐
迷迭绿5 分钟前
Cesium 轨迹回放播放器
前端·javascript·cesium
HolySaga18 分钟前
我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听
前端
程序员老赵37 分钟前
Docker 部署 XWiki:轻松搭建企业知识库与协作 Wiki
前端·docker·开源
console.log('npc')41 分钟前
第 10 章 更多业务模块扩展
前端·人工智能·大模型
行者全栈架构师1 小时前
我用 TRAE Work 把桌游之夜搬进了浏览器:一个计分器的从创意到上线
前端·算法·架构
山雀~1 小时前
线上CI流水打包失败排查思路案例
前端·ci/cd
时间的情敌1 小时前
《回顾 2019 前端:React Hooks 问世,前端工程化爆发的一年》
前端·react.js·前端框架
燕落南枝1 小时前
CSS 类选择器详解:从入门到实战
前端·css
tryCbest1 小时前
Vue3——第 7 篇:组件基础——从注册到使用的完整流程
前端·javascript·vue.js
ai工具集合1 小时前
图标集用法,前端项目 SVG 图标 icon 完整接入实操
前端