前端学习笔记-vue组件通信常用方式
在 Vue 中如何管理组件之间的通信?
答案:组件之间的通信可以通过父组件和子组件的 Props 和 Events、事件 Bus、Vuex 以及 Vue 3 的 Provide 和 Inject 等方式实现。
1. 为什么组件通信如此重要?
Vue 的核心设计理念是组件化——页面被拆解为一个个独立的、可复用的组件。但组件不是孤岛,它们需要协作:
- 一个表单组件的数据变更,需要同步到预览组件
- 一个筛选条件组件的选择,需要驱动列表组件重新请求数据
- 一个全局主题切换,需要通知所有组件更新样式
数据如何在组件之间安全、高效、可维护地流转,直接决定了项目架构的健壮性。选择合适的通信方式,能让代码解耦、可测试、易维护;选错了,等待你的将是 prop drilling 地狱、难以追踪的事件链、和无法定位的全局状态污染。
2. 通信方式全景图
| 方式 | 适用场景 | 数据流向 | 耦合度 | Vue 3 支持 |
|---|---|---|---|---|
| Props & Events | 直接父子关系 | 父→子 (props) / 子→父 (events) | 中 | ✅ 原生 |
| EventBus | 任意组件间的事件通知 | 任意方向 | 低(但难追踪) | ⚠️ 需第三方库 (mitt) |
| v-model | 父子双向数据绑定 | 父子双向 | 中 | ✅ 原生,支持多 v-model |
| Ref & Expose | 父组件直接调用子组件方法/属性 | 父→子 | 高 | ✅ 原生 |
| Provide & Inject | 祖先向后代跨层级注入 | 祖→任意后代 | 低 | ✅ 原生 |
| Pinia | 全局/局部状态共享 | 任意方向 | 极低 | ✅ 官方推荐 |
| $attrs | 属性/事件透传给更深层组件 | 父→深层子 | 低 | ✅ 原生 |
3. 方式一:Props & Events(父子直连)
3.1 核心原理
这是 Vue 最基础、最推荐的父子通信模式:
- 父 → 子:通过
props向子组件传递数据(单向数据流) - 子 → 父:通过
emits触发事件,父组件监听并响应
┌──────────────────┐ │ 父组件 │ │ :title="msg" │──── props ────→ ┌──────────────┐ │ @update="handle" │←── emit ────── │ 子组件 │ └──────────────────┘ │ props.title │ │ $emit('update')│ └──────────────┘3.2 代码示例
子组件 (Child.vue):
<template> <div class="child-card"> <h3>📨 子组件</h3> <p>收到父组件消息:<strong>{{ message }}</strong></p> <p>计数器:<strong>{{ count }}</strong></p> <div class="btn-group"> <button @click="increment">+1 并通知父组件</button> <button @click="reset">重置并通知父组件</button> </div> </div> </template> <script setup> // defineProps 编译器宏:声明接收的 props,无需导入 const props = defineProps({ message: { type: String, default: '' }, count: { type: Number, default: 0 }, }) // defineEmits 编译器宏:声明要触发的事件 const emit = defineEmits(['update:count', 'reset']) function increment() { const newVal = props.count + 1 emit('update:count', newVal) } function reset() { emit('reset', 0) } </script>父组件 (Parent.vue):
<template> <div class="parent-card"> <h2>👨👦 Props & Events 演示</h2> <input v-model="msg" placeholder="输入要传给子组件的消息" /> <Child :message="msg" :count="counter" @update:count="counter = $event" @reset="counter = $event" /> </div> </template> <script setup> import { ref } from 'vue' import Child from './Child.vue' const msg = ref('你好,子组件!') const counter = ref(0) </script>3.3 Props 验证最佳实践
defineProps({// 带类型 + 必填userId:{type:Number,required:true},// 带默认值(引用类型用工厂函数)config:{type:Object,default:()=>({theme:'light',lang:'zh'}),},// 自定义校验status:{validator:(value)=>['active','inactive','pending'].includes(value),},})⭐ 原则应用:Props down, events up——数据从父流向子,事件从子冒泡到父,这是 Vue 单向数据流的核心,确保了 KISS (Keep It Simple, Stupid) 原则:数据流向清晰可预测。
4. 方式二:EventBus(兄弟/跨级事件)
4.1 核心原理
EventBus 是一个轻量级的发布/订阅模式实现。它本身只是一个事件中心对象,组件 A 发布事件,组件 B 订阅事件——两者无需知道对方的存在。
⚠️ Vue 3 注意:Vue 3 移除了
$on/$off/$once实例方法,推荐使用mitt(200 字节) 替代。
┌──────────┐ emit('user-login') ┌──────────────┐ on('user-login') ┌──────────┐ │ 组件 A │ ──────────────────→ │ EventBus │ ────────────────→ │ 组件 B │ │ (发送者) │ │ (事件中心) │ │ (接收者) │ └──────────┘ └──────────────┘ └──────────┘4.2 代码示例
创建 EventBus:
// utils/eventBus.jsimportmittfrom'mitt'exportconsteventBus=mitt()发送方 (Sender.vue):
<template> <div class="sender-card"> <h3>📤 事件发送方</h3> <input v-model.trim="message" placeholder="输入消息,发送给兄弟组件" @keyup.enter="send" /> <button @click="send">发送广播</button> </div> </template> <script setup> import { ref } from 'vue' import { eventBus } from '../../utils/eventBus.js' const message = ref('') function send() { if (!message.value) return eventBus.emit('global-message', { text: message.value, timestamp: Date.now(), }) message.value = '' } </script>接收方 (Receiver.vue):
<template> <div class="receiver-card"> <h3>📥 事件接收方</h3> <div v-if="messages.length === 0" class="empty">等待消息...</div> <ul> <li v-for="(msg, idx) in messages" :key="idx"> [{{ formatTime(msg.timestamp) }}] {{ msg.text }} </li> </ul> </div> </template> <script setup> import { ref, onBeforeUnmount } from 'vue' import { eventBus } from '../../utils/eventBus.js' const messages = ref([]) function onMessage(msg) { messages.value.unshift(msg) } // 订阅事件 eventBus.on('global-message', onMessage) // ⚠️ 必须手动解绑,防止重复监听和内存泄漏 onBeforeUnmount(() => { eventBus.off('global-message', onMessage) }) function formatTime(ts) { return new Date(ts).toLocaleTimeString() } </script>4.3 💀 EventBus 的陷阱
| 问题 | 说明 |
|---|---|
| 事件名冲突 | 事件名是全局字符串,多人协作容易冲突 |
| 难以追踪 | 事件流分散在各组件中,调试时难以定位来源 |
| 内存泄漏 | 忘记off()会导致组件卸载后回调仍被触发 |
| 缺乏类型安全 | 事件名和 payload 没有 TypeScript 约束 |
⭐ 原则应用:EventBus 看似解耦,实则容易引入隐式依赖,违反最小惊讶原则。对于复杂应用,优先用 Pinia 替代。EventBus 仅适合简单的跨组件通知场景(如全局 loading 状态)。
5. 方式三:v-model(双向绑定语法糖)
5.1 核心原理
v-model本质上是props+emit('update:modelValue')的语法糖。
Vue 3 增强了 v-model:
- 可改名:
v-model:title绑定titleprop,触发update:title事件 - 可多个:一个组件可以有多个 v-model
- 可自定义修饰符:如
v-model.capitalize
v-model="val" ←等价于→ :modelValue="val" @update:model-value="val = $event"5.2 代码示例
<!-- 父组件 --> <template> <div class="vmodel-demo"> <h3>🔗 v-model 双向绑定演示</h3> <!-- 单个 v-model --> <CustomInput v-model="text" /> <p>你输入了:<strong>{{ text }}</strong></p> <!-- 多个 v-model --> <UserForm v-model:name="user.name" v-model:email="user.email" /> <p>{{ user }}</p> </div> </template> <script setup> import { ref, reactive } from 'vue' import CustomInput from './CustomInput.vue' import UserForm from './UserForm.vue' const text = ref('') const user = reactive({ name: '', email: '' }) </script>自定义 Input 组件:
<!-- CustomInput.vue --> <template> <div class="custom-input"> <label>自定义输入框:</label> <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> </div> </template> <script setup> defineProps({ modelValue: String }) defineEmits(['update:modelValue']) </script>多 v-model 组件:
<!-- UserForm.vue --> <template> <div class="user-form"> <input :value="name" @input="$emit('update:name', $event.target.value)" placeholder="姓名" /> <input :value="email" @input="$emit('update:email', $event.target.value)" placeholder="邮箱" /> </div> </template> <script setup> defineProps({ name: String, email: String }) defineEmits(['update:name', 'update:email']) </script>⭐ 原则应用:这是对 Props + Events 模式的 DRY 封装——把高频出现的 “传 prop + 监听 update 事件” 模式收敛为 v-model 语法糖,减少样板代码。
6. 方式四:Ref & Expose(父调子方法)
6.1 核心原理
当我们不想用事件 “请求” 子组件做某件事,而是想直接命令它时,可以用:
ref获取子组件实例引用- 子组件用
defineExpose暴露方法/属性 - 父组件直接调用
┌─────────────────────────┐ │ 父组件 │ │ const childRef = ref() │ │ childRef.value.focus() │─── 直接调用 ──→ ┌──────────────────┐ │ │ │ 子组件 │ │ <Child ref="childRef"/> │ │ defineExpose({ │ └─────────────────────────┘ │ focus() {...} │ │ }) │ └──────────────────┘6.2 代码示例
<!-- RefDemo.vue --> <template> <div class="ref-demo"> <h3>🎯 Ref & Expose 演示</h3> <p> <button @click="focusChild">聚焦子组件输入框</button> <button @click="resetChild">重置子组件</button> <button @click="logChildData">读取子组件数据</button> </p> <CommentInput ref="commentRef" /> </div> </template> <script setup> import { ref } from 'vue' import CommentInput from './CommentInput.vue' const commentRef = ref(null) function focusChild() { commentRef.value?.focus() } function resetChild() { commentRef.value?.reset() } function logChildData() { // 自动解包 ref,父组件直接拿到子组件暴露的值 console.log('子组件当前数据:', commentRef.value?.content) } </script>CommentInput.vue:
<template> <div class="comment-input"> <textarea ref="inputRef" v-model="content" placeholder="输入评论..." /> <p>已输入 {{ content.length }} 字</p> </div> </template> <script setup> import { ref } from 'vue' const inputRef = ref(null) const content = ref('') function focus() { inputRef.value?.focus() } function reset() { content.value = '' } // 仅暴露想给父组件使用的方法和属性 defineExpose({ focus, reset, content }) </script>6.3 何时用 vs 何时不用
| ✅ 适合用 | ❌ 不适合用 |
|---|---|
| 表单聚焦/清空 | 跨多层级的状态共享 |
| 触发子组件动画 | 复杂的业务数据流 |
| 获取子组件计算结果 | 兄弟组件通信 |
⭐ 原则应用:
defineExpose遵循了 SOLID 的接口隔离原则 (ISP)——子组件只暴露必要的方法,不暴露内部实现细节。这比直接暴露整个组件实例要安全得多。
7. 方式五:Provide & Inject(跨层级注入)
7.1 核心原理
当祖组件需要向深层后代组件传递数据时,Props 需要逐层转发 (prop drilling),非常繁琐。Provide & Inject 让祖组件直接 “注入” 数据到任意深度的后代组件,跳过中间层。
┌──────────────┐ │ 祖组件 │ provide('theme', 'dark') │ (提供者) │ └──────┬───────┘ │ ┌──────────────┐ ├─→│ 父组件 │ ← 不需要知道 theme,也不需要转发 │ │ (中间层) │ │ └──────┬───────┘ │ │ ┌──────────────┐ └─────────┴─→│ 孙组件 │ inject('theme') → 'dark' │ (消费者) │ └──────────────┘7.2 代码示例
<!-- Grandparent.vue — 提供者 --> <template> <div class="grandparent-card"> <h3>🏗️ Provide & Inject 演示</h3> <p> 当前主题: <button :class="{ active: theme === 'light' }" @click="theme = 'light'">浅色</button> <button :class="{ active: theme === 'dark' }" @click="theme = 'dark'">深色</button> </p> <!-- 中间层父组件不需要接收 theme prop --> <Middle /> </div> </template> <script setup> import { ref, provide, readonly } from 'vue' import Middle from './Middle.vue' const theme = ref('light') function toggleTheme() { theme.value = theme.value === 'light' ? 'dark' : 'light' } // 提供数据 + 修改方法 provide('theme', readonly(theme)) // readonly 防止后代直接修改 provide('toggleTheme', toggleTheme) </script>中间层 (Middle.vue) — 不需要处理 theme:
<template> <div class="middle-card"> <h4>📦 中间层组件(不处理 theme)</h4> <Grandchild /> </div> </template> <script setup> import Grandchild from './Grandchild.vue' </script>孙组件 (Grandchild.vue) — 消费者:
<template> <div class="grandchild-card" :class="`theme-${theme}`"> <h4>🎨 孙组件</h4> <p>当前主题:<strong>{{ theme }}</strong></p> <button @click="toggleTheme">切换主题</button> <p class="note">背景色跟随主题变化</p> </div> </template> <script setup> import { inject } from 'vue' const theme = inject('theme', 'light') // 第二个参数为默认值 const toggleTheme = inject('toggleTheme', () => {}) </script>7.3 Provide 响应式最佳实践
import{ref,reactive,provide,readonly,computed}from'vue'// ✅ 正确:ref/reactive 本身就是响应式的constcount=ref(0)provide('count',readonly(count))// 用 readonly 保护// ✅ 正确:computed 也是响应式的provide('double',computed(()=>count.value*2))// ❌ 错误:直接传值会失去响应性provide('count',count.value)// ✅ 推荐:用 Symbol 作为 injection key 避免命名冲突// keys.jsexportconstTHEME_KEY=Symbol('theme')exportconstCOUNT_KEY=Symbol('count')// 提供方import{THEME_KEY}from'./keys.js'provide(THEME_KEY,theme)// 注入方import{THEME_KEY}from'./keys.js'consttheme=inject(THEME_KEY)7.4 Provide & Inject vs Props & Events
| 维度 | Props & Events | Provide & Inject |
|---|---|---|
| 数据来源可追踪 | ✅ 明确 | ❌ 须查看 provide 调用 |
| 响应式 | ✅ 自动 | ✅ 自动 (需传 ref/reactive) |
| 类型安全 (TS) | ✅ 强 | ⚠️ 需用 InjectionKey |
| 跨层级 | ❌ 逐层转发 | ✅ 一跳直达 |
| 适用距离 | 父子 (1 层) | 多层 (>2 层) |
⭐ 原则应用:Provide & Inject 解决了 prop drilling 问题(YAGNI—中间组件不需要的 props 就不该接收)。但要注意:过度使用会让数据流变得隐晦,违背了可追踪性原则——能用 Props 解决的,优先用 Props。
8. 方式六:Pinia(全局状态管理)
8.1 核心原理
Pinia 是 Vue 3 官方推荐的状态管理库(Vuex 的后继者),适用于多个组件共享同一份状态的场景。
┌─────────────────────┐ │ Pinia Store │ │ ┌─────────────────┐│ │ │ state: { count } ││ │ │ getters: { x2 } ││ │ │ actions: { inc } ││ │ └─────────────────┘│ └──────┬───┬──────────┘ │ │ ┌──────────┘ └──────────┐ ↓ ↓ ┌───────────────┐ ┌───────────────┐ │ 组件 A │ │ 组件 B │ │ count++, x2 │ │ count++, x2 │ └───────────────┘ └───────────────┘8.2 Pinia 核心三要素
| 概念 | 类比 | 说明 |
|---|---|---|
| State | data | 存储数据,本质是reactive |
| Getters | computed | 派生状态,带缓存 |
| Actions | methods | 修改 state 的方法,支持异步 |
8.3 代码示例
Store 定义 (stores/counter.js):
import{defineStore}from'pinia'import{ref,computed}from'vue'// 推荐使用 Setup Store 语法(与 Composition API 一致)exportconstuseCounterStore=defineStore('counter',()=>{// ===== State =====constcount=ref(0)consthistory=ref([])// ===== Getters =====constdoubleCount=computed(()=>count.value*2)constlastAction=computed(()=>history.value.at(-1)||'无操作')// ===== Actions =====functionincrement(){count.value++history.value.push(`+1 →${count.value}`)}functiondecrement(){count.value--history.value.push(`-1 →${count.value}`)}asyncfunctionincrementAsync(){awaitnewPromise((resolve)=>setTimeout(resolve,500))increment()}// 暴露给外部使用return{count,history,doubleCount,lastAction,increment,decrement,incrementAsync}})组件 A (PiniaDemoA.vue):
<template> <div class="pinia-card"> <h4>组件 A</h4> <p>count: {{ store.count }}</p> <p>double: {{ store.doubleCount }}</p> <button @click="store.increment()">+1</button> <button @click="store.incrementAsync()">异步 +1 (0.5s)</button> </div> </template> <script setup> import { useCounterStore } from './stores/counter.js' const store = useCounterStore() </script>组件 B (PiniaDemoB.vue):
<template> <div class="pinia-card"> <h4>组件 B(共享同一 store)</h4> <p>count: {{ store.count }}</p> <button @click="store.decrement()">-1</button> <hr /> <p>最后操作:{{ store.lastAction }}</p> <ul> <li v-for="(h, i) in store.history" :key="i">{{ h }}</li> </ul> </div> </template> <script setup> import { useCounterStore } from './stores/counter.js' const store = useCounterStore() </script>8.4 Pinia vs Vuex
| 特性 | Pinia | Vuex 4 |
|---|---|---|
| 语法简洁度 | ⭐⭐⭐⭐⭐ Setup Store 与 Composition API 统一 | ⭐⭐⭐ mutations/actions 分离 |
| TypeScript | 完美的类型推断 | 需要额外类型声明 |
| 模块化 | 天然模块化 (多 store) | 需要 modules 嵌套 |
| 体积 | ~1KB | ~10KB |
| 去掉了 mutations | ✅ | ❌ |
| DevTools 支持 | ✅ | ✅ |
⭐ 原则应用:Pinia 遵循 SOLID 的单一职责原则 (SRP)——每个 Store 管理一个领域的状态。同时也体现了依赖倒置 (DIP)——组件不直接管理全局状态,而是依赖抽象的 Store。Store 内部的
getters是典型的 DRY 实践,避免在多个组件中重复计算派生状态。
9. 方式七:$attrs(属性透传)
9.1 核心原理
当父组件向子组件传递了很多 props,而子组件需要把这些 props透传给更深层的组件时,不需要逐个声明和转发——使用$attrs可以一键透传。
┌─────────────┐ class, id,>9.2 代码示例<!-- AttrsDemo.vue --> <template> <div class="attrs-demo"> <h3>🔀 $attrs 透传演示</h3> <!-- 父组件往 BaseInput 传了很多属性 --> <BaseInput label="用户名" placeholder="请输入" ><template> <div class="base-input-wrapper"> <label>{{ label }}</label> <!-- v-bind="$attrs" 把剩下的属性/事件一键透传给原生 input --> <input v-bind="$attrs" /> <!-- useAttrs 也能在 script 中访问 --> </div> </template> <script setup> import { useAttrs } from 'vue' defineProps({ label: String }) // useAttrs 返回所有非 prop 的属性(响应式的) const attrs = useAttrs() console.log('透传的属性:', attrs) // { placeholder,>9.3 禁用自动继承Vue 默认把$attrs应用到组件的根元素上。如果你不希望这样:
<script setup> defineOptions({ inheritAttrs: false }) </script>
⭐ 原则应用:$attrs体现了 DRY 原则——不需要在中间组件逐个声明和转发 prop,同时也符合开闭原则 (OCP)——父组件新增属性时,中间组件无需修改即可透传。
