Vue 组件通信有哪些方式?

2026年 阅读约 9 分钟 面试指南 · 前端面试

Vue组件通信面试题全覆盖:props/emit、v-model、ref、provide/inject、mitt、attrs、插槽、Pinia等8种方式选型,keep-alive原理、mixin缺点与组合式函数,分三层讲解。

一句话总结

父子用 props/emit,双绑用 v-model,触发方法用 ref,跨级用 provide/inject,任意组件用 Pinia(或 mitt 事件总线),透传用 $attrs,内容分发用插槽。原则:通信层级越浅越好——超过两层还用 props 层层转递(props 钻透)就该换成 provide/inject 或状态管理。

初级理解

最基础的四种:

方式方向适用
props父 → 子传数据(单向数据流)
emit子 → 父上报事件
v-model双向表单/弹窗显隐(语法糖)
ref + defineExpose父 → 子调子组件方法/拿实例
<!-- 父 --> <Child :title="t" @change="onChange" v-model="visible" ref="childRef" /> <!-- 子 Child.vue --> <script setup> defineProps(['title']) const emit = defineEmits(['change', 'update:modelValue']) emit('update:modelValue', false) // v-model 的本质事件 defineExpose({ reset }) // Vue3 里 ref 要拿到必须先暴露</script>
注意:props 是单向数据流——子组件不能直接修改 props(Vue3 会警告),要改就 emit 回父组件,或用 defineModel(Vue 3.4+ 官方双向绑定宏)。

中级深入

跨层级通信:provide/inject 祖先注入、任意后代消费。要传响应式数据,就 provide 一个 ref/computed,后代拿到的是同一个引用;直接 provide 普通值则是快照、不会同步。

// 祖先 const theme = ref('dark') provide('theme', readonly(theme)) // readonly 防止后代乱改 // 后代 const theme = inject('theme', 'light') // 第二参数是默认值

插槽三种:默认插槽、具名插槽 v-slot:name、作用域插槽(子组件把内部数据传给插槽内容:<slot :row="item">)。作用域插槽让"结构由父定、数据由子给"成为可能,表格列自定义渲染就是这么实现的。

keep-alive:缓存组件实例(不销毁),切换时触发 activated/deactivated 而不是重新走 created/mounted。常用参数:include/exclude(按组件名匹配)、max(LRU 淘汰上限,防内存膨胀)。

全局事件总线:Vue3 移除了 $on/$off,$emit 不能再当总线用,社区方案是 mitt。事件总线"谁都能听、来源不可溯",只在无状态管理可用的轻量场景临时使用。

高级拓展

作用域插槽的编译本质:插槽内容会被编译成一个函数(接收子组件传来的参数返回 vnode)。子组件渲染时调用这个函数决定内容——这就是"作用域"的来源,也是它能随子组件数据更新的原因。默认插槽写在内、具名写在外时要注意 v-slot 只能用在 template 上。

$attrs 与透传:$attrs 收集了父组件传入但未声明为 props 的全部属性。深层级组件透传:v-bind="$attrs" 一行把属性接力下去;不希望根节点自动继承就设 inheritAttrs: false(Vue3 里再手动绑定到指定节点)。

mixin 的问题与 composable 替代:mixin 有三大缺点:属性来源不明确(出问题要翻好几个文件)、命名冲突、隐式依赖。组合式函数(useXxx)利用 ES Module 的显式导入导出解决了这三点:

// composable:来源明确、可重命名、无冲突 const { data, loading, run } = useRequest(fetchUser) const { width, isMobile } = useWindowSize() // 内部就是一个普通的 setup 逻辑封装 export function useRequest(fn) { const data = ref(null), loading = ref(false) const run = async (...args) => { loading.value = true; data.value = await fn(...args); loading.value = false } return { data, loading, run } }
面试加分项:给出"通信选型决策树"——父子 props/emit → 双绑 defineModel → 兄弟提到共同父级或上 Pinia → 跨层级 provide/inject → 全局状态 Pinia → 极简事件 mitt。按"层级深度 + 状态归属"讲选型,比背 8 种方式名次更有说服力。

实战场景

场景一:props 钻透(prop drilling)改造

// A → B → C → D 都只是转发 user // 反例:B、C 里写 :user="user" 纯转手 // 正例:A provide,D inject,B/C 完全无感知 provide('user', readonly(user))

场景二:弹窗组件的打开/关闭设计

// 推荐 v-model(Vue 3.4+ 直接用 defineModel) <script setup> const visible = defineModel('visible') const close = () => (visible.value = false) </script> // 父组件:<MyDialog v-model="show" />,子内关闭无需再 emit 自定义事件

场景三:列表页缓存与返回恢复

// 路由出口配缓存 + 组件名匹配 <router-view v-slot="{ Component }"> <keep-alive include="UserList" :max="10"> <component :is="Component" /> </keep-alive> </router-view> // 注意:include 匹配的是组件名,script setup 组件要用 defineOptions({ name: 'UserList' }) 显式声明

面试模拟

Q:provide/inject 和 Vuex/Pinia 怎么选?

A:看状态归属。provide/inject 适合"组件树内"的共享——比如主题、某个业务域的上下文,数据属于这棵子树;Pinia 适合"全局单例"状态——用户信息、权限、购物车。provide/inject 没有调试追踪,跨树使用会失控,所以要用 readonly 包住、只提供"读取接口"或方法。

Q:keep-alive 的缓存原理是什么?max 是怎么淘汰的?

A:keep-alive 是抽象组件,内部用 Map 以 key→vnode 缓存组件实例,渲染时命中缓存直接取回 DOM 不重新创建。max 用 LRU(最近最少使用)策略:命中就把 key 提到最新,超限时淘汰最久未用的缓存并调用其 unmount 钩子。

Q:为什么 Vue3 移除了 $on/$off(事件总线)?还怎么跨组件通信?

A:隐式事件总线让数据流变得不可追踪,和单向数据流的设计哲学冲突。替代方案:组件树内用 provide/inject 或状态管理共享状态;确实需要事件的轻量场景用 mitt;父子场景显式 emit。本质是把"隐式全局订阅"变成"显式依赖声明"。