Vue 3 组件之间有哪些通信方式?
父传子用 props,子传父用 defineEmits 发事件,双向绑定用 v-model(3.4 起可用 defineModel);没声明的属性和监听器通过透传 attrs 落到根元素;父组件可以用模板 ref 调用子组件通过 defineExpose 暴露的方法;跨多层用 provide/inject;兄弟组件和全局共享状态用 Pinia 或响应式 store。组件事件不会冒泡,Vue 3 也删掉了 $on,EventBus 要换成 mitt 这类库。
简答
做一个商品列表页:父组件把商品数据通过 props 传给子组件
ProductCard,用户点“加入购物车”,子组件用emit('add', id)通知父组件。这是 Vue 3 里最基本的一对通信方式,props 往下,事件往上。在这个基础上还有几种补充:表单类组件用
v-model做双向绑定,Vue 3.4 起子组件里写defineModel()就行;父组件确实要调用子组件的方法时,用模板 ref 拿到子组件,子组件要用defineExpose主动暴露;主题、表单上下文这类要跨好几层的数据,用provide/inject。兄弟组件或者完全不相关的组件,要共享状态时放进 Pinia 或者一个响应式 store。注意组件事件不会冒泡,只有直接父组件能监听到,所以不能指望孙组件的事件传到爷爷组件。
要注意的是方向。数据最好只有一个主人,子组件不直接改 props,
inject进来的数据也尽量交给提供方修改。Vue 3 删掉了实例上的$on、$off,Vue 2 那种 EventBus 写法要换成 mitt 这类库,但能用状态或 props 解决的,就不要用全局事件。
props 往下,事件往上
先看商品卡片怎样拿数据、怎样通知父组件,注意 defineProps 和 defineEmits 都是编译宏,不用导入:
<!-- ProductCard.vue -->
<script setup>
const props = defineProps({
product: { type: Object, required: true },
})
const emit = defineEmits(['add'])
</script>
<template>
<h3>{{ props.product.name }}</h3>
<button @click="emit('add', props.product.id)">加入购物车</button>
</template>
<!-- 父组件 -->
<ProductCard
v-for="item in products"
:key="item.id"
:product="item"
@add="addToCart"
/>
为什么子组件不直接改 product 或者购物车?因为 props 是单向的,子组件改了,父组件和其他用到这份数据的地方就不知道是谁改的。子组件只负责说“用户点了加入”,怎么加由拥有购物车数据的父组件决定,详见《为什么 Vue 提倡 props 单向数据流?》。
defineEmits 必须直接写在 <script setup> 顶层,不能放进函数里。它还支持对象语法,给每个事件写校验函数,或者在 TypeScript 里声明参数类型。
v-model 和 defineModel
输入框、开关这类组件,父组件既要传值又要接收新值。Vue 3.4 起推荐在子组件里用 defineModel():
<!-- SearchInput.vue -->
<script setup>
const keyword = defineModel()
</script>
<template>
<input v-model="keyword" />
</template>
<SearchInput v-model="query" />
defineModel 返回的是一个 ref,看起来子组件在直接改它,这不就违反单向数据流了吗?其实编译器会把它展开成一个叫 modelValue 的 prop 和一个 update:modelValue 事件,子组件改这个 ref 时实际是在发事件,值还是由父组件更新。3.4 之前要手写这对 prop 和事件。需要多个双向绑定时,可以写 v-model:title、v-model:content,子组件里对应 defineModel('title')。原理详见《组件 v-model 的本质是什么?》。
没声明的属性去了哪里
父组件给子组件传了 class、id,或者监听了一个子组件没声明的事件,这些叫透传属性(fallthrough attributes)。子组件只有一个根元素时,它们会自动加到根元素上,class 和 style 会和根元素原有的值合并。
这在封装基础组件时很有用,比如 <MyButton class="large" @click="...">,不用在 MyButton 里一个个声明。不想自动落到根元素,可以设置 inheritAttrs: false,再在模板里用 v-bind="$attrs" 放到指定元素上;在 <script setup> 里用 useAttrs() 读取。Vue 3 把 Vue 2 的 $listeners 合并进了 $attrs,监听器也是透传属性的一部分。
父组件调用子组件的方法
有时候父组件要命令式地操作子组件,比如打开弹窗、重置表单。可以给子组件加模板 ref:
<!-- ResetForm.vue -->
<script setup>
function reset() {
/* 清空字段 */
}
defineExpose({ reset })
</script>
<!-- 父组件 -->
<script setup>
import { useTemplateRef } from 'vue'
const form = useTemplateRef('form')
</script>
<template>
<ResetForm ref="form" />
<button @click="form.reset()">重置</button>
</template>
为什么要 defineExpose?因为用 <script setup> 写的组件默认是封闭的,父组件通过 ref 拿到的实例上什么都访问不到,只有子组件主动暴露的东西才能用。useTemplateRef 是 3.5 新增的;更早的版本要声明一个和 ref 属性同名的 ref(null) 变量。官方建议只在确实需要时这样做,大多数情况用 props 和事件就够了。
跨多层:provide 和 inject
主题、语言、表单上下文要传给很深的后代,每层都写 props 很繁琐。祖先组件 provide,后代组件 inject:
// 祖先组件
const theme = ref('dark')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
provide('theme', { theme: readonly(theme), toggleTheme })
// 任意后代组件
const { theme, toggleTheme } = inject('theme')
提供的是 ref,后代读到的就是响应式的,祖先改了后代自动更新。官方建议把修改留在提供方:后代要改,就调用提供方一起传下来的函数;用 readonly 包一下可以防止后代直接改。大型应用或写给别人用的组件库,注入名最好用 Symbol,避免和别的库冲突。用 app.provide() 可以在应用级别提供,所有组件都能注入,插件常这样用。
兄弟组件和全局状态
组件事件不会冒泡,兄弟组件之间没有直接的通道。常见做法是把状态提升到共同的父组件,或者放进共享的 store。简单场景可以导出一个 reactive() 对象当 store;需要 DevTools 时间线、SSR 支持、TypeScript 类型推导时,用官方推荐的 Pinia。Vuex 现在处于维护模式,新项目建议用 Pinia。
如果确实需要“发一个全局事件”,Vue 3 的实例上已经没有 $on、$off、$once,可以用 mitt、tiny-emitter 这类库,但全局事件难以追踪谁发、谁收,优先考虑用状态表达。
常见追问
孙组件 emit 的事件,爷爷组件能直接监听到吗?
不能。组件事件和原生 DOM 事件不同,不会冒泡,只有直接父组件能监听。要跨层通知,可以让中间组件转发,或者用
provide传一个回调函数下去,或者把状态放进 Pinia。
provide/inject 和 Pinia 怎么选?
provide/inject 跟组件树绑定,适合一棵子树内部共享,比如一个表单组件和它的表单项,不同的子树可以各自提供不同的值。Pinia 是全局单例,适合整个应用共享的状态,比如登录用户、购物车,还带 DevTools 支持。
Vue 3 和 Vue 2 的通信方式有哪些变化?
.sync改成了v-model:xxx,$listeners并进了$attrs,$on/$off被删掉。Vue 3 新增了defineEmits、defineModel、defineExpose这些编译宏,配合<script setup>使用。详见《Vue 2 有哪些组件通信方式?哪些写法在 Vue 3 中被移除或改变?》。