【Vue3+Props/Emit/事件总线】组件通信场景:从基础用法到实战规范,彻底搞懂组件解耦方案,避开耦合过重与内存泄漏坑!

📑 文章目录
- 一、为什么需要组件解耦?
- 二、三种主流通信方式速览
- 三、Props:父传子,单向数据流
- [3.1 基本用法](#3.1 基本用法)
- [3.2 第一个坑:子组件修改 props](#3.2 第一个坑:子组件修改 props)
- [3.3 第二个坑:传对象/数组时"意外共享"](#3.3 第二个坑:传对象/数组时“意外共享”)
- [3.4 Props 规范小结](#3.4 Props 规范小结)
- 四、Emit:子通知父,事件驱动
- [4.1 基本用法](#4.1 基本用法)
- [4.2 v-model 本质:props + emit 的语法糖](#4.2 v-model 本质:props + emit 的语法糖)
- [4.3 坑:事件名大小写](#4.3 坑:事件名大小写)
- [4.4 Emit 规范小结](#4.4 Emit 规范小结)
- 五、事件总线:跨组件通信,需谨慎
- [5.1 适用场景](#5.1 适用场景)
- [5.2 Vue3 的推荐做法:mitt / tiny-emitter](#5.2 Vue3 的推荐做法:mitt / tiny-emitter)
- [5.3 事件总线的坑](#5.3 事件总线的坑)
- [5.4 替代方案:provide / inject](#5.4 替代方案:provide / inject)
- [5.5 事件总线使用规范](#5.5 事件总线使用规范)
- 六、实战:一个完整案例
- [6.1 结构设计](#6.1 结构设计)
- [6.2 完整代码示例](#6.2 完整代码示例)
- [6.3 这样设计的好处](#6.3 这样设计的好处)
- 七、选择决策速查
- 八、总结
- [🔍 系列模块导航](#🔍 系列模块导航)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
一、为什么需要组件解耦?
组件写多了,就容易出现这种情况:
- 子组件到处
$parent、$children乱用 - 一层层
props往下传、再一层层emit往上冒 - 组件之间互相强依赖,改一个影响一片
这些问题本质都是耦合过重:组件之间关系太紧,难以独立复用和测试。
本文的目标是:用清晰的规范,告诉你「什么场景用什么方式」「为什么这么选」「坑在哪」,让组件之间保持合适的关系。
[⬆ 返回目录](#⬆ 返回目录)
二、三种主流通信方式速览
| 方式 | 方向 | 适用场景 | 耦合程度 |
|---|---|---|---|
| Props | 父 → 子 | 父把数据传给子 | 低(单向、显式) |
| Emit | 子 → 父 | 子通知父做某件事 | 低(事件驱动) |
| 事件总线 | 任意 | 跨层级、兄弟组件 | 中(需谨慎) |
下面按「基础用法 → 常见坑 → 实战规范」的顺序展开。
[⬆ 返回目录](#⬆ 返回目录)
三、Props:父传子,单向数据流
3.1 基本用法
html
<!-- 父组件 Parent.vue -->
<template>
<div>
<Child :user-name="userName" :count="count" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const userName = ref('张三')
const count = ref(0)
</script>
html
<!-- 子组件 Child.vue -->
<template>
<div>
<p>用户名:{{ userName }}</p>
<p>数量:{{ count }}</p>
</div>
</template>
<script setup>
// 方式一:直接使用,无类型约束
const props = defineProps(['userName', 'count'])
// 方式二:带类型和默认值(推荐)
defineProps({
userName: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
</script>
要点:
user-name是 kebab-case,对应 JS 里的userName(camelCase)- Props 是只读的,不要在子组件里直接改
[⬆ 返回目录](#⬆ 返回目录)
3.2 第一个坑:子组件修改 props
❌ 错误示例:
html
<script setup>
const props = defineProps(['count'])
// 不要这样!违反单向数据流
const handleClick = () => {
props.count++ // 控制台会警告
}
</script>
正确做法有两种:
- 用
emit通知父组件,由父组件改 - 用
computed做一个本地的"可写副本"(仅当确实需要本地编辑时)
✅ 推荐做法:
html
<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])
const handleClick = () => {
emit('update:count', props.count + 1)
}
</script>
父组件用 v-model 或 @update:count 接收。
[⬆ 返回目录](#⬆ 返回目录)
3.3 第二个坑:传对象/数组时"意外共享"
html
<!-- 父组件 -->
<Child :config="sharedConfig" />
如果多个子组件接收同一个对象引用,在一个组件里改 config.xxx,别的组件也会跟着变。
✅ 建议:需要"独立副本"时,在父组件传时做一次拷贝:
html
<Child :config="{ ...sharedConfig }" />
<!-- 或者用 toRef 等方式,按需决定 -->
[⬆ 返回目录](#⬆ 返回目录)
3.4 Props 规范小结
- 命名:父模板用 kebab-case,子组件定义用 camelCase
- 类型 :尽量用对象式
defineProps,写明type、default、required - 禁止:子组件直接修改 props
- 复杂数据:注意引用共享,必要时传副本或做深拷贝
[⬆ 返回目录](#⬆ 返回目录)
四、Emit:子通知父,事件驱动
4.1 基本用法
html
<!-- 子组件 SubmitButton.vue -->
<template>
<button @click="handleSubmit">提交</button>
</template>
<script setup>
const emit = defineEmits(['submit'])
const handleSubmit = () => {
emit('submit', { timestamp: Date.now() })
}
</script>
html
<!-- 父组件 -->
<template>
<SubmitButton @submit="onSubmit" />
</template>
<script setup>
const onSubmit = (payload) => {
console.log('收到提交事件', payload)
}
</script>
要点:
- 子组件只负责"发出事件 + 带参数",不关心父组件怎么处理
- 事件名建议用 kebab-case:
submit、update:count
[⬆ 返回目录](#⬆ 返回目录)
4.2 v-model 本质:props + emit 的语法糖
html
<!-- 这两种写法等价 -->
<Child v-model="value" />
<Child :modelValue="value" @update:modelValue="value = $event" />
子组件对应写法:
html
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const updateValue = (val) => {
emit('update:modelValue', val)
}
</script>
多个 v-model:
html
<Child v-model:title="title" v-model:content="content" />
对应 title / content 的 props 和 update:title、update:content 事件。
[⬆ 返回目录](#⬆ 返回目录)
4.3 坑:事件名大小写
HTML 不区分大小写,所以:
- 推荐:
@update-count、emit('update-count') - 不推荐:
@updateCount,在模板里可能被转成小写,导致监听不到
[⬆ 返回目录](#⬆ 返回目录)
4.4 Emit 规范小结
- 事件名:用 kebab-case
- payload :需要传参时,用对象
{ ... },便于扩展 - 职责:子组件只负责"触发 + 传参",业务逻辑尽量放在父组件
- v-model :理解成
modelValue+update:modelValue,多字段用v-model:xxx
[⬆ 返回目录](#⬆ 返回目录)
五、事件总线:跨组件通信,需谨慎
5.1 适用场景
- 兄弟组件之间
- 跨多层级(爷孙、更远)
- 全局提示、主题切换等
[⬆ 返回目录](#⬆ 返回目录)
5.2 Vue3 的推荐做法:mitt / tiny-emitter
Vue3 移除了 $on、$off、$emit,不再内置事件总线,推荐用 mitt 等库。
bash
npm install mitt
js
// utils/eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()
html
<!-- 组件 A:发布 -->
<script setup>
import { eventBus } from '@/utils/eventBus'
const send = () => {
eventBus.emit('user-login', { userId: 123 })
}
</script>
html
<!-- 组件 B:订阅 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'
const handler = (payload) => {
console.log('收到登录事件', payload)
}
onMounted(() => {
eventBus.on('user-login', handler)
})
onUnmounted(() => {
eventBus.off('user-login', handler) // 必须解绑!
})
</script>
[⬆ 返回目录](#⬆ 返回目录)
5.3 事件总线的坑
- 忘记 off:组件销毁后监听还在,可能内存泄漏、重复执行
- 事件满天飞:事件名不统一、到处 emit,难维护
- 数据流不清晰:谁发的、谁在听,不直观
[⬆ 返回目录](#⬆ 返回目录)
5.4 替代方案:provide / inject
跨层级传数据时,用 provide/inject 比事件总线更直观:
html
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
html
<!-- 任意子孙组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>
更适合:主题、语言、用户信息这类"向下传递的上下文"。
[⬆ 返回目录](#⬆ 返回目录)
5.5 事件总线使用规范
- 能 Props + Emit 解决的,优先用 Props + Emit
- 必须用事件总线时 :统一在
eventBus.js管理,事件名加前缀(如app:user-login) - 在 onUnmounted 中一定要 off
- 跨层级传数据 :优先考虑
provide/inject或 Pinia
[⬆ 返回目录](#⬆ 返回目录)
六、实战:一个完整案例
6.1 结构设计
ProductPage (父)
├── FilterBar (筛选) --- props: 无 / emit: filter-change
├── ProductList (列表) --- props: products, loading / emit: add-to-cart
└── CartSummary (摘要) --- props: cartCount
数据流:父组件管理 filter、products、cartCount,子组件只负责"触发事件"。
[⬆ 返回目录](#⬆ 返回目录)
6.2 完整代码示例
html
<!-- ProductPage.vue 父页面 -->
<template>
<div class="product-page">
<FilterBar @filter-change="handleFilterChange" />
<ProductList
:products="filteredProducts"
:loading="loading"
@add-to-cart="handleAddToCart"
/>
<CartSummary :cart-count="cartCount" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import FilterBar from './FilterBar.vue'
import ProductList from './ProductList.vue'
import CartSummary from './CartSummary.vue'
const filter = ref({ category: '', keyword: '' })
const products = ref([])
const cartCount = ref(0)
const loading = ref(false)
const filteredProducts = computed(() => {
let list = products.value
if (filter.value.category) {
list = list.filter(p => p.category === filter.value.category)
}
if (filter.value.keyword) {
list = list.filter(p => p.name.includes(filter.value.keyword))
}
return list
})
const handleFilterChange = (newFilter) => {
filter.value = { ...filter.value, ...newFilter }
}
const handleAddToCart = (productId) => {
cartCount.value++
// 实际项目中这里会调接口、更新购物车等
}
</script>
html
<!-- FilterBar.vue 筛选组件 -->
<template>
<div class="filter-bar">
<select v-model="localCategory" @change="emitFilter">
<option value="">全部分类</option>
<option value="电子">电子</option>
<option value="服饰">服饰</option>
</select>
<input v-model="localKeyword" placeholder="搜索" @input="emitFilter" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const emit = defineEmits(['filter-change'])
const localCategory = ref('')
const localKeyword = ref('')
const emitFilter = () => {
emit('filter-change', {
category: localCategory.value,
keyword: localKeyword.value
})
}
</script>
html
<!-- ProductList.vue 列表组件 -->
<template>
<div class="product-list">
<div v-if="loading">加载中...</div>
<div v-else v-for="p in products" :key="p.id" class="product-item">
<span>{{ p.name }}</span>
<button @click="emit('add-to-cart', p.id)">加入购物车</button>
</div>
</div>
</template>
<script setup>
defineProps({
products: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
})
defineEmits(['add-to-cart'])
</script>
html
<!-- CartSummary.vue 购物车摘要 -->
<template>
<div class="cart-summary">购物车:{{ cartCount }} 件</div>
</template>
<script setup>
defineProps({
cartCount: { type: Number, default: 0 }
})
</script>
[⬆ 返回目录](#⬆ 返回目录)
6.3 这样设计的好处
- 单向数据流:数据在父组件,子组件只读 props、只发事件
- 职责清晰:FilterBar 管筛选、ProductList 管展示和点击、CartSummary 管展示数量
- 易测试:每个组件可单独测 props 和 emit
- 易扩展:加新筛选项、新列表列,不影响其他组件
[⬆ 返回目录](#⬆ 返回目录)
七、选择决策速查
| 场景 | 推荐方式 | 说明 |
|---|---|---|
| 父传子数据 | Props | 单向、清晰 |
| 子通知父 | Emit | 事件驱动、解耦 |
| 兄弟组件 | 提升到共同父级用 Props+Emit,或事件总线 | 优先提升 state |
| 跨多层级传数据 | provide/inject | 比事件总线更适合上下文 |
| 跨多层级发事件 | 事件总线 或 Pinia | 事件总线要规范使用 |
| 全局状态 | Pinia | 官方推荐 |
[⬆ 返回目录](#⬆ 返回目录)
八、总结
- Props:父 → 子,只读,不直接修改
- Emit:子 → 父,用 kebab-case 事件名,payload 用对象
- 事件总线:跨组件时用,要记得 off,能不用尽量不用
- 核心原则:单向数据流、职责单一、显式通信
先把 Props 和 Emit 用熟,再按需使用 provide/inject 或事件总线,组件之间的关系会清晰很多,维护和排错都会更轻松。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 Vue 组件与模板规范
一、《Vue3 组件拆分实战规范:页面 / 业务 / 基础组件边界清晰化,高内聚低耦合落地指南|Vue 组件与模板规范篇》
二、《Vue3 Props 传参实战规范:必传校验 + 默认值 + 类型标注,避开 undefined / 类型混用坑|Vue 组件与模板规范篇》
三、《Vue3 模板语法规范实战:v-if/v-for 不混用 + 表达式精简,避坑指南|Vue 组件与模板规范篇》
四、《Vue3 样式实战:scoped + 深度选择器 + BEM 规范,解决冲突与穿透失效|Vue 组件与模板规范篇》
五、《Vue3 组合式函数(Hooks)封装规范实战:命名 / 输入输出 / 复用边界 + 避坑|Vue 组件与模板规范篇》
六、《Vue3 + Element Plus 中后台弹窗规范:开闭、传参、回调,告别弹窗地狱|Vue 组件与模板规范篇》
七、《Vue3 组件解耦实战:Props/Emit/ 事件总线用法 + 避坑指南|Vue 组件与模板规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~