Vue3 组件解耦实战:Props/Emit/ 事件总线用法 + 避坑指南|Vue 组件与模板规范篇

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

📑 文章目录

同学们好,我是 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 规范小结

  1. 命名:父模板用 kebab-case,子组件定义用 camelCase
  2. 类型 :尽量用对象式 defineProps,写明 typedefaultrequired
  3. 禁止:子组件直接修改 props
  4. 复杂数据:注意引用共享,必要时传副本或做深拷贝

[⬆ 返回目录](#⬆ 返回目录)


四、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:submitupdate: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 / contentpropsupdate:titleupdate:content 事件。

[⬆ 返回目录](#⬆ 返回目录)

4.3 坑:事件名大小写

HTML 不区分大小写,所以:

  • 推荐:@update-countemit('update-count')
  • 不推荐:@updateCount,在模板里可能被转成小写,导致监听不到

[⬆ 返回目录](#⬆ 返回目录)

4.4 Emit 规范小结

  1. 事件名:用 kebab-case
  2. payload :需要传参时,用对象 { ... },便于扩展
  3. 职责:子组件只负责"触发 + 传参",业务逻辑尽量放在父组件
  4. 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 事件总线的坑

  1. 忘记 off:组件销毁后监听还在,可能内存泄漏、重复执行
  2. 事件满天飞:事件名不统一、到处 emit,难维护
  3. 数据流不清晰:谁发的、谁在听,不直观

[⬆ 返回目录](#⬆ 返回目录)

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 事件总线使用规范

  1. 能 Props + Emit 解决的,优先用 Props + Emit
  2. 必须用事件总线时 :统一在 eventBus.js 管理,事件名加前缀(如 app:user-login
  3. 在 onUnmounted 中一定要 off
  4. 跨层级传数据 :优先考虑 provide/inject 或 Pinia

[⬆ 返回目录](#⬆ 返回目录)


六、实战:一个完整案例

6.1 结构设计

复制代码
ProductPage (父)
├── FilterBar (筛选) --- props: 无 / emit: filter-change
├── ProductList (列表) --- props: products, loading / emit: add-to-cart
└── CartSummary (摘要) --- props: cartCount

数据流:父组件管理 filterproductscartCount,子组件只负责"触发事件"。

[⬆ 返回目录](#⬆ 返回目录)

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 这样设计的好处

  1. 单向数据流:数据在父组件,子组件只读 props、只发事件
  2. 职责清晰:FilterBar 管筛选、ProductList 管展示和点击、CartSummary 管展示数量
  3. 易测试:每个组件可单独测 props 和 emit
  4. 易扩展:加新筛选项、新列表列,不影响其他组件

[⬆ 返回目录](#⬆ 返回目录)


七、选择决策速查

场景 推荐方式 说明
父传子数据 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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
Cache技术分享1 小时前
360. Java IO API - 访问文件系统
前端·后端
计算机学姐2 小时前
基于SpringBoot的校园二手交易系统
java·vue.js·spring boot·后端·spring·tomcat·intellij-idea
小璐资源网2 小时前
CSS进阶指南:深入解析选择器优先级与继承机制
前端·css
工边页字2 小时前
为什么 RAG系统里,Embedding成本往往远低于 LLM成本,但很多公司仍然疯狂优化 Embedding?
前端·人工智能·后端
墨渊君2 小时前
OpenClaw 上手实践: 使用 Docker 从构建到可用全流程指南
前端·agent
冰暮流星2 小时前
javascript之回调函数
开发语言·前端·javascript
米丘2 小时前
Rollup 打包工具
前端
We་ct2 小时前
LeetCode 74. 搜索二维矩阵:两种高效解题思路
前端·算法·leetcode·矩阵·typescript·二分查找
moneyinto2 小时前
Three.js 必背核心方法
前端