×

Vue 组件通信 8 种方式:从 Props 到 Pinia 的工程化选型

独孤求败 独孤求败 发表于2026-05-27 09:04:01 浏览15 评论0

抢沙发发表评论

含 provide/inject、EventBus、Pinia/Vuex:看场景选方案,少走弯路

很多 Vue 项目一开始只是父子传值。  页面越拆越细之后,问题就来了:一个字段要穿过五六层组件;一个弹窗开关,不知道是谁触发;一个全局状态,既在 props 里传,又在 bus 里发,还在 store 里存。  问题往往不在于 Vue 通信方式不够多,而在于没有按场景选方案。  本文把 8 种常见通信方式放到同一张工程视角里看:Props、Emits、Provide/Inject、EventBus、Pinia/Vuex、$attrs、$refs/$parent、Slot。  重点不是背 API,而是判断:什么时候该用,什么时候别用。

通信方式的选择,本质是架构边界的选择。

1. Props & Emits:父子通信的默认答案

Props 向下传数据,Emits 向上通知变化。

这是 Vue 最基础、也最推荐的父子通信方式。

它的优点是数据流清晰:父组件负责状态,子组件负责展示和触发事件。

问题也常常出在这里:props 不写类型,emit 不声明事件,子组件直接改 props。

这些写法短期省事,长期会让组件变得不可预测。

父子通信的核心原则:子组件不要直接修改 props。

如果子组件需要改变值,应该 emit 事件,让父组件更新。

vue

<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true,
    validator: (value: string) => value.length <= 20
  },
  count: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits<{
  (e: 'update:count', value: number): void
  (e: 'submit', payload: { title: string }): void
}>()

function add() {
  emit('update:count', props.count + 1)
}
</script>

<template>
  <button @click="add">{{ title }}:{{ count }}</button>
</template>

父组件使用:

vue

<CounterCard
  title="库存"
  v-model:count="stock"
  @submit="handleSubmit"
/>

这里的 `v-model:count`,本质就是 `:count + @update:count`。

工程建议:

- 父子关系明确时,优先使用 Props/Emits。

- props 一定写类型,复杂值建议配合 TypeScript

- emit 事件名要稳定,不要随意改。

- 子组件只表达“发生了什么”,不要替父组件决定业务。

2. Provide/Inject:跨级传值,不必层层转发

当组件层级很深时,Props 会变得痛苦。

例如:页面组件有用户信息,真正使用它的是第五层按钮。中间几层组件并不关心这个数据,却必须一层层传下去。

这就是典型的 props drilling。

Provide/Inject 就是为跨级传值准备的。

祖先组件 provide,后代组件 inject,中间组件不需要参与。

vue

<!-- Parent.vue -->
<script setup>
import { ref, computed, provide } from 'vue'

const user = ref({ name: 'Ada', role: 'admin' })

provide('currentUser', computed(() => user.value))
</script>

vue

<!-- DeepChild.vue -->
<script setup>
import { inject, type ComputedRef } from 'vue'

const currentUser = inject<ComputedRef<{ name: string; role: string }>>(
  'currentUser'
)

if (!currentUser) {
  throw new Error('currentUser is not provided')
}
</script>

<template>
  <span>{{ currentUser.value.name }}</span>
</template>

如果不希望报错,可以提供默认值:

ts

const theme = inject('theme', 'light')

Provide/Inject 适合“稳定、低频、跨层级”的上下文数据。

比如主题、语言、表单上下文、用户基础信息、权限上下文。

但它也有代价:数据来源不如 props 直观。

组件看起来没有入参,实际却依赖了上层注入。

所以,不要把所有业务状态都塞进 provide。

工程建议:

- provide 响应式数据时,使用 `ref`、`reactive` 或 `computed`。

- inject 必须处理默认值或异常情况。

- key 建议使用 Symbol,避免字符串冲突。

- 高频业务状态优先考虑 Pinia。

3. EventBus:能解耦,也容易失控

EventBus 的思路很简单:所有组件都往一个事件中心发消息,也从这个事件中心监听消息。

它能让任意组件通信,尤其适合兄弟组件、非直接关系组件。

Vue 2 时代,很多人用一个 Vue 实例做 bus。

Vue 3 不再推荐这种方式,一般使用 `mitt`。

安装:

bash

npm i mitt

创建 bus:

ts

// eventBus.ts
import mitt from 'mitt'

type Events = {
  'modal:open': { id: string }
  'user:refresh': void
}

export const eventBus = mitt<Events>()

组件 A 触发:

ts

import { eventBus } from './eventBus'

eventBus.emit('modal:open', { id: 'create-user' })

组件 B 监听:

ts

import { onMounted, onUnmounted } from 'vue'
import { eventBus } from './eventBus'

function handleOpen(payload: { id: string }) {
  console.log(payload.id)
}

onMounted(() => {
  eventBus.on('modal:open', handleOpen)
})

onUnmounted(() => {
  eventBus.off('modal:open', handleOpen)
})

这里最容易踩的坑,是忘记 `off`。

组件反复挂载后,监听器会重复注册,导致事件触发多次,甚至内存泄漏。

EventBus 最大的问题不是不能用,而是难追踪。

你很难从代码里一眼看出:事件是谁发的,谁接的,数据流向哪里。

工程建议:

- 小项目、低频事件可以用。

- 弹窗打开、全局提示、一次性通知可以考虑。

- 复杂业务状态不要用 EventBus 管。

- 事件名统一命名,最好有类型约束。

- 必须在卸载时移除监听。

4. Pinia vs Vuex:全局状态要可追踪

当状态被多个页面、多个组件共享时,就应该考虑状态管理。

Vue 3 里,优先选择 Pinia。

相比 Vuex,Pinia 概念更少:没有 mutations,天然支持模块化,TypeScript 体验也更自然。

一个基础 store:

ts

// stores/user.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    profile: null as null | { name: string; role: string }
  }),
  getters: {
    isLogin: (state) => Boolean(state.token)
  },
  actions: {
    setToken(token: string) {
      this.token = token
    },
    async fetchProfile() {
      // const res = await api.getUser()
      this.profile = { name: 'Ada', role: 'admin' }
    }
  }
})

组件中使用:

vue

<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const { token, profile, isLogin } = storeToRefs(userStore)

function logout() {
  userStore.setToken('')
}
</script>

注意一个常见坑:

ts

const { token } = userStore // 可能丢失响应性

解构 store 的 state/getters 时,用 `storeToRefs`。

actions 可以直接解构,因为它们已经绑定好上下文。

全局共享、可追踪、需要调试的业务状态,交给 Pinia。

适合放进 Pinia 的数据:

- 用户登录态。

- 权限信息。

- 多页面共享筛选条件。

- 购物车。

- 全局配置。

不适合放进 Pinia 的数据:

- 单个组件内部开关。

- 只在父子之间使用的临时字段。

- 表单局部输入过程状态。

Vuex 在老项目里仍然常见。

新项目如果没有历史包袱,建议直接 Pinia。

5. $attrs、$refs、$parent、Slot:特殊通道要少而准

除了常规方案,Vue 还有一些“特殊通道”。

它们不是主力通信方式,但在特定场景很有用。

先说 `$attrs`。

它适合封装高阶组件。

比如你封装了一个 `BaseInput`,希望父组件传入的原生属性继续透传到底层 input。

vue

<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <label>
    <span>名称</span>
    <input v-bind="$attrs" />
  </label>
</template>

再说 `$refs`。

它可以让父组件直接调用子组件方法。

vue

<!-- Child.vue -->
<script setup>
function focus() {
  // do something
}

defineExpose({ focus })
</script>

vue

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const childRef = ref<InstanceType<typeof Child> | null>(null)

function handleClick() {
  childRef.value?.focus()
}
</script>

<template>
  <Child ref="childRef" />
</template>

`$refs` 适合调用明确的命令式能力,比如 focus、reset、open。

但不要用它读取子组件内部状态。

`$parent` 可以访问父实例,但强耦合非常严重,基本不建议在业务代码里使用。

Slot 则是另一种优雅的通信方式。

子组件通过作用域插槽把数据暴露给父组件,由父组件决定如何渲染。

vue

<!-- ListProvider.vue -->
<script setup>
const list = [
  { id: 1, name: 'Vue' },
  { id: 2, name: 'Pinia' }
]
</script>

<template>
  <slot :list="list" />
</template>

vue

<!-- Parent.vue -->
<ListProvider v-slot="{ list }">
  <ul>
    <li v-for="item in list" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</ListProvider>

这些方式适合边界场景,不适合作为主通信模型。

6. 选型指南:不是都会用,而是会取舍

真正的工程能力,不是知道 8 种方式,而是知道什么时候不用某一种。

可以按下面这张表判断:

| 通信方式 | 适用关系 | 优点 | 风险 | 推荐级别 |

|---|---|---|---|---|

| Props | 父传子 | 显式、简单、可维护 | 层级深会冗余 | 高 |

| Emits | 子传父 | 数据流清晰 | 事件设计混乱会难维护 | 高 |

| Provide/Inject | 跨级组件 | 避免层层传递 | 依赖隐式化 | 中高 |

| EventBus | 任意组件 | 快速、灵活 | 难追踪、易泄漏 | 低中 |

| Pinia | 全局/跨页面 | 可追踪、可调试 | 滥用会全局污染 | 高 |

| Vuex | 老项目全局状态 | 生态成熟 | 样板代码多 | 中 |

| $attrs | 包装组件透传 | 封装友好 | 属性来源不明显 | 中 |

| $refs/$parent | 命令式调用 | 直接 | 强耦合 | 低 |

| Slot | 子向父暴露渲染数据 | 灵活、解耦展示 | 嵌套复杂会难读 | 中高 |

简单记:

父子用 Props/Emits。

跨级上下文用 Provide/Inject。

全局业务状态用 Pinia。

命令式能力用 refs。

事件通知慎用 EventBus。

架构上还要看三个维度。

第一,数据生命周期。

如果数据只存在于一个组件,就不要提升到全局。

第二,数据影响范围。

如果多个页面都依赖,Pinia 比 props 更合适。

第三,调试成本。

如果一个状态变化需要排查链路,显式方案优先。

通信方式越隐式,越要控制使用范围。

父子通信不要绕路,全局状态不要硬传。
能显式表达数据流,就不要依赖隐式魔法。

组件通信不是“会几种 API”就够了,关键是根据组件关系、数据生命周期和维护成本做选择。  父子优先 Props/Emits,跨级少量共享用 Provide/Inject,全局业务状态交给 Pinia。  EventBus、$refs、$parent 这类方案可以用,但要克制,只放在边界场景。把通信边界设计清楚,后续迭代才不会被隐式依赖拖垮。如果你正在整理 Vue 项目的组件通信方案,欢迎关注,后面继续聊更多 Vue 工程化实践。


群贤毕至

访客