跳到主要内容

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-modelprops + 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 注意事项
  1. 必须在组件卸载时移除监听,否则会内存泄漏
  2. 难以追踪数据流,调试困难
  3. 推荐只用于简单场景,复杂状态用 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 有什么区别?

答案

维度propsprovide/inject
传递深度直接父子任意深度
数据流向单向向下向下(可伪双向)
类型推断自动需要手动指定
显式依赖否(隐式)
使用场景组件接口跨层级共享
// props:适合组件接口
<UserCard :user="user" />

// provide/inject:适合深层共享
// 主题、国际化、配置等全局性数据
provide('theme', theme);

Q3: Vue 3 为什么移除了 on/on/off/$once?

答案

Vue 3 移除了实例上的事件 API,原因:

  1. EventBus 难以追踪:数据流不清晰,调试困难
  2. 容易内存泄漏:忘记移除监听
  3. 有更好的替代方案
    • 父子通信: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 2Vue 3
默认 propvaluemodelValue
默认事件inputupdate: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

相关链接