Vue 组件通信
1 前言
在 Vue 开发中,组件化是核心理念之一。页面被拆分为一个个组件,组件之间必然需要传递数据、同步状态。而父子组件通信是最基础、最常见的通信场景------子组件挂载在父组件内部渲染,两者存在天然的层级关系:
# 父组件
Father.vue
│ # 子组件
└─Child.vue
Vue 生态提供了多种父子通信方案,从最基础的 props/emits,到全局状态管理 Pinia,各有适用场景。本文将逐一介绍 8 种方案,给出实际代码示例,并对它们的优缺点进行对比,最后解释为什么需要这么多种方案。
2 props / emits
这是 Vue 最基础、最标准的父子通信方式,Vue 官方推荐优先使用。
- 父 → 子 :通过
props向子组件传递数据。 - 子 → 父 :通过
emits触发父组件绑定的事件。
2.1 代码示例
vue
<!-- Father.vue -->
<template>
<div>
<h2>父组件</h2>
<p>当前计数:{{ count }}</p>
<!--
引入子组件并建立双向通信:
:value="count" (Props 向下传)
- 使用 v-bind 语法,将父组件的 count 数据绑定到子组件的 value 属性上。
- 子组件可以通过 defineProps 接收并使用这个值。
@increment="handleIncrement" (Events 向上发)
- 使用 v-on 语法,监听子组件触发的自定义事件 'increment'。
- 当子组件触发该事件时,自动执行父组件的 handleIncrement 方法。
-->
<Child :value="count" @increment="handleIncrement" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const count = ref(0)
/**
* 定义事件处理函数,用于接收子组件传上来的数据并更新父组件状态
* @param {Number} step - 子组件通过 $emit 传递过来的增量参数(如 1 或 5)
*/
function handleIncrement(step) {
count.value += step
}
</script>
vue
<!-- Child.vue -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>子组件</h3>
<!--
渲染从父组件接收到的 props 数据
这里的 value 对应下方 defineProps 中声明的属性
-->
<p>从父组件接收的值:{{ value }}</p>
<!--
按钮点击事件:通过 $emit 向父组件发送自定义事件
- 第一个参数:事件名称(必须与父组件 @ 后面监听的名称一致)
- 第二个参数:传递给父组件的数据(可选,这里传递了数字 1)
-->
<button @click="$emit('increment', 1)">+1</button>
<button @click="$emit('increment', 5)">+5</button>
</div>
</template>
<script setup>
/**
* defineProps 宏:用于声明当前组件期望接收的 props
* - 在 <script setup> 中无需 import,直接使用即可
* - 这里声明了 value 属性,并指定了类型校验规则
*/
defineProps({
value: { type: Number, required: true }
})
/**
* defineEmits 宏:用于声明当前组件会向外触发哪些自定义事件
* - 声明后,在模板中使用 $emit 或在 JS 中使用 emit() 时会有更好的类型提示
* - 这里声明了 'increment' 事件,告诉父组件:"我会发出这个事件,你可以监听"
*/
defineEmits(['increment'])
</script>
2.2 优缺点
| 优点 | 缺点 |
|---|---|
| 数据流向清晰(单向数据流),易于调试 | 仅适用于直接父子关系 |
| Vue 官方推荐,生态兼容最好 | 嵌套深时 props 逐层传递非常繁琐 |
| 组件解耦程度高,子组件不依赖父组件具体实现 | 兄弟组件通信需要借助共同父组件中转 |
2.3 单向数据流设计思想
Vue 官方明确规定:所有 props 都遵循单向数据流(One-Way Data Flow)。子组件绝对不能直接修改父组件传下来的 prop。
这种设计主要出于以下几个核心考虑:
- 保证数据流向的清晰与可预测性(核心原因)
如果允许子组件直接修改父组件的数据,那么当你的应用变得复杂时,数据修改的源头就会变得极其难以追踪。
- 场景假设 :假设你有 5 个子组件都接收了父组件的
count。如果它们都能直接修改count,当count突然发生变化时,你需要去这 5 个子组件里逐一排查到底是谁改的。 - 单向数据流的好处:现在的数据流向是:父组件 -> 子组件(通过 props),子组件 -> 父组件(通过 emit)。数据永远只在父组件内部被修改,你只需要看父组件的代码,就能完全掌控数据是怎么变化的。
- 防止意外的"副作用"(避免牵一发而动全身)
子组件通常是作为"通用组件"存在的,它不应该知道父组件的具体业务逻辑。
- 场景假设 :如果子组件内部直接修改了父组件的
count,而这个count同时也被父组件的其他子组件使用,或者触发了父组件的某个复杂计算属性。那么子组件的这一个小小改动,可能会在父组件中引发一连串意想不到的连锁反应(Bug)。 - 通过 emit 的好处 :子组件只是发出一个"意图"(我想加 1),而**"是否真的加 1"以及"加 1 之后还要做什么"**,完全由父组件来决定。父组件可以在
handleIncrement里加上各种校验逻辑,比如:"如果 count 超过 100,就不允许再加了"。 - 子组件只负责展示和交互,它不关心父组件的数据叫什么名字,也不关心父组件拿到事件后怎么处理。这使得子组件变得高度纯粹和可复用。
3 v-model / emits
Vue 3 中 v-model 是对 props + emits 的语法糖,适用于表单类组件的双向绑定场景。
- 父 → 子 :与 props 相同,Vue 3 支持多个
v-model绑定。 - 子 → 父 :通过
update:modelValue事件更新父组件数据。
3.1 代码示例
vue
<!-- Father.vue -->
<template>
<div>
<h2>父组件</h2>
<p>姓名:{{ name }}</p>
<p>年龄:{{ age }}</p>
<!--
1. 默认 v-model 语法糖:
Vue 编译器会将其自动展开为:
<NameInput :modelValue="name" @update:modelValue="name = $event" />
- 向下传:把 name 的值传给子组件的 modelValue 属性
- 向上绑:监听子组件发出的 update:modelValue 事件,并用新值更新 name
-->
<NameInput v-model="name" />
<!--
2. Vue 3 带参数的 v-model(多 v-model 绑定):
Vue 编译器会将其自动展开为:
<AgeInput :age="age" @update:age="age = $event" />
- 向下传:把 age 的值传给子组件的 age 属性(不再是默认的 modelValue)
- 向上绑:监听子组件发出的 update:age 事件,并用新值更新 age
-->
<AgeInput v-model:age="age" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import NameInput from './NameInput.vue'
import AgeInput from './AgeInput.vue'
const name = ref('张三')
const age = ref(25)
</script>
vue
<!-- NameInput.vue -->
<template>
<div>
<h3>姓名输入</h3>
<!--
6. 手动实现双向绑定:
- :value="modelValue":将父组件传来的 prop 绑定到 input 的显示值上
- @input:监听用户的输入事件
- $emit('update:modelValue', ...):触发父组件监听的更新事件,
并将输入框的最新值 ($event.target.value) 传递给父组件
-->
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
placeholder="请输入姓名"
/>
</div>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
vue
<!-- AgeInput.vue -->
<template>
<div>
<h3>年龄输入</h3>
<!--
9. 手动实现带参数的双向绑定:
- :value="age":因为父组件用的是 v-model:age,所以这里的 prop 名称是 age
- @input:监听输入事件
- $emit('update:age', ...):触发 update:age 事件,
注意这里使用了 Number() 将字符串强转为数字,保证数据类型与父组件一致
-->
<input
type="number"
:value="age"
@input="$emit('update:age', Number($event.target.value))"
/>
</div>
</template>
<script setup>
// 10. 声明接收的 prop 为 age(对应父组件 v-model:age 的参数名)
defineProps(['age'])
// 11. 声明触发的更新事件为 update:age(对应父组件 v-model:age 的约定事件名)
defineEmits(['update:age'])
</script>
3.2 优缺点
| 优点 | 缺点 |
|---|---|
语法简洁,v-model 一行搞定双向绑定 |
本质还是 props + emits,仅多了语法糖 |
| Vue 3 支持多个 v-model,一个子组件可同时绑定多个值 | 不适合非表单类的复杂数据传递 |
| 自定义组件也能像原生表单元素一样使用 | 仍然是父子一对一通信 |
3.3 和props的区别
Props(单向数据流):数据只能从父组件流向子组件。子组件接收到 props 后,只能"看"和"用",绝对不能直接修改它14。如果子组件想改变数据,必须通过 $emit 触发事件,由父组件去修改11。
v-model(双向绑定):数据在父组件和子组件之间双向同步。当父组件的数据改变时,子组件的视图会自动更新;反之,当用户在子组件(如表单输入框)中修改了值,父组件的数据也会自动同步更新
Props:适用于单向展示或配置场景。比如父组件向子组件传递标题、列表数据、布尔值(控制显隐)等。
v-model:专为表单类控件设计。常用于 、、 等需要频繁与用户交互、且需要将用户输入的值实时同步到数据模型中的场景
<script setup>
import { ref } from 'vue'
const username = ref('')
</script>
<template>
<!-- 用户输入 " admin ",username 的值会自动变成 "admin" -->
<input v-model.trim="username" placeholder="请输入用户名" />
<p>当前用户名:[{{ username }}]</p>
</template>
底层原理 :Vue 会在每次更新数据前,自动调用字符串的 .trim() 方法,把首尾的空格、换行符等空白字符剔除。
4 ref / emits
通过 ref 获取子组件实例,父组件可以直接调用子组件暴露的方法或访问其数据。
- 父 → 子:通过模板 ref 拿到子组件实例,调用其暴露的方法。
- 子 → 父:子组件仍通过 emits 向父组件通知变化(或用 expose 暴露回调)。
4.1 代码示例
vue
<!-- Father.vue -->
<template>
<div>
<h2>父组件</h2>
<button @click="resetChild">重置子组件</button>
<button @click="getChildData">获取子组件数据</button>
<p>子组件消息:{{ childMsg }}</p>
<Child ref="childRef" @submit="handleSubmit" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref(null)
const childMsg = ref('')
function resetChild() {
childRef.value.reset()
}
function getChildData() {
childMsg.value = childRef.value.getFormData()
}
function handleSubmit(data) {
console.log('子组件提交:', data)
}
</script>
vue
<!-- Child.vue -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>子组件</h3>
<input v-model="inputVal" placeholder="说点什么..." />
<button @click="submit">提交</button>
<p>内部值:{{ inputVal }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const inputVal = ref('')
const emit = defineEmits(['submit'])
// 通过 defineExpose 暴露给父组件的方法和数据
function reset() {
inputVal.value = ''
}
function getFormData() {
return inputVal.value
}
function submit() {
emit('submit', inputVal.value)
}
defineExpose({ reset, getFormData })
</script>
4.2 优缺点
| 优点 | 缺点 |
|---|---|
| 父组件可以精确控制子组件的行为(调用方法) | 父组件与子组件强耦合,需要知道子组件的内部方法名 |
| 适合表单重置、聚焦等命令式操作 | 破坏了数据驱动的理念,可维护性下降 |
| 无需在父组件维护额外状态来同步 | 子组件对父组件不透明,重构成本高 |
5 provide / inject
适用于跨层级 的数据传递,祖辈组件通过 provide 提供数据,任意深度的后代组件通过 inject 获取,中间组件无需中转。
- 父(祖)→ 子(孙) :通过
provide下发数据。 - 子(孙)→ 父(祖) :通过
provide下发一个修改函数,后代调用该函数修改数据。
5.1 代码示例
vue
<!-- Father.vue -->
<template>
<div>
<h2>父组件(祖先)</h2>
<p>主题颜色:{{ theme }}</p>
<p>用户信息:{{ user.name }},{{ user.age }}岁</p>
<button @click="toggleTheme">切换主题</button>
<Child />
</div>
</template>
<script setup>
import { ref, provide, readonly } from 'vue'
import Child from './Child.vue'
const theme = ref('light')
const user = ref({ name: '李四', age: 30 })
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function updateUser(data) {
user.value = { ...user.value, ...data }
}
// provide 可传静态值、ref、响应式对象
provide('theme', readonly(theme)) // readonly 防止后代直接修改
provide('user', user)
provide('updateUser', updateUser) // 提供修改函数,实现孙→祖通信
</script>
vue
<!-- Child.vue(中间组件 - 不需要接收和传递任何 props) -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>子组件(中间层)</h3>
<p>主题:{{ theme }}</p>
<GrandChild />
</div>
</template>
<script setup>
import { inject } from 'vue'
import GrandChild from './GrandChild.vue'
const theme = inject('theme')
</script>
vue
<!-- GrandChild.vue -->
<template>
<div style="border: 1px dashed #ccc; padding: 10px; margin-top: 10px">
<h3>孙子组件</h3>
<p>主题:{{ theme }}</p>
<p>用户:{{ user.name }},{{ user.age }}岁</p>
<button @click="changeAge">修改年龄(孙→祖)</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const user = inject('user')
const updateUser = inject('updateUser')
function changeAge() {
updateUser({ age: 35 }) // 孙子直接调用祖先提供的函数
}
</script>
5.2 优缺点
| 优点 | 缺点 |
|---|---|
| 跨任意层级传递,消除 prop drilling | 数据来源不直观,调试困难(不知道谁 provide 的) |
| 中间组件无需感知和转发 | 类型安全差,inject 的 key 是字符串,易拼写错误 |
| 适合全局配置(主题、语言、权限) | 响应式数据被多处注入时,追踪变更来源困难 |
| 配合 Symbol 做 key 可避免命名冲突 | 滥用会使组件间的数据流变得隐晦 |
6 EventBus
EventBus 是一种发布-订阅模式 ,通过一个中央事件总线来实现任意组件间的通信。Vue 3 中官方已移除了 $on/$off/$emit 实例方法 ,需要借助第三方库(如 mitt)实现。
- 任何组件都可以
emit事件到总线上,也可以on监听总线上的事件。
6.1 代码示例
js
// eventBus.js ------ 用 mitt 创建事件总线
import mitt from 'mitt'
export const bus = mitt()
vue
<!-- Father.vue -->
<template>
<div>
<h2>父组件</h2>
<p>最新通知:{{ notice }}</p>
<ChildA />
<ChildB />
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { bus } from './eventBus'
import ChildA from './ChildA.vue'
import ChildB from './ChildB.vue'
const notice = ref('暂无')
// 通过回调方式传递参数
function handleNotice(text) {
notice.value = text
}
onMounted(() => {
bus.on('notice', handleNotice)
})
onBeforeUnmount(() => {
bus.off('notice', handleNotice) // 必须手动解绑,避免内存泄漏
})
</script>
vue
<!-- ChildA.vue -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>子组件 A</h3>
<button @click="sendNotice('A 发来消息:任务完成')">发送通知</button>
</div>
</template>
<script setup>
import { bus } from './eventBus'
function sendNotice(text) {
bus.emit('notice', text)
}
</script>
vue
<!-- ChildB.vue ------ 同样可以 emit 或 on -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>子组件 B</h3>
<button @click="sendNotice('B 发来消息:数据已更新')">发送通知</button>
</div>
</template>
<script setup>
import { bus } from './eventBus'
function sendNotice(text) {
bus.emit('notice', text)
}
</script>
6.2 优缺点
| 优点 | 缺点 |
|---|---|
| 任意组件之间直接通信,无需层级关系 | 事件名是字符串,容易拼写错误且无编译检查 |
| 使用简单,少量事件时非常灵活 | 大量使用后事件流向难以追踪,调试噩梦 |
| 组件完全解耦,不需要互相引用 | 必须手动在 onBeforeUnmount 中解绑,否则内存泄漏 |
| 适合跨兄弟组件、跨路由的简单通信 | Vue 3 不再内置支持,需要额外依赖 |
7 Pinia
Pinia 是 Vue 3 官方推荐的新一代状态管理库,也是 Vuex 的精神续作。它放弃了 Mutation 概念,API 更简洁,对 TypeScript 支持极好。
- 组件通过
useXxxStore()获取 store 实例,直接读写状态或调用 actions。
7.1 代码示例
js
// stores/counter.js ------ 使用 Pinia 定义的全局状态仓库
// 1. 从 pinia 中引入 defineStore 宏,从 vue 中引入响应式 API
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
/**
* 2. 【核心】定义并导出 Store
* - 第一个参数 'counter':Store 的唯一 ID(类似数据库的主键)
* - 第二个参数:一个 Setup 函数(与 Vue 组件的 <script setup> 写法完全一致)
*/
export const useCounterStore = defineStore('counter', () => {
// ========== State(状态) ==========
// 3. 直接使用 ref 定义响应式变量,Pinia 会自动将其识别为全局状态
const count = ref(0)
// ========== Getter(获取器/派生状态) ==========
// 4. 直接使用 computed 定义计算属性,当 count 变化时,double 会自动重新计算
const double = computed(() => count.value * 2)
// ========== Action(动作) ==========
// 5. 定义普通的同步方法,【可以直接修改 state】,无需像 Vuex 那样提交 Mutation
function increment(n = 1) {
count.value += n
}
function decrement(n = 1) {
count.value -= n
}
// 6. 定义异步方法(如请求接口获取数据)
// 在 Pinia 中,异步操作和同步修改都放在 Action 中,逻辑更加内聚
async function fetchAndSet() {
const res = await fetch('/api/count').then(r => r.json())
count.value = res.value // 直接赋值,Pinia 会自动追踪并触发视图更新
}
// 7. 【必须】将状态、计算属性和方法统一 return 出去,供外部组件使用
return { count, double, increment, decrement, fetchAndSet }
})
vue
<!-- Father.vue (父组件 - 布局与数据读取) -->
<template>
<div>
<h2>父组件</h2>
<!--
8. 直接在模板中通过 counter.xxx 读取全局状态和计算属性
因为 Pinia 的状态是完全响应式的,所以这里的展示会自动更新
-->
<p>计数:{{ counter.count }} | 翻倍:{{ counter.double }}</p>
<!-- 引入子组件,无需传递任何 props -->
<CounterControl />
<CounterDisplay />
</div>
</template>
<script setup>
// 9. 导入刚才定义的 Store Hook 函数
import { useCounterStore } from './stores/counter'
import CounterControl from './CounterControl.vue'
import CounterDisplay from './CounterDisplay.vue'
// 10. 【核心】调用 Hook 获取全局 Store 实例
// 任何组件只要调用这个函数,拿到的都是同一个单例对象
const counter = useCounterStore()
</script>
vue
<!-- CounterControl.vue(子组件 - 数据的修改者) -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>计数器控制(子组件)</h3>
<!--
11. 直接调用 Store 暴露的方法来修改全局数据
-->
<button @click="counter.increment()">+1</button>
<button @click="counter.decrement()">-1</button>
<button @click="counter.increment(5)">+5</button>
</div>
</template>
<script setup>
// 12. 导入并实例化 Store
import { useCounterStore } from './stores/counter'
const counter = useCounterStore()
</script>
vue
<!-- CounterDisplay.vue(子组件 - 数据的消费者) -->
<template>
<div style="border: 1px solid #ccc; padding: 10px; margin-top: 10px">
<h3>计数器展示(子组件)</h3>
<!--
13. 读取全局状态
当 CounterControl 组件中调用了 increment 方法后,
因为底层是 Vue 的响应式系统,这里的视图会自动、实时地刷新
-->
<p>当前值:{{ counter.count }}</p>
<p>翻倍值:{{ counter.double }}</p>
</div>
</template>
<script setup>
// 14. 导入并实例化 Store(与父组件和控制组件拿到的是同一个实例)
import { useCounterStore } from './stores/counter'
const counter = useCounterStore()
</script>
7.2 优缺点
- 选择 Pinia :当你需要共享和管理数据时(例如:购物车数据、用户登录状态、全局主题配置、复杂的表单状态)。你需要知道数据"现在是什么"以及"它是怎么变成这样的"。
- 选择 EventBus :当你只需要发送一个动作或通知,且不需要关心数据状态时(例如:全局消息提示、触发某个不相关的组件刷新、关闭全局弹窗)。你只关心"发生了一件事",不关心"数据变成了什么"。
一句话比喻 :
Pinia 就像是公司的中央数据库和规章制度 ,所有数据的存取都要留痕;
EventBus 就像是公司里的大喇叭广播,喊一嗓子通知大家有事发生,但广播完就散了,没人记录。
8 为什么会有这么多种方案?
看到这里,你可能会困惑:不就是父子传个数据吗,为什么要搞出 8 种方案?
答案是:不同的场景需要不同的复杂度。没有银弹,只有最合适的选择。
8.1 各方案的定位
| 方案 | 适用场景 |
|---|---|
| props / emits | 直接父子关系,数据流简单明确 |
| v-model | 表单类组件,需要双向绑定 |
| ref / emits | 父组件需要命令式调用子组件方法 |
| provide / inject | 跨层级传递(主题、语言、用户信息) |
| EventBus | 任意组件间的松耦合通信 |
| Pinia | 新项目首选状态管理 |
8.2 选择原则
- 能用 props/emits 解决的,绝不上状态管理库。 这是 Vue 的核心设计,简单、直观、可预测。
- 跨层级不超过 2 层,仍然用 props 透传。 如果只是爷爷传孙子,透传一次完全可以接受。
- 跨 3 层以上,或数据被多处消费,考虑 provide/inject。 比如全局主题、国际化、用户权限。
- 需要共享的状态是"应用级别"的(如购物车、登录态),上 Pinia。 它给你 devtools、持久化、模块化,这些不是"多余"的,而是大项目必需的保障。
8.3 核心矛盾:灵活 vs 可控
这 8 种方案本质上是在灵活性 和可控性之间做权衡:
- props/emits:灵活性最低,但数据流最清晰,谁传的、谁改的一目了然。
- **EventBus **:灵活性最高,任意组件都能通信,但数据流完全失控,调试困难。
- Pinia:在灵活和可控之间找到了工程化的平衡,有规范约束,但不至于繁琐。