第 119 章:组件通信模式
学习目标
- 掌握 Vue 3 中各种组件通信方式
- 学会 props/emits、provide/inject 的使用场景
- 理解 Pinia 在状态管理中的角色
- 能在项目中选择最合适的通信方式
一、组件通信全景
二、父子通信:Props / Emits
2.1 Props 父传子
vue
<!-- Child.vue -->
<script setup lang="ts">
interface Props {
title: string;
count?: number;
}
const props = defineProps<Props>();
</script>
<template>
<h1>{{ title }}</h1>
<p>count: {{ count }}</p>
</template>vue
<!-- Parent.vue -->
<Child title="Hello" :count="10" />2.2 Emits 子传父
vue
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
update: [value: string];
}>();
</script>vue
<!-- Parent.vue -->
<Child @update="handleUpdate" />三、父子通信:v-model
3.1 单个 v-model
vue
<!-- Child.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>vue
<Child v-model="text" />3.2 多个 v-model
vue
<Child v-model:name="userName" v-model:age="userAge" />四、兄弟通信:通过共同父组件
vue
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import SiblingA from './SiblingA.vue';
import SiblingB from './SiblingB.vue';
const sharedData = ref('');
</script>
<template>
<SiblingA v-model="sharedData" />
<SiblingB :data="sharedData" />
</template>vue
<!-- SiblingA.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>vue
<!-- SiblingB.vue -->
<script setup lang="ts">
defineProps<{ data: string }>();
</script>五、跨级通信:provide / inject
5.1 基础用法
vue
<!-- Grandparent.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue';
const theme = ref('dark');
const user = ref({ name: 'Tom' });
// 向所有后代提供
provide('theme', theme);
provide('user', user);
</script>
<template>
<slot />
</template>vue
<!-- Grandchild.vue (任意深度) -->
<script setup lang="ts">
import { inject } from 'vue';
const theme = inject('theme', 'light'); // 'light' 是默认值
const user = inject('user');
</script>
<template>
<p>主题:{{ theme }}</p>
<p>用户:{{ user?.name }}</p>
</template>5.2 类型化 provide/inject
typescript
// injection-keys.ts
import type { InjectionKey, Ref } from 'vue';
export interface User {
id: number;
name: string;
}
export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme');
export const UserKey: InjectionKey<Ref<User | null>> = Symbol('user');vue
<!-- Provider -->
<script setup lang="ts">
import { provide, ref } from 'vue';
import { ThemeKey, UserKey, type User } from './injection-keys';
const theme = ref('dark');
const user = ref<User | null>(null);
provide(ThemeKey, theme);
provide(UserKey, user);
</script>vue
<!-- Consumer -->
<script setup lang="ts">
import { inject } from 'vue';
import { ThemeKey, UserKey } from './injection-keys';
const theme = inject(ThemeKey, ref('light'));
const user = inject(UserKey);
</script>5.3 实战:全局配置
typescript
// composables/useAppConfig.ts
import { provide, inject, ref, type InjectionKey, type Ref } from 'vue';
interface AppConfig {
apiUrl: string;
theme: 'light' | 'dark';
}
const ConfigKey: InjectionKey<Ref<AppConfig>> = Symbol('app-config');
export function provideAppConfig(config: AppConfig) {
const ref = useRef(config);
provide(ConfigKey, ref);
return ref;
}
export function useAppConfig() {
return inject(ConfigKey, ref({ apiUrl: '', theme: 'light' }));
}vue
<!-- App.vue -->
<script setup lang="ts">
provideAppConfig({ apiUrl: 'https://api.example.com', theme: 'dark' });
</script>vue
<!-- AnyChild.vue -->
<script setup lang="ts">
const config = useAppConfig();
</script>六、全局通信:Pinia
6.1 创建 store
typescript
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => !!user.value);
async function login(username: string, password: string) {
const res = await api.login({ username, password });
user.value = res.data;
}
function logout() {
user.value = null;
}
return { user, isLoggedIn, login, logout };
});6.2 在组件中使用
vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
</script>
<template>
<div v-if="userStore.isLoggedIn">
欢迎,{{ userStore.user?.name }}
<button @click="userStore.logout">登出</button>
</div>
<button v-else @click="userStore.login('tom', '123')">登录</button>
</template>七、事件总线:mitt
7.1 安装
bash
pnpm add mitt7.2 创建事件总线
typescript
// utils/event-bus.ts
import mitt, { type Emitter } from 'mitt';
type Events = {
'user-login': { id: number; name: string };
'user-logout': void;
'theme-change': 'light' | 'dark';
};
export const bus: Emitter<Events> = mitt<Events>();7.3 使用
typescript
// 组件 A:触发事件
import { bus } from '@/utils/event-bus';
const handleLogin = () => {
bus.emit('user-login', { id: 1, name: 'Tom' });
};typescript
// 组件 B:监听事件
import { bus } from '@/utils/event-bus';
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
bus.on('user-login', (user) => {
console.log('用户登录', user);
});
});
onUnmounted(() => {
bus.off('user-login'); // 清理
});八、VueUse 全局状态
bash
pnpm add @vueuse/coretypescript
import { createGlobalState, useStorage } from '@vueuse/core';
// 全局响应式状态
export const useGlobalTheme = createGlobalState(() =>
useStorage('theme', 'light')
);vue
<!-- 任何组件 -->
<script setup lang="ts">
const theme = useGlobalTheme();
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
切换主题
</button>
</template>九、对比与选择
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Props/Emits | 父子组件 | 简单直观 | 多层繁琐 |
| v-model | 双向绑定 | 简洁 | 仅父子 |
| provide/inject | 跨级共享 | 避免 prop drilling | 难追踪来源 |
| Pinia | 全局状态 | 类型友好、DevTools | 学习成本 |
| Event Bus | 临时事件 | 解耦 | 难调试 |
| VueUse | 简单全局状态 | 上手快 | 不适合复杂 |
选择流程图
十、实战案例
10.1 用户认证流
typescript
// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { loginApi, logoutApi } from '@/api/auth';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '');
const user = ref<User | null>(null);
const isLoggedIn = computed(() => !!token.value);
async function login(credentials: LoginDTO) {
const res = await loginApi(credentials);
token.value = res.data.token;
user.value = res.data.user;
localStorage.setItem('token', token.value);
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('token');
}
return { token, user, isLoggedIn, login, logout };
});10.2 主题切换
typescript
// stores/theme.ts
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export const useThemeStore = defineStore('theme', () => {
const theme = ref<'light' | 'dark'>('light');
watch(theme, (val) => {
document.documentElement.dataset.theme = val;
}, { immediate: true });
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light';
}
return { theme, toggle };
});10.3 全局通知
typescript
// composables/useNotification.ts
import { ref } from 'vue';
interface Notification {
id: number;
type: 'success' | 'error' | 'info';
message: string;
}
const notifications = ref<Notification[]>([]);
export function useNotification() {
function notify(type: Notification['type'], message: string) {
const id = Date.now();
notifications.value.push({ id, type, message });
setTimeout(() => {
notifications.value = notifications.value.filter((n) => n.id !== id);
}, 3000);
}
return {
notifications,
success: (msg: string) => notify('success', msg),
error: (msg: string) => notify('error', msg),
info: (msg: string) => notify('info', msg)
};
}十一、常见错误
11.1 provide 没用响应式
typescript
// ❌ 不是响应式
provide('theme', 'dark');
// ✅ 用 ref 包装
const theme = ref('dark');
provide('theme', theme);11.2 provide/inject 滥用
// ❌ 不应该用 provide/inject 替代 props
// 父传子一两层用 props 更清晰11.3 mitt 没清理
typescript
// ❌ 忘记清理导致内存泄漏
onMounted(() => bus.on('event', handler));
// ✅ 配对清理
onMounted(() => bus.on('event', handler));
onUnmounted(() => bus.off('event', handler));十二、本章小结
| 通信方式 | 适用场景 |
|---|---|
| Props/Emits | 父子 |
| v-model | 父子双向 |
| 共同父组件 | 兄弟 |
| provide/inject | 跨层级、配置类 |
| Pinia | 全局状态 |
| mitt | 临时事件 |
动手练习
- provide/inject:实现一个全局 toast 通知系统
- Pinia:把现有的全局数据迁移到 Pinia
- 事件总线:用 mitt 实现组件间消息通知
- 混合使用:在一个页面里同时使用 3 种通信方式
推荐阅读
- 📖 Vue 3 provide/inject — 官方文档
- 📖 Pinia 官方文档 — 中文文档
- 🌐 VueUse createGlobalState — 全局状态
下一章:第 120 章:生命周期 →