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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi8 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu10 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss