Skip to main content

Vue 组件通信按层级来

Vue 的通信方式比 React 多,选错方式的代价是后期难以追踪数据流。按层级选,不要按习惯选——父子用 props/emits,隔层用 provide/inject,跨整棵组件树用状态库,这是一条最不容易出错的默认路径。

1. props 与 emits​

props 向下、emits 向上是默认答案。关键约束是单向数据流:子组件不应该直接修改 props 对象里的内容,需要「可写」时用 v-model 或 defineModel 把它变成受控写法。截至 2026 年 10 月,defineModel 已在 Vue 3.4 稳定,它把「声明 prop + 监听 update 事件」折叠成一行,返回一个可直接读写的 ref。

1.1 一个组件上可以绑多个​

v-model 本质是 modelValue + update:modelValue 的语法糖,Vue 3 支持在一个组件上绑多个命名 v-model,这也是复杂表单组件最常见的接口形态。

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

<!-- 子组件:defineModel 声明命名模型 -->
<script setup>
const name = defineModel('name')
const age = defineModel('age')
</script>

1.2 父调子方法只作应急​

ref + defineExpose 是父拿子方法的应急手段——父组件通过模板 ref 调用子组件暴露的方法。它破坏了单向流的清晰性,只在封装第三方命令式能力(如聚焦、滚动到某处)时应急,不要滥用。ref + expose 写法:

<script setup>
import { ref } from 'vue'
const inputRef = ref()
// 父组件通过 inputRef.value.focus() 调用子组件暴露的方法
defineExpose({ focus: () => inputRef.value?.focus() })
</script>

需要可写时优先用 v-model / defineModel,而不是直接改 props

2. provide 与 inject​

provide / inject 解决的是「隔了很多层」的传值,但它有两个代价。一是依赖变隐式:看组件代码不知道数据从哪来,中间任何一层都能 inject 到,排查来源困难。二是默认不响应式:传普通值不会更新,必须传 ref、getter 或响应式对象。所以它适合主题、配置、上下文这类「少量且稳定」的数据,不适合承载业务状态。

<!-- 祖先组件:provide 一个响应式 ref -->
<script setup>
import { ref, provide } from 'vue'
const theme = ref('dark')
provide('theme', theme) // 必须传 ref,inject 端才会响应
</script>

<!-- 后代组件:inject 拿到的是同一个响应式对象 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
// theme.value 变化时,这里自动更新
</script>

provide/inject 只适合少量且稳定的数据,业务状态请走状态库

3. 状态库与事件总线​

兄弟组件之间,首选把状态提升到共同父级,再走正常的 props/emits;如果共同父级很远,那就进 Pinia 这类状态库。事件总线(mitt 之类的全局 emitter)只在极小的局部场景可接受——比如两个完全不相关的浮层组件需要解耦通信。它的代价是:事件名散落在各处、触发与监听分离、重构时搜不到引用,中大型项目用多了数据流会变得无法追踪。

3.1 事件名散了就追不回来​

事件总线只用于临时解耦,不要当成通用方案

3.2 模板 ref 与 expose​

插槽(含作用域插槽)本质也是通信——父组件把内容「传」给子组件渲染。作用域插槽特殊在它是反向传值:结构在外(父组件决定怎么渲染),数据在内(子组件把每行数据通过 slot props 交出来)。典型场景是表格组件——组件只负责数据与分页,把「每一列长什么样」的渲染权交给业务方。

<!-- 表格组件:把每行数据通过作用域插槽交出去 -->
<template>
<tr v-for="row in rows" :key="row.id">
<slot name="cell" :row="row" />
</tr>
</template>

<!-- 使用方:决定列如何渲染 -->
<DataTable :rows="rows">
<template #cell="{ row }">
<td>{{ row.name }}</td>
<td>{{ formatPrice(row.price) }}</td>
</template>
</DataTable>

3.3 没声明的属性会落到根上​

attrs 透传(未被声明为 prop 的属性自动落到根元素)是另一种隐式通道,适合封装原生元素,但同样会让数据流不那么显式。

双向绑定现在有了专门的写法,比手写 props + emit 少一半样板:

<script setup>
// 子组件:一条语句同时拿到「读」和「写」
const model = defineModel({ default: '' })
</script>

<template>
<input v-model="model" />
</template>

3.4 按层级定默认路径​

给一条默认顺序,照着排基本不会错:能 props 就 props → 跨两层以上用 provide/inject → 跨组件树用状态库。核心是「先量两个组件隔了几层」再决定方式,而不是顺手就用自己最熟的那个。

另外,全局状态要区分「服务端数据」与「UI 状态」:服务端拉来的列表、详情属于缓存层(请求库的数据缓存更合适),而选中态、弹窗开关、主题这类 UI 状态才放进状态库。把服务端数据塞进全局 store 会造成重复缓存和失效难题。

两个组件相隔几层父子 → props + emits隔一两层 → provide / inject跨整棵树 → 状态库完全无关 → 事件总线代价:隐式依赖,追起来难代价:全局污染,难清理越往上,依赖越隐式、越难追踪——所以默认应该选最靠上的那条路。
图:按层级选通道——能就近解决就不要升级到全局

props 的单向数据流与校验规则见 Vue 官方:Props,判断该用哪种通信方式时先回到这个约束。