学完这篇你能拿到一份「按场景选方案」的 Vue 组件通信清单:8 种方式各自怎么写、什么时候用、哪里最容易翻车,对着自己项目里的组件关系直接挑就行。
下面统一使用 Vue 3 + `<script setup>` 语法(Vue 2 用户看对应概念即可)。
第 1 种:props + $emit —— 父子通信的基本盘
方向:父传子用 props,子传父用 emit。
<!-- 父 -->
<Child :title="title" @submit="onSubmit" />
<!-- 子 Child.vue -->
<script setup>
const props = defineProps({ title: { type: String, default: '' } })
const emit = defineEmits(['submit'])
emit('submit', { id: 1 })
</script>
适用:组件层级 ≤ 2 层的常规数据传递,绝大多数业务组件都用这个。
注意:props 是单向数据流,子组件不要直接改 props。如果传的是对象/数组,子组件改内部属性父组件会同步变化,这种「隐式双向」最容易出 bug,要改就 emit 出去让父组件改。
第 2 种:v-model / defineModel —— 表单类双向绑定
Vue 3.4+ 直接一行搞定:
<!-- 子 Input.vue -->
<script setup>
const model = defineModel({ type: String, default: '' })
</script>
<template><input v-model="model" /></template>
<!-- 父 -->
<Input v-model="form.name" />
需要多个绑定就加名字:父写 `<Dialog v-model:visible="show" v-model:title="t" />`,子里写 `defineModel('visible')`、`defineModel('title')`。
适用:自定义输入框、开关、弹窗显隐这类「读写一体」的组件。
注意:Vue 3.4 之前没有 defineModel,要用 `props: ['modelValue']` + `emit('update:modelValue', v)` 手写。参数名必须是 `modelValue` / `update:modelValue` 这套约定。
第 3 种:模板 ref + defineExpose —— 父组件主动调子组件
<!-- 父 -->
<Form ref="formRef" />
<script setup>
const formRef = ref()
function submit() { formRef.value.validate() }
</script>
<!-- 子 Form.vue -->
<script setup>
function validate() { /* ... */ }
defineExpose({ validate })
</script>
适用:打开弹窗、触发表单校验、让输入框聚焦、控制播放器。
注意:`<script setup>` 默认是封闭的,不写 `defineExpose` 父组件拿到的 `ref` 是空对象。另外 `ref` 要等 `onMounted` 之后才有值,在 setup 顶层同步调用会报 undefined。
第 4 种:provide / inject —— 跨层级「隔代传」
<!-- 祖先 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // 建议用 Symbol 当 key 避免重名
</script>
<!-- 任意后代,不用逐层透传 -->
<script setup>
const theme = inject('theme', 'light') // 第二个参数是默认值
</script>
适用:主题、语言、当前登录用户、表单上下文、深层嵌套组件。
注意:inject 拿到的值本身不是响应式的——传 `ref`/`reactive` 才有响应式,传普通字符串就是死值。provide 必须在 setup 里同步调用,放到 `setTimeout` 里会失效。
第 5 种:$attrs 透传 —— 二次封装 UI 组件
<!-- 包装组件 MyInput.vue -->
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<div class="wrap"><input v-bind="$attrs" /></div>
</template>
适用:包一层 Element Plus / Ant Design 的输入框、按钮,把 `placeholder`、`disabled`、`@change` 原样透下去。
注意:事件监听器也放在 `$attrs` 里(形如 `onChange`),所以 `v-bind="$attrs"` 会把事件一起带下去,不用再单独写一遍。加 `inheritAttrs: false` 是为了不让属性重复落在根元素上。
第 6 种:插槽 slot —— 父组件传「结构」给子组件
<!-- 子 Table.vue -->
<template>
<div v-for="row in rows" :key="row.id">
<slot name="cell" :row="row">{{ row.name }}</slot>
</div>
</template>
<!-- 父 -->
<Table :rows="rows">
<template #cell="{ row }"><b>{{ row.name }}</b></template>
</Table>
适用:布局容器、表格列自定义、弹窗底部按钮区。作用域插槽其实是「子传父数据」的另一种正规手段。
注意:默认插槽没内容时,`<slot>` 标签里的内容作为兜底显示;别在插槽外层用 `v-if` 控制同一个插槽的渲染位置,容易出渲染顺序问题。
第 7 种:mitt 事件总线 —— 两个「八竿子打不着」的组件
npm i mitt
// bus.js
import mitt from 'mitt'
export const bus = mitt()
// 发送方
bus.emit('refresh-list', { page: 1 })
// 接收方
onMounted(() => bus.on('refresh-list', handler))
onUnmounted(() => bus.off('refresh-list', handler))
适用:一次性的「通知类」通信,比如保存成功后让另一个面板刷新。
注意:必须在 `onUnmounted` 里 `off`,否则组件销毁后回调还在跑,重复触发 + 内存泄漏。事件多了之后调用链无法追踪,超过两三处就该换 Pinia。
第 8 种:Pinia —— 全局共享状态
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({ name: '', token: '' }),
actions: { async login() { /* ... */ } }
})
<script setup>
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { name } = storeToRefs(userStore) // 解构要用 storeToRefs 才保持响应式
userStore.login()
</script>
适用:用户信息、权限菜单、购物车、跨路由页面共享的状态。
注意:`const { name } = userStore` 直接解构会丢响应式,必须用 `storeToRefs`(方法可以直接解构)。另外别拿 store 当 props 用,只在单个组件内部用的状态就留在组件里。
补充:不在组件树里的通信
跨页面传参用路由 `query` / `params`;跨标签页用 `localStorage` + `storage` 事件或 `BroadcastChannel`。这两类不属于组件通信,别和上面的方案混着用。
小结
- 父子直连:props + emit,永远是最优先的选择。
- 读写一体:v-model / defineModel。
- 父调子:ref + defineExpose,弹窗、校验、聚焦专用。
- 跨层级:provide / inject,超过两层再考虑。
- 包装组件:$attrs 透传;传结构:slot。
- 无关联组件:mitt,记得 off;全局状态:Pinia,解构用 storeToRefs。
- 判断顺序:先问「谁是父子、隔几层、要不要双向、是不是全局」,四个问题答完,方案基本就定了。