overreactedby Swanine

别拿“父传子、子传父”吓我了

December 11, 2023

我们从小就被告诫,组件之间要“数据单向流动”。Vue 也这样教我们: 父组件通过 props 给子组件传值,子组件通过 $emit 通知父组件某个事件发生了

但你写久了之后就会发现: 这事儿根本没那么简单。

场景一复杂,层级一多,Vue 的组件通信就会逐渐从“清水煮白菜”变成了“七大姑八大姨串门”——一个 props 传了五层,一个 emit 被转发三手,一个兄弟组件用全局事件乱吼……

别慌,其实这些情况很常见,只是我们以前在学组件通信的时候,太过理想化了。

这篇文章我就想聊聊:Vue 组件通信这回事,应该怎么看、怎么写、怎么不把自己写崩。


🧭 组件通信的五大派别(别背 API,先理解思路)

Vue 组件通信,其实就这几个套路,每一种都有适合它的“场景生态位”:

通信方式适合场景特点
props / emit父子之间单向传值、简单直白
provide / inject跨层级的祖孙关系解耦、隐式依赖
事件总线(eventBus)松耦合兄弟、跨组件灵活但容易失控
Vuex / Pinia跨页面、跨组件、共享状态官方推荐、适合复杂项目
自定义 hooks / composables可复用状态逻辑灵活组合、自带响应性

这些方式没有对错,关键看你项目的规模和数据流动的复杂度

🧒 父传子、子传父:新手村里的套路,也有进阶写法

Vue 最基础的通信方式就是 props$emit,学起来简单,但写起来其实也能很“优雅”。

✅ 正常写法:

<!-- 父组件 -->
<MyInput :value="username" @update="username = $event" />
 
<!-- 子组件 -->
<template>
  <input :value="value" @input="$emit('update', $event.target.value)" />
</template>
 
<script setup>
defineProps(['value'])
defineEmits(['update'])
</script>

这就是我们常说的“受控组件”:父组件管控数据,子组件通知变化。

🚀 更进一步:v-model 语法糖

<!-- 父组件 -->
<MyInput v-model="username" />

你以为这是语法糖,其实底层就是 :modelValue + @update:modelValue,Vue 帮你自动做了参数和事件名的约定而已。

写起来舒服多了,也方便统一封装一堆表单类组件。


👴 provide / inject:爷爷给孙子发钱,谁也别告诉爸爸

当你 props 传了三四层,组件都在当“中转站”时,就该考虑换个思路了。

Vue 提供了 provide / inject,可以让祖先组件向任意后代组件提供数据,后代随用随取,不需要层层传递。

// App.vue
provide('theme', 'dark')
 
// Button.vue(第5层)
const theme = inject('theme')

这套机制适合“全局但不常变的数据”,比如主题、语言、配置项等等。

❗ 提醒:虽然它很方便,但用多了容易让组件间关系变得隐蔽,阅读成本高。所以更适合“结构性通信”,不太适合“业务值通信”。


🤝 兄弟组件通信:你俩又不认识,别硬聊!

兄弟组件不能直接用 props/emit,但有时偏偏要互动,比如:

  • 左边的侧边栏点击切换,右边内容区要响应
  • 表格和分页是兄弟组件,共享查询参数

这时有几种方式可以解耦:

✅ 共享父组件作为“中介人”

父组件统一管理状态,两个兄弟通过 propsemit 各自接入。

💣 用 eventBus 快速接上(但别上瘾)

// eventBus.js
export const eventBus = mitt();
 
// A组件
eventBus.emit('search', '关键词')
 
// B组件
eventBus.on('search', (val) => { doSearch(val) })

这方式快捷灵活,但容易到处注册/解绑,特别容易内存泄漏 or 逻辑混乱。建议小项目或 demo 里用用,别做核心依赖。


🏢 Pinia / Vuex:让组件都变成“打工人”

如果一个状态不仅要被多个组件使用,还要长期存在(比如用户信息、购物车数据、权限表),这时候你就该上 Pinia 了。

它其实是一个“响应式的数据工厂”:你在里面定义状态、动作,组件从中取用和更新,一切都响应式同步。

// store/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '小张', token: '' }),
  actions: {
    logout() { this.token = '' }
  }
})
<script setup>
const user = useUserStore()
user.name // 响应式的
</script>

现在 Vue 的状态管理,已经越来越模块化、组合化,Pinia 比 Vuex 更轻量,语法也更现代。


🧩 自定义 composable:组件通信之外的终极解耦武器

最后介绍一个高手段:提取状态和逻辑到 useXXX() ** composable 函数中**,多个组件使用同一个逻辑,就天然“通信”了。

// useCounter.js
import { ref } from 'vue'
 
const count = ref(0)
export function useCounter() {
  return {
    count,
    inc: () => count.value++
  }
}
<script setup>
const { count, inc } = useCounter()
</script>

多个组件引入 useCounter(),他们看到的是同一个 count 实例,就天然共享状态了。

这是一种“状态即服务”的思路,非常适合复杂场景下的组件复用。


  简单 ➝ ➝ ➝ ➝ ➝ ➝ ➝ ➝ ➝ 复杂
[props/emit] → [provide/inject] → [eventBus] → [Pinia] → [composables]
   ⬆️适合父子     ⬆️适合祖孙     ⬆️松耦合     ⬆️全局状态     ⬆️抽象逻辑

别再纠结“组件通信怎么选”了。选什么方式其实只取决于两件事:

  • 谁要通信?
  • 状态在哪维护、谁能控制?

把这两个问题答清楚,通信方式自然水落石出。

🐾 最后的叮咛

Vue 的组件通信不复杂,复杂的是“业务本身的耦合”。

你与其追求“通信方式要高级”,不如先让你的状态更清晰、结构更合理。通信只是表象,本质上我们是想让状态保持“合理流动”。

用对方式,就像选对交通工具:近就走路,远就开车,再远就坐高铁。别拿走路去赶高铁,别拿高铁去串门。


Discuss on Bluesky  ·  Edit on GitHub