Vue组件通信

13 分钟

概述

Vue 组件通信是面试中的高频考点,也是日常开发中最基础的架构决策之一。不同的通信方式适用于不同的组件关系和数据流向,选错方式会导致代码耦合度升高、维护成本增大。

本文按通信方式逐一拆解原理与用法,最后给出对比表格和选型建议。

Props / Emit:父子通信基石

Props 向下传递

父组件通过 props 向子组件传递数据,这是最基本的单向数据流。

<!-- 父组件 -->
<template>
  <ChildComponent :userName="currentUser" :count="itemCount" />
</template>

<!-- 子组件 -->
<script setup>
const props = defineProps({
  userName: { type: String, required: true },
  count: { type: Number, default: 0 }
})
</script>

关键点

  • Props 是只读的,子组件不应直接修改
  • Vue 对 props 做了浅层响应式代理,对象类型的 prop 修改内部属性虽然"能生效",但违反单向数据流原则
  • Props 验证在开发环境生效,生产环境会被跳过

Emit 向上通知

子组件通过 emit 触发自定义事件,将数据或操作意图传递给父组件。

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'delete'])

function handleClick() {
  emit('update', { id: 1, name: 'new name' })
}
</script>

<!-- 父组件 -->
<template>
  <ChildComponent @update="handleUpdate" @delete="handleDelete" />
</template>

面试追问:props 修改会怎样?

直接赋值会在开发环境触发 warning。如果需要基于 prop 做本地修改,应使用 computed 或拷贝到本地 ref

<script setup>
const props = defineProps({ initialCount: Number })

// 方案一:本地副本
const localCount = ref(props.initialCount)

// 方案二:computed 派生
const doubleCount = computed(() => props.initialCount * 2)
</script>

v-model:双向绑定语法糖

Vue2 实现

Vue2 中 v-model 默认绑定 value prop 和 input 事件,一个组件只能有一个 v-model

<!-- 等价于 -->
<CustomInput :value="msg" @input="msg = $event" />

<!-- 子组件 -->
<script>
export default {
  props: ['value'],
  methods: {
    updateValue(event) {
      this.$emit('input', event.target.value)
    }
  }
}
</script>

Vue2 中若需多个双向绑定,需要使用 .sync 修饰符:

<MyDialog :visible.sync="showDialog" :title.sync="dialogTitle" />

Vue3 实现

Vue3 统一了 v-model.sync,支持多个 v-model,默认 prop 名改为 modelValue,事件改为 update:modelValue

<!-- 父组件:多个 v-model -->
<UserForm v-model:name="userName" v-model:age="userAge" />

<!-- 子组件 -->
<script setup>
const props = defineProps({ name: String, age: Number })
const emit = defineEmits(['update:name', 'update:age'])

function updateName(value) {
  emit('update:name', value)
}
</script>

Vue 3.4+ 引入了 defineModel 宏,进一步简化:

<script setup>
const name = defineModel('name')
const age = defineModel('age')

// 直接赋值即可触发更新
name.value = 'new name'
</script>

面试追问:v-model 的本质是什么?

v-model 是编译时语法糖,编译后等价于 prop + emit 的组合。Vue3 的 defineModel 底层仍然是 defineProps + defineEmits + 一个内部 ref 的封装。

Provide / Inject:跨层级依赖注入

适用于祖先组件向深层后代传递数据,避免逐层 props 透传(prop drilling)。

基本用法

<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
provide('theme', theme)         // 传递响应式数据
provide('appVersion', '2.0.0')  // 传递静态值
</script>

<!-- 深层后代组件 -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme')           // 响应式 ref
const version = inject('appVersion')
const fallback = inject('notExist', 'default value')  // 带默认值
</script>

响应式注意事项

  • provide 传递 refreactive 对象时,后代获取的是响应式引用,数据变更会自动更新
  • 传递普通值(字符串、数字)则不具备响应式
  • 建议将修改逻辑也 provide 出去,保持数据流可追踪:
<!-- 祖先组件 -->
<script setup>
const currentUser = ref(null)

function updateUser(user) {
  currentUser.value = user
}

provide('userContext', {
  user: readonly(currentUser),  // 只读,防止后代直接修改
  updateUser                     // 提供修改方法
})
</script>

面试追问:provide/inject 和 props 如何选择?

  • 层级 ≤ 2 层:用 props,数据流清晰
  • 层级 ≥ 3 层且数据只在特定子树使用:用 provide/inject
  • provide/inject 的缺点是数据来源不够显式,调试时不如 props 直观

$attrs / $listeners:属性透传

Vue2 中的 $attrs 和 $listeners

$attrs 包含父作用域中不被子组件 props 识别的 attribute 绑定;$listeners 包含父作用域中的事件监听器。常用于高阶组件 / 组件封装:

<!-- 二次封装的 Input 组件 -->
<template>
  <div class="custom-input">
    <label>{{ label }}</label>
    <input v-bind="$attrs" v-on="$listeners" />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
  props: ['label']
}
</script>

Vue3 的变化

Vue3 移除了 $listeners,将事件监听器合并到 $attrs 中。同时 classstyle 也被纳入 $attrs

<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
// attrs 包含所有未声明为 props 的属性和事件
</script>

<template>
  <input v-bind="$attrs" />
</template>

设置 defineOptions({ inheritAttrs: false }) 可阻止自动透传到根元素。

Ref / Expose:直接访问子组件

父组件通过 ref 获取子组件实例,直接调用其方法或访问数据。

Vue3 中的 expose 控制

<script setup> 中组件默认不暴露任何内容,需要通过 defineExpose 显式声明:

<!-- 子组件 ChildForm.vue -->
<script setup>
const formData = reactive({ name: '', email: '' })
const errors = ref([])

function validate() {
  errors.value = []
  if (!formData.name.trim()) errors.value.push('姓名不能为空')
  if (!/^\S+@\S+\.\S+$/.test(formData.email)) errors.value.push('邮箱格式不正确')
  return errors.value.length === 0
}

function reset() {
  formData.name = ''
  formData.email = ''
  errors.value = []
}

function getData() {
  return { ...formData }
}

defineExpose({ validate, reset, getData })
</script>

<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import ChildForm from './ChildForm.vue'

const childFormRef = ref(null)
const submitting = ref(false)

async function handleSubmit() {
  if (!childFormRef.value.validate()) return

  submitting.value = true
  const data = childFormRef.value.getData()
  await api.submitForm(data)
  childFormRef.value.reset()
  submitting.value = false
}
</script>

<template>
  <ChildForm ref="childFormRef" />
  <button @click="handleSubmit" :disabled="submitting">提交</button>
</template>

面试追问:什么时候该用 ref 直接调用?

  • 适合命令式操作场景:表单校验、弹窗开关、播放器控制等
  • 不适合数据同步场景——会导致数据流混乱,难以追踪状态变更来源
  • 过度使用 ref 调用是组件设计问题的信号,应优先考虑 props/emit 的声明式方案

EventBus:跨组件事件通信

原理

EventBus 本质是一个发布-订阅模式的事件中心,任意组件都可以向它发布事件或订阅事件,实现解耦通信。

Vue2 中的实现

Vue2 常利用空 Vue 实例作为 EventBus:

// eventBus.js
import Vue from 'vue'
export const EventBus = new Vue()

// 组件 A:发布事件
EventBus.$emit('data-updated', payload)

// 组件 B:订阅事件
EventBus.$on('data-updated', (payload) => {
  // 处理
})

// 组件销毁时必须手动解绑
beforeDestroy() {
  EventBus.$off('data-updated')
}

Vue3 中的替代方案:mitt

Vue3 移除了实例上的 $on$off$once,官方推荐使用第三方库 mitt:

// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()

// 组件 A
emitter.emit('data-updated', { id: 1 })

// 组件 B
import { onUnmounted } from 'vue'

function handler(payload) {
  console.log(payload)
}

emitter.on('data-updated', handler)

onUnmounted(() => {
  emitter.off('data-updated', handler)
})

面试追问:EventBus 有什么问题?

  • 事件名是字符串,没有类型约束,容易拼错且难以追踪调用链
  • 忘记 off 会导致内存泄漏和重复执行
  • 项目规模增大后,事件满天飞会让数据流变得不可预测
  • 适合小型项目或少量跨组件通知场景,中大型项目应使用状态管理

Vuex / Pinia:全局状态管理

Vuex 核心概念

Vuex 采用单一状态树,通过 state → getters → mutations → actions 的严格流程管理状态:

// store.js (Vuex 4)
import { createStore } from 'vuex'

export const store = createStore({
  state: () => ({
    userList: [],
    currentUser: null
  }),
  getters: {
    activeUsers: (state) => state.userList.filter(u => u.active)
  },
  mutations: {
    SET_USER_LIST(state, users) {
      state.userList = users
    }
  },
  actions: {
    async fetchUsers({ commit }) {
      const users = await api.getUsers()
      commit('SET_USER_LIST', users)
    }
  }
})

Pinia:Vue3 时代的首选

Pinia 是 Vue 官方推荐的状态管理库,API 更简洁,天然支持 TypeScript 和 Composition API:

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const userList = ref([])
  const currentUser = ref(null)

  const activeUsers = computed(() => userList.value.filter(u => u.active))

  async function fetchUsers() {
    userList.value = await api.getUsers()
  }

  return { userList, currentUser, activeUsers, fetchUsers }
})

// 组件中使用
const userStore = useUserStore()
await userStore.fetchUsers()
console.log(userStore.activeUsers)

Pinia vs Vuex 对比

维度VuexPinia
mutations必须通过 mutation 修改 state无 mutations,直接修改
TypeScript需要额外类型声明天然类型推导
模块化namespace modules,嵌套复杂扁平化 store,互相引用简单
体积~6KB~1KB
DevTools支持支持
SSR需要额外配置内置支持

面试追问:什么时候需要状态管理?

  • 多个不相关组件需要共享同一份数据
  • 数据需要在路由切换后保持
  • 需要时间旅行调试、状态持久化等高级能力
  • 如果只是父子/兄弟间的简单通信,不需要引入全局状态管理

通信方式对比总结

通信方式适用关系数据流向优点缺点
props/emit父子单向数据流清晰,易追踪多层传递繁琐
v-model父子双向语法简洁仅适合表单类场景
provide/inject祖先→后代自上而下跨层级不需逐层传递来源不显式,调试困难
$attrs/$listeners父子透传高阶组件封装利器只能向下透传
ref/expose父→子命令式适合触发子组件动作耦合度高,不适合数据同步
EventBus/mitt任意任意完全解耦难追踪,易泄漏
Vuex/Pinia任意全局集中管理,DevTools 支持引入额外复杂度

选型建议

按组件关系选择

  • 父子通信:优先 props/emit,表单场景用 v-model
  • 跨 2+ 层级:provide/inject
  • 兄弟组件:通过共同父组件中转,或使用状态管理
  • 任意组件:Pinia(中大型项目)、mitt(轻量通知场景)

按项目规模选择

  • 小型项目(< 10 个页面):props/emit + provide/inject 足够
  • 中型项目(10-50 个页面):引入 Pinia 管理共享状态
  • 大型项目(50+ 个页面):Pinia + 明确的 store 拆分规范 + 严格约束组件通信方向

一条核心原则:能用声明式(props/emit/v-model)解决的,不要用命令式(ref);能在局部解决的,不要上升到全局(Pinia)。数据流越短、越显式,代码越好维护。