Vue 3 组件通信方式
大白话解释: Vue 3 的组件通信和 Vue 2 类似,但写法更简洁:
- Props/Emits:父子传值(Vue 3 用
defineProps和defineEmits,不用写this) - Provide/Inject:跨层级传值(爷爷直接给孙子)
- Pinia:全局状态管理(替代 Vuex,更简单)
Vue 3 vs Vue 2 通信方式对比:
- Props/Events:Vue 3 用
defineProps/defineEmits,Vue 2 用props/this.$emit - 状态管理:Vue 3 推荐 Pinia,Vue 2 用 Vuex
- Provide/Inject:Vue 3 更好用,支持响应式
Vue 3 组件间数据传递的完整方案汇总,涵盖父子、跨层级、全局状态等场景。
1. Props / Emits(父子组件)
最常见的父子组件通信方式,Vue 3 中增强了类型支持。
Props
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const message = ref('hello')
const user = ref({ name: '张三', age: 25 })
</script>
<template>
<Child
:msg="message"
:count="42"
:user="user"
:tags="['前端', 'Vue']"
@update="onUpdate"
/>
</template>vue
<!-- 子组件(方式一:基本类型声明)-->
<script setup lang="ts">
const props = defineProps<{
msg: string
count?: number
user: {
name: string
age: number
}
tags: string[]
}>()
console.log(props.msg)
</script>vue
<!-- 子组件(方式二:带默认值,用 withDefaults)-->
<script setup lang="ts">
const props = withDefaults(
defineProps<{
msg: string
count?: number
tags?: string[]
}>(),
{
count: 0,
tags: () => [],
}
)
</script>Emits
vue
<!-- 子组件 -->
<script setup lang="ts">
// 类型声明
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
submit: [data: { name: string; age: number }]
}>()
function handleClick() {
emit('update', 'world')
}
function handleDelete() {
emit('delete', 123)
}
</script>vue
<!-- 父组件 -->
<template>
<Child @update="onUpdate" @delete="onDelete" />
</template>
<script setup lang="ts">
function onUpdate(value: string) {
console.log(value)
}
function onDelete(id: number) {
console.log(id)
}
</script>2. v-model 双向绑定
Vue 3 中 v-model 是 props + update:xxx 事件的语法糖。
单个 v-model
vue
<!-- 父组件 -->
<CustomInput v-model="searchText" />
<!-- 等价于 -->
<CustomInput :modelValue="searchText" @update:modelValue="searchText = $event" />vue
<!-- 子组件 -->
<script setup lang="ts">
const model = defineModel<string>() // Vue 3.4+
// 或手动实现
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="model"
@input="model = ($event.target as HTMLInputElement).value"
/>
</template>多个 v-model
vue
<!-- 父组件 -->
<UserForm
v-model:name="userName"
v-model:age="userAge"
v-model:email="userEmail"
/>vue
<!-- 子组件 -->
<script setup lang="ts">
const name = defineModel<string>('name')
const age = defineModel<number>('age')
const email = defineModel<string>('email')
</script>
<template>
<input v-model="name" />
<input v-model="age" type="number" />
<input v-model="email" type="email" />
</template>带修饰符的 v-model
vue
<!-- 父组件 -->
<CustomInput v-model.trim="text" v-model.number="age" />vue
<!-- 子组件 -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string>('text', {
get(value: string) {
return value
},
set(value: string) {
if (modifiers.trim) return value.trim()
return value
},
})
</script>3. defineExpose(父访问子)
父组件通过 ref 直接调用子组件方法或访问数据。
vue
<!-- 子组件 -->
<script setup lang="ts">
import { ref } from 'vue'
const formRef = ref<HTMLFormElement>()
const internalData = ref('内部数据')
function validate() {
// 表单校验逻辑
return true
}
function reset() {
// 重置表单
}
// 只暴露指定的属性和方法
defineExpose({
validate,
reset,
internalData,
})
</script>vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import ChildForm from './ChildForm.vue'
const formRef = ref<InstanceType<typeof ChildForm>>()
function handleSubmit() {
// 调用子组件方法
const isValid = formRef.value?.validate()
if (isValid) {
// 访问子组件数据
console.log(formRef.value?.internalData)
}
}
function handleReset() {
formRef.value?.reset()
}
</script>
<template>
<ChildForm ref="formRef" @submit="handleSubmit" />
<button @click="handleReset">重置</button>
</template>💡
defineExpose不调用时,默认不暴露任何内部状态。
4. provide / inject(跨层级)
祖孙组件间通信,无需逐层传递 props。
ts
// 祖先组件
import { provide, ref, readonly, type InjectionKey } from 'vue'
// 用 Symbol 作为 key,避免命名冲突
export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')
export const ToggleThemeKey: InjectionKey<() => void> = Symbol('toggleTheme')
const theme = ref('dark')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
// 提供只读数据 + 修改方法
provide(ThemeKey, readonly(theme))
provide(ToggleThemeKey, toggleTheme)ts
// 后代组件(任意层级)
import { inject } from 'vue'
import { ThemeKey, ToggleThemeKey } from './keys'
const theme = inject(ThemeKey, ref('light')) // 第二参数是默认值
const toggleTheme = inject(ToggleThemeKey, () => {})
// 使用
console.log(theme.value) // 'dark'
toggleTheme() // 切换主题provide/inject 的响应式
ts
// ❌ 非响应式 —— 基本类型的值
provide('count', count.value) // 传的是 0,不是 ref
// ✅ 响应式 —— 传 ref 本身
provide('count', count) // 传的是 ref
// ✅ 响应式 —— 传函数
provide('getCount', () => count.value) // 每次调用都获取最新值5. 事件总线(mitt)
任意组件间通信,适合简单场景。
bash
yarn add mittts
// utils/eventBus.ts
import mitt from 'mitt'
// 定义事件类型
type Events = {
'user:login': { id: string; name: string; token: string }
'user:logout': void
'theme:change': 'light' | 'dark'
'notification': { type: 'info' | 'error'; message: string }
}
export const eventBus = mitt<Events>()vue
<!-- 组件 A —— 发送 -->
<script setup lang="ts">
import { eventBus } from '@/utils/eventBus'
function login() {
eventBus.emit('user:login', {
id: '1',
name: '张三',
token: 'xxx',
})
}
</script>vue
<!-- 组件 B —— 接收 -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'
function handleLogin(payload: { id: string; name: string; token: string }) {
console.log(`${payload.name} 登录了`)
}
onMounted(() => {
eventBus.on('user:login', handleLogin)
})
onUnmounted(() => {
eventBus.off('user:login', handleLogin) // 必须取消监听
})
</script>mitt 的通配符监听
ts
// 监听所有事件
eventBus.on('*', (type, payload) => {
console.log(`事件: ${type}`, payload)
})⚠️ 记得在
onUnmounted中取消监听,避免内存泄漏。
6. Pinia(全局状态)
适合多组件共享的复杂状态,详见 Pinia 状态管理。
ts
// stores/user.ts —— 定义 Store
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// state —— 用 ref 定义状态
const token = ref('')
const userInfo = ref<{ name: string; roles: string[] } | null>(null)
// getters —— 用 computed 定义派生状态
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name ?? '游客')
// actions —— 用普通函数定义操作
async function login(credentials: { username: string; password: string }) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
})
const data = await res.json()
token.value = data.token
userInfo.value = data.user
}
function logout() {
token.value = ''
userInfo.value = null
}
return { token, userInfo, isLoggedIn, userName, login, logout }
})vue
<!-- 任意组件中使用 -->
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 直接读取状态
console.log(userStore.userName)
// 调用 action
userStore.login({ username: 'admin', password: '123456' })
</script>
<template>
<div v-if="userStore.isLoggedIn">
欢迎, {{ userStore.userName }}
<button @click="userStore.logout()">退出</button>
</div>
</template>7. $attrs(跨层级透传)
什么是非 props 属性? 父组件传递给子组件的属性中,没有在子组件 defineProps 中声明的部分,就是非 props 属性(non-prop attributes)。常见的包括:class、style、id、title 等 HTML 原生属性,以及未声明的自定义属性和事件监听器。默认情况下,这些属性会自动"透传"到子组件的根元素上,但使用 inheritAttrs: false 可以关闭这一行为,手动控制透传目标。
将父组件的非 props 属性和事件透传给深层子组件。
vue
<!-- 祖先组件 -->
<BaseInput type="text" placeholder="请输入" @focus="onFocus" class="custom" />vue
<!-- 中间组件 BaseInput.vue -->
<script setup lang="ts">
import { useAttrs } from 'vue'
import InnerInput from './InnerInput.vue'
// 不自动应用到根元素
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
// attrs: { type: 'text', placeholder: '请输入', onFocus: fn, class: 'custom' }
</script>
<template>
<div class="wrapper">
<!-- 手动透传到指定元素 -->
<InnerInput v-bind="attrs" />
</div>
</template>8. 兄弟组件通信
方案一:共同父组件中转
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref } from 'vue'
import ChildA from './ChildA.vue'
import ChildB from './ChildB.vue'
const sharedData = ref('')
</script>
<template>
<ChildA @send="sharedData = $event" />
<ChildB :data="sharedData" />
</template>方案二:EventBus / Pinia
ts
// EventBus
eventBus.emit('shared:event', data)
// Pinia
const store = useSharedStore()
store.setData(data)方案选择指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 父 → 子 | props | 最简单,类型安全 |
| 子 → 父 | emits / v-model | 单向数据流 |
| 父访问子 | defineExpose + ref | 直接调用子组件方法 |
| 祖先 → 后代 | provide/inject | 避免逐层传递 props |
| 兄弟组件 | 父组件中转 / Pinia | 按复杂度选择 |
| 任意组件 | Pinia | 大型项目,状态可追踪 |
| 简单事件广播 | mitt | 轻量级事件通信 |
| 包装组件透传 | $attrs / v-bind | 保持原生行为 |