本篇定位 :在掌握了 Props 和 Emit 的基础通信方式后,我们需要解决更复杂的场景------跨层级组件通信。本篇将深入讲解
provide和inject的依赖注入模式,以及事件总线(mitt)的使用方法,帮助你应对各种复杂的组件间通信需求。读完本篇,你将能够灵活选择和组合不同的通信方式,构建高内聚、低耦合的组件架构。
一、为什么需要跨层级通信?
在 Vue 应用中,组件树往往是深层嵌套的:
App.vue
└── Dashboard.vue
└── UserPanel.vue
└── UserProfile.vue
└── Avatar.vue
如果 Avatar 组件需要访问 App 根组件中的用户数据,使用 Props 逐层传递会导致 Props 钻探(Prop Drilling):
vue
<!-- App.vue -->
<template>
<Dashboard :user="user" />
</template>
<!-- Dashboard.vue -->
<template>
<UserPanel :user="user" />
</template>
<!-- UserPanel.vue -->
<template>
<UserProfile :user="user" />
</template>
<!-- UserProfile.vue -->
<template>
<Avatar :user="user" />
</template>
每一层都需要接收并传递 user prop,即使中间组件本身并不需要这个数据。这种模式的问题:
- 代码冗余:每个中间组件都要定义 prop
- 维护困难:当数据来源变化时,需要修改所有中间层
- 耦合度高:中间组件被迫感知上层的数据结构
Provide / Inject 正是为了解决这个问题而生的。
二、Provide / Inject:跨层级依赖注入
2.1 基本概念
provide 和 inject 是一对用于跨层级传递数据的 API,它允许祖先组件向所有子孙组件注入数据,无论层级多深。
| API | 角色 | 说明 |
|---|---|---|
provide |
提供者 | 在祖先组件中定义,提供数据 |
inject |
消费者 | 在子孙组件中定义,接收数据 |
vue
<!-- 祖先组件:提供数据 -->
<script setup>
import { provide, ref } from 'vue'
const user = ref({ name: '张三', age: 25 })
const theme = ref('dark')
// 提供数据
provide('user', user)
provide('theme', theme)
</script>
<!-- 任意子孙组件:注入数据 -->
<script setup>
import { inject } from 'vue'
// 注入数据,可以指定默认值
const user = inject('user')
const theme = inject('theme', 'light') // 如果未找到,使用默认值
</script>
2.2 使用 Symbol 作为 Key
为了避免命名冲突,建议使用 Symbol 作为 provide 的 key:
typescript
// keys.ts - 集中管理注入 key
export const USER_KEY = Symbol('user')
export const THEME_KEY = Symbol('theme')
export const TOGGLE_THEME_KEY = Symbol('toggleTheme')
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
import { USER_KEY, THEME_KEY, TOGGLE_THEME_KEY } from './keys'
const user = ref({ name: '张三', age: 25 })
const theme = ref('dark')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
provide(USER_KEY, user)
provide(THEME_KEY, theme)
provide(TOGGLE_THEME_KEY, toggleTheme)
</script>
vue
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'
import { USER_KEY, THEME_KEY, TOGGLE_THEME_KEY } from './keys'
const user = inject(USER_KEY)
const theme = inject(THEME_KEY)
const toggleTheme = inject(TOGGLE_THEME_KEY)
// 使用注入的数据和方法
function updateUser() {
if (user) {
user.value.name = '李四'
}
}
</script>
2.3 Provide / Inject 的响应式
Provide 的数据默认是响应式的 ,前提是提供的是响应式数据(ref 或 reactive)。
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref, reactive } from 'vue'
// ✅ 提供 ref(响应式)
const count = ref(0)
provide('count', count)
// ✅ 提供 reactive(响应式)
const user = reactive({ name: '张三' })
provide('user', user)
// ❌ 提供普通值(非响应式)
const staticValue = 'hello'
provide('static', staticValue) // 子组件无法响应式更新
</script>
vue
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
const user = inject('user')
// 修改注入的数据(会触发响应式更新)
function increment() {
count.value++ // ✅ 祖先组件中的 count 也会更新
}
function updateUser() {
user.name = '李四' // ✅ 祖先组件中的 user 也会更新
}
</script>
2.4 使用 readonly 保护注入数据
为了防止子组件意外修改祖先提供的数据,可以使用 readonly 包裹:
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref, readonly } from 'vue'
const count = ref(0)
// 提供只读版本
provide('count', readonly(count))
// 提供修改方法
provide('increment', () => {
count.value++
})
</script>
vue
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
const increment = inject('increment')
// count 是只读的,不能直接修改
// count.value++ ❌ 会触发警告
// 通过提供的函数修改
increment() // ✅ 正确方式
</script>
2.5 在应用根级别 Provide
如果需要在整个应用中提供数据,可以在 main.ts 中调用 app.provide():
typescript
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全局提供数据,所有组件都可注入
app.provide('globalConfig', {
apiBase: 'https://api.example.com',
appVersion: '1.0.0'
})
app.mount('#app')
2.6 深层 Provide / Inject 的注意点
① 只能从祖先到子孙,不能反向
数据流方向是单向的:祖先 → 子孙。孙子组件无法直接向祖先传递数据,需要通过事件或其他方式。
② 响应式数据的修改需要谨慎
虽然子组件可以修改注入的响应式数据,但遵循单向数据流原则更利于维护:
- 推荐在祖先组件中提供修改方法
- 子组件通过调用方法来修改数据
- 避免在子组件中直接修改注入的数据
③ 依赖注入的调试
Vue DevTools 可以查看 provide 和 inject 的数据结构,方便调试。
2.7 Provide / Inject 与 Props 的选择
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 父子组件直接通信 | Props / Emit | 清晰、可追踪 |
| 跨越多层(祖孙) | Provide / Inject | 避免 Props 钻探 |
| 全局共享状态(如主题、用户) | Provide / Inject | 简单轻量,不引入 Pinia |
| 大型应用状态管理 | Pinia | 更规范、有 devtools 支持 |
| 同一组件树的多个分支共享 | Provide / Inject | 范围限定在组件树内 |
三、事件总线(mitt):任意组件通信
3.1 什么是事件总线
事件总线(Event Bus) 是一种设计模式,它允许任意两个组件之间 直接通信,而不需要父子关系。在 Vue 3 中,Vue 实例不再内置事件总线功能,推荐使用专门的库------mitt。
适用场景:
- 兄弟组件之间通信
- 非父子关系的组件通信
- 跨页面/跨路由的事件通知
3.2 安装与使用 mitt
安装:
bash
pnpm add mitt
创建事件总线实例:
typescript
// utils/eventBus.ts
import mitt from 'mitt'
// 定义事件类型
type Events = {
'user-login': { userId: number; name: string }
'user-logout': void
'theme-change': 'dark' | 'light'
'notification': { message: string; type: 'success' | 'error' }
}
// 创建类型安全的事件总线
export const eventBus = mitt<Events>()
// 导出便捷方法
export const { emit, on, off } = eventBus
在组件中使用:
vue
<!-- 组件 A:触发事件 -->
<script setup>
import { eventBus } from '@/utils/eventBus'
function login() {
// 触发事件,传递数据
eventBus.emit('user-login', { userId: 1, name: '张三' })
}
function logout() {
eventBus.emit('user-logout')
}
</script>
<template>
<button @click="login">登录</button>
<button @click="logout">登出</button>
</template>
vue
<!-- 组件 B:监听事件 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'
function handleLogin(data: { userId: number; name: string }) {
console.log('用户登录:', data)
}
function handleLogout() {
console.log('用户已登出')
}
onMounted(() => {
// 监听事件
eventBus.on('user-login', handleLogin)
eventBus.on('user-logout', handleLogout)
})
onUnmounted(() => {
// ⚠️ 重要:在组件销毁时移除监听,防止内存泄漏
eventBus.off('user-login', handleLogin)
eventBus.off('user-logout', handleLogout)
})
</script>
3.3 mitt 的核心 API
| API | 签名 | 说明 |
|---|---|---|
on |
emitter.on(event, handler) |
监听事件 |
off |
emitter.off(event, handler) |
移除监听 |
emit |
emitter.emit(event, payload) |
触发事件 |
all |
emitter.all |
获取所有事件监听器(调试用) |
一次性监听(mitt 不原生支持,可自行封装):
typescript
// 封装一次性监听
function once(emitter: mitt.Emitter, event: string, handler: Function) {
const wrapper = (payload: any) => {
handler(payload)
emitter.off(event, wrapper)
}
emitter.on(event, wrapper)
}
// 使用
once(eventBus, 'user-login', (data) => {
console.log('只执行一次', data)
})
3.4 在组件中自动管理事件监听
为了避免手动管理 onMounted / onUnmounted,可以封装一个组合式函数:
typescript
// composables/useEventBus.ts
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'
export function useEventBus(
events: Array<{
event: string
handler: (...args: any[]) => void
}>
) {
onMounted(() => {
events.forEach(({ event, handler }) => {
eventBus.on(event, handler)
})
})
onUnmounted(() => {
events.forEach(({ event, handler }) => {
eventBus.off(event, handler)
})
})
}
vue
<script setup>
import { useEventBus } from '@/composables/useEventBus'
useEventBus([
{ event: 'user-login', handler: (data) => console.log('登录', data) },
{ event: 'theme-change', handler: (theme) => document.body.className = theme }
])
</script>
3.5 事件总线的优缺点
| 优点 | 缺点 |
|---|---|
| 解耦:组件不需要知道彼此的存在 | 调试困难:事件流不可追踪,难以定位问题 |
| 灵活:任何组件都可以通信 | 维护成本高:事件名分散,容易冲突或遗忘 |
| 简单:API 直观,学习成本低 | 类型安全弱:虽然可用 TS,但不如 Props/Emit 强 |
| 跨组件通信:兄弟、跨层级都适用 | 滥用会导致混乱:数据流不可预测 |
最佳实践:
- 将事件总线用于低频、跨组件的事件通知(如全局消息、主题切换)
- 避免用事件总线传递大量数据 或频繁触发的事件
- 建议为事件名定义常量枚举,避免硬编码
四、组件通信方式全面对比
| 通信方式 | 适用场景 | 数据流方向 | 响应式 | 复杂度 |
|---|---|---|---|---|
| Props | 父子组件传数据 | 父 → 子 | ✅ | 低 |
| Emit | 子父组件传事件 | 子 → 父 | ❌(事件) | 低 |
| v-model | 双向绑定 | 父 ↔ 子 | ✅ | 低 |
| Provide / Inject | 跨层级祖孙通信 | 祖先 → 子孙 | ✅(配合 ref) | 中 |
| 事件总线(mitt) | 任意组件通信 | 任意方向 | ❌(事件) | 中 |
| Pinia | 全局状态管理 | 全局 | ✅ | 高 |
| $refs | 直接访问子组件实例 | 父 → 子 | 通过实例 | 中 |
4.1 选择指南
组件间需要通信
↓
是否有直接的父子关系?
↓ 是 ↓ 否
是否跨越多层? 是否为全局共享状态?
↓ 是 ↓ 否 ↓ 是 ↓ 否
Provide/Inject Props/Emit Pinia 事件总线
五、综合实战:主题切换与全局通知
让我们结合 Provide / Inject 和事件总线,构建一个具有主题切换和全局通知功能的完整示例。
5.1 项目结构
src/
├── main.ts
├── App.vue
├── keys/
│ └── index.ts # 注入 key 定义
├── composables/
│ ├── useTheme.ts # 主题组合式函数
│ └── useNotification.ts # 通知组合式函数
├── components/
│ ├── ThemeToggle.vue # 主题切换按钮
│ ├── NotificationContainer.vue # 通知容器
│ └── UserProfile.vue # 用户个人资料
└── utils/
└── eventBus.ts # 事件总线
5.2 事件总线定义
typescript
// utils/eventBus.ts
import mitt from 'mitt'
// 定义事件类型
export type Events = {
'notification': {
id: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
duration?: number
}
'notification:clear': void
'theme:changed': 'dark' | 'light'
}
export const eventBus = mitt<Events>()
export const { emit, on, off } = eventBus
// 便利方法
export function notify(
message: string,
type: Events['notification']['type'] = 'info',
duration: number = 3000
) {
emit('notification', {
id: Date.now().toString(),
message,
type,
duration
})
}
5.3 Provide / Inject 定义
typescript
// keys/index.ts
import { InjectionKey, Ref } from 'vue'
export type Theme = 'dark' | 'light'
export const THEME_KEY = Symbol('theme') as InjectionKey<Ref<Theme>>
export const TOGGLE_THEME_KEY = Symbol('toggleTheme') as InjectionKey<() => void>
export const APP_VERSION_KEY = Symbol('appVersion') as InjectionKey<string>
5.4 主题组合式函数
typescript
// composables/useTheme.ts
import { ref, provide, inject, watch } from 'vue'
import { THEME_KEY, TOGGLE_THEME_KEY, type Theme } from '@/keys'
import { eventBus } from '@/utils/eventBus'
export function useTheme() {
const theme = ref<Theme>('light')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
// 通过事件总线通知主题变化
eventBus.emit('theme:changed', theme.value)
}
// 应用主题到 DOM
watch(theme, (newTheme) => {
document.documentElement.setAttribute('data-theme', newTheme)
}, { immediate: true })
return { theme, toggleTheme }
}
// 在根组件中调用,提供主题
export function provideTheme() {
const { theme, toggleTheme } = useTheme()
provide(THEME_KEY, theme)
provide(TOGGLE_THEME_KEY, toggleTheme)
return { theme, toggleTheme }
}
// 在子组件中注入主题
export function useInjectTheme() {
const theme = inject(THEME_KEY)
const toggleTheme = inject(TOGGLE_THEME_KEY)
if (!theme || !toggleTheme) {
throw new Error('Theme not provided')
}
return { theme, toggleTheme }
}
5.5 通知组合式函数
typescript
// composables/useNotification.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus, type Events } from '@/utils/eventBus'
interface Notification extends Events['notification'] {
id: string
}
export function useNotification() {
const notifications = ref<Notification[]>([])
function addNotification(notification: Notification) {
notifications.value.push(notification)
// 自动移除
if (notification.duration && notification.duration > 0) {
setTimeout(() => {
removeNotification(notification.id)
}, notification.duration)
}
}
function removeNotification(id: string) {
notifications.value = notifications.value.filter(n => n.id !== id)
}
function clearAll() {
notifications.value = []
}
// 监听事件总线
onMounted(() => {
eventBus.on('notification', addNotification)
eventBus.on('notification:clear', clearAll)
})
onUnmounted(() => {
eventBus.off('notification', addNotification)
eventBus.off('notification:clear', clearAll)
})
return {
notifications,
addNotification,
removeNotification,
clearAll
}
}
5.6 主组件实现
vue
<!-- App.vue -->
<script setup>
import { provideTheme } from '@/composables/useTheme'
import { APP_VERSION_KEY } from '@/keys'
import ThemeToggle from './components/ThemeToggle.vue'
import NotificationContainer from './components/NotificationContainer.vue'
import UserProfile from './components/UserProfile.vue'
// 提供主题
provideTheme()
// 提供应用版本
provide(APP_VERSION_KEY, 'v1.0.0')
</script>
<template>
<div class="app">
<header>
<h1>🚀 Vue 3 组件通信示例</h1>
<ThemeToggle />
</header>
<main>
<UserProfile />
</main>
<NotificationContainer />
</div>
</template>
<style>
[data-theme="dark"] {
--bg: #1a202c;
--text: #e2e8f0;
--card-bg: #2d3748;
--border: #4a5568;
}
[data-theme="light"] {
--bg: #f7fafc;
--text: #1a202c;
--card-bg: #ffffff;
--border: #e2e8f0;
}
.app {
min-height: 100vh;
background: var(--bg);
color: var(--text);
transition: background 0.3s, color 0.3s;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
main {
padding: 40px;
max-width: 800px;
margin: 0 auto;
}
</style>
5.7 主题切换组件
vue
<!-- components/ThemeToggle.vue -->
<script setup>
import { useInjectTheme } from '@/composables/useTheme'
import { notify } from '@/utils/eventBus'
const { theme, toggleTheme } = useInjectTheme()
function handleToggle() {
toggleTheme()
notify(`切换至 ${theme.value === 'dark' ? '暗色' : '亮色'} 主题`, 'success', 2000)
}
</script>
<template>
<button @click="handleToggle" class="theme-toggle">
<span v-if="theme === 'dark'">🌙</span>
<span v-else>☀️</span>
{{ theme === 'dark' ? '暗色' : '亮色' }}
</button>
</template>
<style scoped>
.theme-toggle {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
opacity: 0.8;
}
</style>
5.8 通知容器组件
vue
<!-- components/NotificationContainer.vue -->
<script setup>
import { useNotification } from '@/composables/useNotification'
import { computed } from 'vue'
const { notifications, removeNotification } = useNotification()
// 按类型获取样式
function getTypeStyles(type: string) {
const map = {
info: { bg: '#ebf8ff', color: '#2b6cb0', border: '#bee3f8' },
success: { bg: '#f0fff4', color: '#276749', border: '#c6f6d5' },
warning: { bg: '#fffff0', color: '#975a16', border: '#f6e05e' },
error: { bg: '#fff5f5', color: '#9b2c2c', border: '#fed7d7' }
}
return map[type as keyof typeof map] || map.info
}
</script>
<template>
<div class="notification-container">
<div
v-for="notif in notifications"
:key="notif.id"
class="notification"
:style="{
background: getTypeStyles(notif.type).bg,
color: getTypeStyles(notif.type).color,
borderColor: getTypeStyles(notif.type).border
}"
>
<span class="icon">
{{ notif.type === 'info' ? 'ℹ️' : notif.type === 'success' ? '✅' : notif.type === 'warning' ? '⚠️' : '❌' }}
</span>
<span class="message">{{ notif.message }}</span>
<button @click="removeNotification(notif.id)" class="close-btn">×</button>
</div>
</div>
</template>
<style scoped>
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.notification {
padding: 12px 16px;
border: 2px solid;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.message { flex: 1; font-size: 14px; }
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
opacity: 0.6;
}
.close-btn:hover { opacity: 1; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
5.9 用户资料组件
vue
<!-- components/UserProfile.vue -->
<script setup>
import { inject } from 'vue'
import { APP_VERSION_KEY } from '@/keys'
import { notify } from '@/utils/eventBus'
const appVersion = inject(APP_VERSION_KEY)
function handleSave() {
// 模拟保存操作
notify('用户资料已保存!', 'success', 3000)
}
function handleError() {
notify('保存失败,请重试', 'error', 3000)
}
function handleWarning() {
notify('部分字段未填写完整', 'warning', 3000)
}
</script>
<template>
<div class="user-profile">
<div class="profile-header">
<h2>👤 用户资料</h2>
<span class="version">版本:{{ appVersion }}</span>
</div>
<div class="profile-body">
<div class="info-row">
<label>姓名</label>
<input type="text" placeholder="请输入姓名" />
</div>
<div class="info-row">
<label>邮箱</label>
<input type="email" placeholder="请输入邮箱" />
</div>
<div class="actions">
<button @click="handleSave" class="btn-save">💾 保存</button>
<button @click="handleWarning" class="btn-warn">⚠️ 警告</button>
<button @click="handleError" class="btn-error">❌ 错误</button>
</div>
</div>
</div>
</template>
<style scoped>
.user-profile {
background: var(--card-bg);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border);
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.version {
font-size: 12px;
color: var(--text);
opacity: 0.6;
}
.info-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
}
.info-row label {
font-size: 14px;
font-weight: 500;
}
.info-row input {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.actions button {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
.actions button:hover { opacity: 0.8; }
.btn-save { background: #4299e1; color: white; }
.btn-warn { background: #ecc94b; color: #744210; }
.btn-error { background: #fc8181; color: white; }
</style>
六、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| Provide / Inject | 跨层级依赖注入 | 使用 Symbol 作为 key 避免冲突 |
| 响应式 Provide | 提供 ref/reactive 数据 | 配合 readonly 保护数据 |
| 事件总线(mitt) | 任意组件通信 | 需要在 onUnmounted 中移除监听 |
| 选择策略 | 按场景选择合适方式 | Props/Emit → Provide → 事件总线 → Pinia |
| 组合式函数 | 封装 Provide/Inject 逻辑 | 提高代码复用性 |
七、思考与练习
-
基础练习 :使用 Provide / Inject 在根组件提供一个
locale状态('zh' / 'en'),并在深层组件中显示当前语言和切换按钮。 -
实践任务 :使用 mitt 实现一个全局的
loading状态管理,任何组件都可以触发showLoading和hideLoading事件,并在全局统一显示加载状态。 -
陷阱排查:以下代码有什么问题?
vue<!-- 祖先组件 --> <script setup> provide('count', 0) function increment() { count++ } </script> <!-- 子孙组件 --> <script setup> const count = inject('count') function update() { count++ } </script> -
深度思考:Provide / Inject 和 Pinia 都可以用于共享状态,它们分别适合什么场景?在什么情况下你会选择 Provide / Inject 而不是引入 Pinia?
-
扩展任务 :为通知系统添加
id自动生成和duration默认值,并实现通知的批量清除功能。
下篇预告
第 10 篇我们将学习插槽(Slot),这是实现组件高度可定制化的核心工具:
- 默认插槽与具名插槽
- 作用域插槽------子组件向父组件传递数据
- 动态插槽名
- 插槽的高级模式与最佳实践
准备好让你的组件变得更加强大和灵活!🎨