Skip to content
第 119 / 250 章前端⏱ 10 分钟阅读

第 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 mitt

7.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/core
typescript
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临时事件

动手练习

  1. provide/inject:实现一个全局 toast 通知系统
  2. Pinia:把现有的全局数据迁移到 Pinia
  3. 事件总线:用 mitt 实现组件间消息通知
  4. 混合使用:在一个页面里同时使用 3 种通信方式

推荐阅读


下一章第 120 章:生命周期

本站基于 VitePress 构建 · 由 Codebook 团队维护