一个页面拆成几十个组件后,最大的麻烦不是写组件,而是数据怎么在组件之间流转。父组件想让子组件显示自己的数据、子组件操作完要告诉父组件、爷孙组件隔着两层怎么传、两个八竿子打不着的组件要共享登录态……每个场景都有对应的招式,用错了代码就绕。
这篇把 Vue 组件通信的几种方式摆开:各自解决什么问题、怎么写、什么时候用,最后一张对比表收口。下文基于 Vue 3(版本脉络见 Vue 版本演进:Vue 2 与 Vue 3 全对比)。
一、先立规矩:单向数据流
Vue(和 React 一样)推崇单向数据流:数据从父组件流向子组件,子组件不能直接改父组件传下来的数据,只能「汇报」让父组件改。
这个规矩带来两个好处:数据流向清楚(出 bug 顺着链路往上找就行)、数据源唯一(一个数据只有一处会改它,不会多处乱改互相打架)。
二、父子通信:props 向下传,emit 向上抛
最常用、也是唯一绕不开的组合。
<!-- 子组件:AssetCard.vue -->
<script setup>
const props = defineProps({
name: String,
price: Number,
})
const emit = defineEmits(['select'])
// 子组件不能改 props.name,只能抛事件让父组件改
const handleClick = () => emit('select', props.name)
</script>
<template>
<div class="card" @click="handleClick">
<h3>{{ name }}</h3>
<p>¥{{ price }}</p>
</div>
</template><!-- 父组件 -->
<script setup>
import AssetCard from './AssetCard.vue'
const assets = ref([
{ name: 'ThinkPad X1', price: 8999 },
{ name: 'MacBook Pro', price: 12999 },
])
const onSelect = (name) => {
// 收到子组件抛上来的事件,这里改数据
console.log('选中了', name)
}
</script>
<template>
<AssetCard
v-for="item in assets"
:key="item.name"
:name="item.name"
:price="item.price"
@select="onSelect"
/>
</template>两条线:
- 父 → 子:
defineProps声明收什么,模板里:name传过去。只读,别在子组件里给props.name = xxx。 - 子 → 父:
defineEmits声明能抛什么事件,emit('select', name)抛出去,父组件@select接住。
三、v-model:双向绑定的语法糖
表单场景(输入框、下拉框、弹窗开关)频繁用「子组件改一个值,父组件要同步」,写 props + emit 两行代码有点啰嗦,Vue 给了 v-model 语法糖:
<!-- 子组件:PriceInput.vue -->
<script setup>
// modelValue 是约定好的 prop 名
defineProps({ modelValue: Number })
// 约定好的事件名:update:modelValue
const emit = defineEmits(['update:modelValue'])
const onInput = (e) => emit('update:modelValue', Number(e.target.value))
</script>
<template>
<input :value="modelValue" @input="onInput" type="number" />
</template><!-- 父组件:一行搞定,等价于 :modelValue + @update:modelValue -->
<PriceInput v-model="price" />一个组件可以绑多个值,自定义 v-model 参数名(v-model:title 对应 title + update:title)。凡是「子组件收一个值、要改了就上报」的形态,都值得用 v-model 简化——弹窗的 visible 开关、表单控件的值,全是典型场景。
四、跨层级:provide / inject
爷孙组件隔着中间层,一层层 props 传下去很痛苦(中间那层根本不用这个数据,纯中转)。provide/inject 让祖先直接供给后代,跨多少层都行:
<!-- 祖先组件:提供 -->
<script setup>
import { provide, ref } from 'vue'
const tenant = ref('cxtech')
provide('tenant', tenant) // 供给后代,名字自己定
</script><!-- 任意后代组件:注入 -->
<script setup>
import { inject } from 'vue'
const tenant = inject('tenant') // 直接用,中间组件不用管
</script>注意两点:inject 的键名全项目要约定好,否则容易冲突;「谁 provide 的、谁改了数据」对中间组件不可见,只适合少量跨层共享的只读/半只读数据(主题、租户、当前用户),不适合大范围状态管理。
五、全局状态:Pinia 与「事件总线」的过时
真正多组件共享、还要改来改去的状态(登录态、购物车、筛选条件),用 Pinia(Vue 官方推荐,Vuex 的后继者):
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ name: '', role: '' }),
actions: {
login(name, role) {
this.name = name
this.role = role
},
},
})<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
userStore.login('BinMaker', 'admin') // 任何组件都能改、都能读
</script>老项目还能见到「事件总线」(mitt / 空的 Vue.prototype.$bus)——谁都能 $emit 谁都能 $on,调试时你不知道事件从哪来的,项目一大就是灾难。新代码一律用 Pinia,别再开历史倒车。
六、什么时候用哪种:对比表
| 通信场景 | 首选方式 | 原因 |
|---|---|---|
| 父 → 子(直接) | props | 单向数据流,最简单直接 |
| 子 → 父(直接) | emit 事件 | 只汇报,不改父数据 |
| 子组件改一个值、父要同步 | v-model | 语法糖,少写两行 |
| 跨多层(爷 → 孙) | provide/inject | 免去中间层无谓中转 |
| 多组件共享、频繁修改 | Pinia | 全局单一数据源,可调试 |
| 任何「事件总线」方案 | ❌ 不要用 | 来源不可追踪,难维护 |
判断口诀:能就近解决就别拉远——父子能搞定就 props/emit,跨层少量用 provide/inject,全局大状态才上 Pinia。通信方式本身不复杂,复杂的是「数据该放哪」的判断,放错了层级,后面每次改动都要来回传。
响应式怎么让 props 变更自动触发视图更新,见 响应式原理详解;组件代码按功能怎么组织,见 Composition API 详解。
