Vue组件通信
概述
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传递ref或reactive对象时,后代获取的是响应式引用,数据变更会自动更新- 传递普通值(字符串、数字)则不具备响应式
- 建议将修改逻辑也 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 中。同时 class 和 style 也被纳入 $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 对比
| 维度 | Vuex | Pinia |
|---|---|---|
| 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)。数据流越短、越显式,代码越好维护。