Skip to content

Vue 3 组件通信方式

大白话解释: Vue 3 的组件通信和 Vue 2 类似,但写法更简洁:

  • Props/Emits:父子传值(Vue 3 用 definePropsdefineEmits,不用写 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 mitt
ts
// 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)。常见的包括:classstyleidtitle 等 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保持原生行为

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准