Vue 组件通信方式
问题
Vue 组件之间有哪些通信方式?它们各自适用什么场景?
答案
Vue 提供了多种组件通信方式,根据组件关系选择合适的方案:
1. props / emit(父子通信)
最基本的通信方式,单向数据流:父组件通过 props 向下传递数据,子组件通过 emit 向上触发事件。
<!-- Parent.vue -->
<template>
<Child :message="msg" @update="handleUpdate" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';
const msg = ref('Hello');
function handleUpdate(newMsg: string) {
msg.value = newMsg;
}
</script>
<!-- Child.vue -->
<template>
<div>
<p>{{ message }}</p>
<button @click="emit('update', 'New Message')">Update</button>
</div>
</template>
<script setup lang="ts">
// 定义 props
const props = defineProps<{
message: string;
}>();
// 定义 emits
const emit = defineEmits<{
update: [value: string];
}>();
</script>
props 最佳实践
// 使用 TypeScript 定义 props 类型
interface Props {
title: string;
count?: number;
items: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
});
2. v-model(双向绑定)
v-model 是 props + emit 的语法糖,适合表单组件:
<!-- Parent.vue -->
<template>
<!-- v-model 语法糖 -->
<CustomInput v-model="inputValue" />
<!-- 等价于 -->
<CustomInput :modelValue="inputValue" @update:modelValue="inputValue = $event" />
<!-- 多个 v-model -->
<UserForm v-model:name="name" v-model:age="age" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
const inputValue = ref('');
const name = ref('');
const age = ref(0);
</script>
<!-- CustomInput.vue -->
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
<script setup lang="ts">
defineProps<{
modelValue: string;
}>();
defineEmits<{
'update:modelValue': [value: string];
}>();
</script>
<!-- UserForm.vue(多个 v-model) -->
<template>
<input :value="name" @input="$emit('update:name', $event.target.value)" />
<input :value="age" type="number" @input="$emit('update:age', +$event.target.value)" />
</template>
<script setup lang="ts">
defineProps<{
name: string;
age: number;
}>();
defineEmits<{
'update:name': [value: string];
'update:age': [value: number];
}>();
</script>
3. ref / expose(父访问子)
父组件通过 ref 获取子组件实例,子组件通过 defineExpose 暴露方法:
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';
const childRef = ref<InstanceType<typeof Child>>();
function callChildMethod() {
childRef.value?.sayHello();
console.log(childRef.value?.count);
}
</script>
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
function sayHello() {
console.log('Hello from child!');
}
// 显式暴露给父组件
defineExpose({
count,
sayHello
});
</script>
使用限制
<script setup>组件默认不暴露任何内容- 必须使用
defineExpose显式暴露 - 过度使用会破坏组件封装性
4. provide / inject(跨层级通信)
适合深层嵌套组件通信,避免逐层传递 props:
<!-- Grandparent.vue -->
<template>
<Parent />
</template>
<script setup lang="ts">
import { ref, provide } from 'vue';
const theme = ref('dark');
const updateTheme = (newTheme: string) => {
theme.value = newTheme;
};
// 提供数据(可以是响应式的)
provide('theme', theme);
provide('updateTheme', updateTheme);
// 使用 Symbol 作为 key 避免命名冲突
const ThemeKey = Symbol('theme');
provide(ThemeKey, theme);
</script>
<!-- DeepChild.vue(任意深度的后代组件) -->
<template>
<div :class="theme">
<button @click="updateTheme('light')">切换主题</button>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import type { Ref } from 'vue';
// 注入数据
const theme = inject<Ref<string>>('theme', ref('light'));
const updateTheme = inject<(theme: string) => void>('updateTheme', () => {});
</script>
类型安全的 provide/inject
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue';
export interface ThemeContext {
theme: Ref<string>;
updateTheme: (theme: string) => void;
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme');
// Grandparent.vue
import { ThemeKey } from './types/injection-keys';
provide(ThemeKey, {
theme,
updateTheme
});
// DeepChild.vue
import { ThemeKey } from './types/injection-keys';
const themeContext = inject(ThemeKey)!;
// themeContext.theme 和 themeContext.updateTheme 都有类型提示
5. $attrs(透传属性)
用于组件封装,将未声明为 props 的属性透传给子元素:
<!-- BaseButton.vue -->
<template>
<!-- $attrs 包含所有未声明的属性和事件 -->
<button v-bind="$attrs" class="base-button">
<slot />
</button>
</template>
<script setup lang="ts">
// 禁用默认的属性继承
defineOptions({
inheritAttrs: false
});
</script>
<!-- Parent.vue -->
<template>
<!-- disabled 和 @click 会被透传到 button -->
<BaseButton disabled @click="handleClick">
Click me
</BaseButton>
</template>
6. EventBus(任意组件通信)
适合非父子组件之间的通信,Vue 3 推荐使用 mitt 库:
// eventBus.ts
import mitt from 'mitt';
type Events = {
'user:login': { userId: string; name: string };
'user:logout': void;
'message:new': string;
};
export const emitter = mitt<Events>();
<!-- ComponentA.vue -->
<script setup lang="ts">
import { emitter } from './eventBus';
function login() {
emitter.emit('user:login', { userId: '123', name: 'Tom' });
}
</script>
<!-- ComponentB.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { emitter } from './eventBus';
function handleLogin(data: { userId: string; name: string }) {
console.log('User logged in:', data);
}
onMounted(() => {
emitter.on('user:login', handleLogin);
});
onUnmounted(() => {
// 必须清理!
emitter.off('user:login', handleLogin);
});
</script>
EventBus 注意事项
- 必须在组件卸载时移除监听,否则会内存泄漏
- 难以追踪数据流,调试困难
- 推荐只用于简单场景,复杂状态用 Pinia
7. Vuex / Pinia(全局状态管理)
适合复杂应用的状态管理:
// stores/user.ts(Pinia)
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
const name = ref('');
const isLoggedIn = ref(false);
function login(userName: string) {
name.value = userName;
isLoggedIn.value = true;
}
function logout() {
name.value = '';
isLoggedIn.value = false;
}
return { name, isLoggedIn, login, logout };
});
<!-- 任意组件 -->
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
// 直接访问状态
console.log(userStore.name);
// 调用方法
userStore.login('Tom');
</script>
通信方式对比
| 方式 | 适用关系 | 响应式 | 复杂度 | 推荐场景 |
|---|---|---|---|---|
| props/emit | 父子 | ✅ | 低 | 基础通信 |
| v-model | 父子 | ✅ | 低 | 表单组件 |
| ref/expose | 父子 | ❌ | 低 | 调用子组件方法 |
| provide/inject | 跨层级 | ✅ | 中 | 主题、配置、依赖注入 |
| $attrs | 父子 | ✅ | 低 | 组件封装 |
| EventBus | 任意 | ❌ | 中 | 简单跨组件事件 |
| Pinia | 任意 | ✅ | 高 | 全局状态管理 |
常见面试问题
Q1: 父组件如何调用子组件的方法?
答案:
使用 ref + defineExpose:
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';
const childRef = ref<InstanceType<typeof Child>>();
// 调用子组件方法
childRef.value?.validate();
</script>
<!-- Child.vue -->
<script setup lang="ts">
function validate() {
// 验证逻辑
return true;
}
defineExpose({ validate });
</script>
Q2: provide/inject 和 props 有什么区别?
答案:
| 维度 | props | provide/inject |
|---|---|---|
| 传递深度 | 直接父子 | 任意深度 |
| 数据流向 | 单向向下 | 向下(可伪双向) |
| 类型推断 | 自动 | 需要手动指定 |
| 显式依赖 | 是 | 否(隐式) |
| 使用场景 | 组件接口 | 跨层级共享 |
// props:适合组件接口
<UserCard :user="user" />
// provide/inject:适合深层共享
// 主题、国际化、配置等全局性数据
provide('theme', theme);
Q3: Vue 3 为什么移除了 off/$once?
答案:
Vue 3 移除了实例上的事件 API,原因:
- EventBus 难以追踪:数据流不清晰,调试困难
- 容易内存泄漏:忘记移除监听
- 有更好的替代方案:
- 父子通信:props/emit
- 跨组件:provide/inject、Pinia
- 事件总线:使用 mitt 库
// Vue 2
const bus = new Vue();
bus.$on('event', handler);
bus.$emit('event', data);
// Vue 3:使用 mitt
import mitt from 'mitt';
const emitter = mitt();
emitter.on('event', handler);
emitter.emit('event', data);
Q4: v-model 在 Vue 2 和 Vue 3 中有什么区别?
答案:
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 默认 prop | value | modelValue |
| 默认事件 | input | update:modelValue |
| 多个 v-model | ❌(需要 .sync) | ✅ |
| 自定义修饰符 | ❌ | ✅ |
<!-- Vue 2 -->
<CustomInput :value="val" @input="val = $event" />
<CustomInput v-model="val" />
<!-- 多个绑定需要 .sync -->
<UserForm :name.sync="name" :age.sync="age" />
<!-- Vue 3 -->
<CustomInput :modelValue="val" @update:modelValue="val = $event" />
<CustomInput v-model="val" />
<!-- 多个 v-model -->
<UserForm v-model:name="name" v-model:age="age" />
Q5: 如何实现兄弟组件通信?
答案:
兄弟组件通信有几种方式:
// 1. 状态提升到父组件
// Parent.vue
const sharedData = ref('');
<BrotherA :data="sharedData" @update="sharedData = $event" />
<BrotherB :data="sharedData" />
// 2. 使用 Pinia
// stores/shared.ts
export const useSharedStore = defineStore('shared', () => {
const data = ref('');
return { data };
});
// BrotherA.vue BrotherB.vue
const store = useSharedStore();
// 3. EventBus(简单场景)
// BrotherA: emitter.emit('data-change', newData)
// BrotherB: emitter.on('data-change', handler)
推荐:
- 简单场景:状态提升
- 复杂场景:Pinia