Vue 父子、兄弟与跨层级组件通信
最近几场面试都会问到「组件之间怎么传数据」,尤其是 Vue3 项目里还会顺带问 Pinia、mitt 这些。我把常见几种方式按场景整理了一下,并补了几个在项目里能直接套的小例子。
题目
- 父子组件通信方式
- 兄弟组件通信方式
- 跨多层级组件通信方式
父子组件:最常用的一套
父 → 子:props(Vue3 用 defineProps)。
<!-- Parent.vue -->
<script setup>
import Child from './Child.vue'
const user = { name: '小刘', role: '前端' }
</script>
<template>
<Child :user="user" title="详情" />
</template><!-- Child.vue -->
<script setup>
const props = defineProps({
user: { type: Object, required: true },
title: { type: String, default: '默认标题' }
})
</script>
<template>
<p>{{ title }}:{{ user.name }}</p>
</template>子 → 父:emit 触发自定义事件,父组件监听(defineEmits)。
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['save', 'cancel'])
function onSave() {
emit('save', { ok: true, at: Date.now() })
}
</script><!-- Parent.vue -->
<Child @save="handleSave" />父直接调子组件方法:需要时在子组件里 defineExpose,父组件用 ref 拿实例。适合「打开弹窗」「表单校验」这类命令式操作,别滥用,不然数据和调用链会绕。
<!-- Child.vue -->
<script setup>
import { ref } from 'vue'
const visible = ref(false)
function open() { visible.value = true }
defineExpose({ open })
</script><!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
const childRef = ref(null)
function openDialog() {
childRef.value?.open()
}
</script>
<template>
<Child ref="childRef" />
<button @click="openDialog">打开</button>
</template>v-model:本质是 props + update:xxx 语法糖,表单、开关用得最多。
<!-- 父 -->
<CustomInput v-model="keyword" />
<!-- 等价于 :modelValue="keyword" @update:modelValue="keyword = $event" -->兄弟组件:别硬 props 层层传
两个同级组件都在同一个父下面,不要为了传个值再套一层无意义的中间组件。
常见做法(按项目习惯选)
- 共同父组件:状态提到父级,一个子
props下发,另一个子emit上报(小项目、状态简单时够用)- 事件总线:Vue2 里常见
$bus/ mitt;Vue3 官方更推荐用状态库或 provide/inject,但老项目里 mitt 仍不少- Pinia / Vuex:兄弟其实是「多个组件订阅同一份 store」,面试时说清楚「共享状态 + 单向数据流」即可
父组件做中转的示例:
<!-- Parent.vue:列表 + 详情两个兄弟 -->
<script setup>
import { ref } from 'vue'
import ListPanel from './ListPanel.vue'
import DetailPanel from './DetailPanel.vue'
const activeId = ref(null)
function onSelect(id) {
activeId.value = id
}
</script>
<template>
<ListPanel @select="onSelect" />
<DetailPanel :id="activeId" />
</template>mitt 示例(适合低频、非核心的跨组件通知):
// utils/eventBus.js
import mitt from 'mitt'
export const bus = mitt()<!-- A.vue -->
<script setup>
import { bus } from '@/utils/eventBus'
function notify() {
bus.emit('cart-updated', { count: 3 })
}
</script><!-- B.vue -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { bus } from '@/utils/eventBus'
function onCart(e) {
console.log('收到', e)
}
onMounted(() => bus.on('cart-updated', onCart))
onUnmounted(() => bus.off('cart-updated', onCart))
</script>注意:总线用多了不好排查谁发的,生产里我更倾向 Pinia。
跨多层级:避免 props 钻透
中间层组件如果根本不关心数据,却一直写 props 往下传,就是「prop drilling」,维护很痛苦。
provide / inject(Vue3 推荐,适合主题、权限、表单上下文):
<!-- 祖先 Ancestor.vue -->
<script setup>
import { provide, ref, readonly } from 'vue'
const theme = ref('dark')
// 只读可避免子孙乱改;要改就再 provide 方法
provide('theme', readonly(theme))
provide('setTheme', (v) => { theme.value = v })
</script><!-- 深层后代 DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', ref('light')) // 第二个参数是默认值
const setTheme = inject('setTheme', () => {})
</script>Vue3.3+ 可以用 defineOptions 或把 provide 写成响应式 symbol,大项目里会给 key 起常量,避免字符串撞名。
全局状态 Pinia(跨很多层、还要持久化、调试):
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ token: '', name: '' }),
actions: {
setUser(payload) {
this.token = payload.token
this.name = payload.name
}
}
})任意深度组件 const user = useUserStore() 即可,面试时可以说:和 React Context 类似,但 Pinia 带 devtools、模块化更清晰。
**attrs/透传∗∗:中间层只做布局、不消费props时,可用‘v−bind="attrs"` 继续往下传,减少重复声明。
面试时怎么答(我自己的顺序)
- 父子:
props下、emit上,v-model是语法糖;必要时ref+defineExpose- 兄弟:优先状态上浮到父;复杂用 Pinia;临时通知可用 mitt
- 跨层:
provide/inject管上下文;全局共享用 Pinia;避免无意义逐层 props
再补一句:选型看数据谁拥有、会不会被多处改、要不要时间旅行调试,面试官一般会觉得你不是背题。
写在最后
通信方式没有绝对最优,小公司项目里我见过为了「快」滥用事件总线,后面排查 emit 来源很痛苦。能 Pinia 或父组件集中管理的,我一般会先往那边收一收。
创作不易,转载请注明出处和作者。
