在 Vue3 的日常开发中,组件间的数据传递与通信是最基本的操作。面对不同的组件关系(父子、祖孙、兄弟、任意组件)和不同的交互需求(单向、双向、共享状态、跨层级透传),Vue3 提供了丰富而灵活的传参方案。本文将对 Vue3 中常见的传参方式进行系统梳理,对比它们的特性和适用场景,帮助你根据实际情况做出最合适的选择。
一、父子组件通信
1. Props ------ 父传子
父组件通过自定义属性向子组件传递数据,子组件用 defineProps 声明接收。
vue
<!-- 父组件 -->
<Child :title="pageTitle" :count="10" />
<!-- 子组件 -->
<script setup>
const props = defineProps({
title: String,
count: Number
})
</script>
<template>
<h1>{{ props.title }}</h1>
</template>
- 优点:单向数据流,清晰可追踪,是 Vue 官方推荐的基础传参方式。
- 缺点:只能父传子;当需要层层传递时,中间组件也会被迫接收无关属性。
- 场景:绝大部分父组件向直接子组件传递数据的场景。
2. Emits ------ 子传父
子组件通过 defineEmits 声明事件,触发时将数据作为参数回传给父组件。
vue
<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'delete'])
function handleClick() {
emit('update', { id: 1, value: 'new' })
}
</script>
<!-- 父组件 -->
<Child @update="handleUpdate" />
- 优点:符合单向数据流,事件命名清晰,易于维护。
- 缺点:只能子传父,跨多级通信时代码会变冗长。
- 场景:子组件通知父组件发生了某个行为,并传递附属数据。
3. v-model ------ 双向绑定
Vue3 支持多个 v-model 绑定,本质是 prop + emit 的语法糖。子组件通过 defineProps 接收 modelValue,通过 defineEmits 触发 update:modelValue。
vue
<!-- 父组件 -->
<CustomInput v-model="text" v-model:visible="show" />
<!-- 子组件 -->
<script setup>
const props = defineProps({
modelValue: String,
visible: Boolean
})
const emit = defineEmits(['update:modelValue', 'update:visible'])
function updateValue(e) {
emit('update:modelValue', e.target.value)
}
</script>
- 优点:语义化的双向绑定,简洁高效;支持多个 v-model 和自定义修饰符。
- 缺点:仍是父子通信的延伸,不适用于跨层级。
- 场景:表单控件、弹窗显隐、任何需要子组件即时同步数据到父组件的场景。
4. Ref + defineExpose ------ 父组件直接访问子组件
父组件通过 ref 获取子组件实例,子组件通过 defineExpose 暴露方法和数据。
vue
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
const childRef = ref(null)
function focusInput() {
childRef.value?.focus()
}
</script>
<template>
<Child ref="childRef" />
</template>
<!-- 子组件 -->
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
function focus() {
inputRef.value?.focus()
}
defineExpose({ focus })
</script>
- 优点:父组件可以命令式地调用子组件方法,比事件更直接。
- 缺点:破坏了组件封装性,增加了耦合;只能用于父子之间。
- 场景:需要父组件集中控制子组件行为(如调用子组件内部方法、手动获取表单值等),且没有更优雅的声明式替代方案时。
二、祖孙 / 跨层级通信
5. provide / inject ------ 依赖注入
祖先组件通过 provide 提供数据,任意后代组件通过 inject 接收。
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 可设默认值
</script>
- 优点:任意跨层级传递,无需中间组件参与;响应式数据依然保持响应。
- 缺点:数据来源不够直观,依赖注入的 key 可能冲突;过度使用会导致组件间关系模糊。
- 场景:全局主题、语言、用户信息、表单禁用状态等需要跨层级共享的数据。
6. 透传 Attributes ($attrs)
父组件传递给子组件、但子组件未声明为 props 的属性,会被自动透传到子组件的根元素上。可通过 useAttrs() 获取这些属性。
vue
<!-- 父组件 -->
<Child class="box" data-test="hello" />
<!-- 子组件 -->
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.class) // 'box'
</script>
<template>
<div v-bind="attrs">...</div>
</template>
- 优点:简化了"中间组件转发属性"的模板代码;适合构建二次封装组件。
- 缺点:只能传递属性,不适合传递复杂逻辑数据;多层透传时调试困难。
- 场景:封装 UI 组件库时,将未识别的属性和事件自动传递给底层原生元素或组件。
三、全局状态管理
7. Pinia(或 Vuex)
将共享状态抽离到全局 Store 中,任意组件都可以直接读写。
js
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() { count.value++ }
return { count, increment }
})
vue
<!-- 任意组件 -->
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<span>{{ counter.count }}</span>
<button @click="counter.increment()">+</button>
</template>
- 优点:真正解耦,任意层级、任意关系的组件都能共享数据和逻辑;支持 DevTools 调试。
- 缺点:引入了额外的库和概念,简单场景下可能过于繁重。
- 场景:多个组件(特别是非父子关系的)需要共享同一份状态,如用户登录状态、购物车数据、多步骤表单中间数据等。
8. 组合式函数(Composables)共享状态
将响应式状态和逻辑抽取到一个组合函数中,利用 Vue 的模块单例特性实现共享。
js
// composables/useSharedCount.js
import { ref } from 'vue'
const count = ref(0) // 模块作用域单例
export function useSharedCount() {
function increment() { count.value++ }
return { count, increment }
}
vue
<!-- 任意组件 -->
<script setup>
import { useSharedCount } from '@/composables/useSharedCount'
const { count, increment } = useSharedCount()
</script>
- 优点:比 Pinia 更轻量,适合简单共享状态;与组合式 API 天然契合。
- 缺点:无 DevTools 支持,不方便持久化和中间件;状态难以被手动清理;对服务端渲染需要额外处理。
- 场景:简单的跨组件共享,如某几个邻近组件的临时状态、无需严格管理的全局变量。
四、其他常见传参方式
9. 事件总线(mitt / tiny-emitter)
Vue3 移除了 $on/$off,可引入第三方库如 mitt 实现任意组件间的发布-订阅通信。
- 优点:完全不关心组件层级,任意组件可触发和监听事件。
- 缺点:全局事件容易造成"事件爆炸",难以追踪数据流;不利于类型推导;组件销毁时需要手动解绑。
- 场景:极少组件间的松耦合通信,或遗留项目迁移时的过渡方案。新项目不推荐优先使用。
10. 路由传参 (Vue Router)
通过路由参数、查询参数或状态传递数据。
- 优点:可在 URL 上持久化部分状态,方便分享和收藏;页面间解耦。
- 缺点:仅适用于路由跳转场景,参数大小受限。
- 场景:页面间传递标识符(ID)、过滤条件等;需要从 URL 直接定位的状态。
11. 插槽(作用域插槽)
父组件通过作用域插槽接收子组件暴露的数据,实现数据回传渲染。
vue
<!-- 子组件 -->
<template>
<slot :data="list" :loading="isLoading" />
</template>
<!-- 父组件 -->
<Child>
<template v-slot="{ data, loading }">
<Spinner v-if="loading" />
<List :items="data" />
</template>
</Child>
- 优点:将渲染控制权交给父组件,但数据仍由子组件提供;非常灵活。
- 缺点:只适用于父子关系,且逻辑偏向 UI 层的组合。
- 场景:列表渲染、容器组件的动态布局、任何需要"数据在子组件,但渲染方式由父组件决定"的情形。
五、传参方式对比总结
| 传参方式 | 方向/范围 | 响应式 | 耦合度 | 适用场景 |
|---|---|---|---|---|
| props / emits | 父↔子 | ✅ | 低 | 标准父子通信,单向/事件通知 |
| v-model | 父↔子(双向) | ✅ | 低 | 表单输入、组件状态即时同步 |
| ref + defineExpose | 父→子 | ✅ | 中 | 父组件命令式控制子组件行为 |
| provide / inject | 祖先→后代 | ✅ (引用) | 中 | 跨层级共享主题、配置等 |
| $attrs | 父→子(透传) | ❌ | 低 | 封装基础组件,透传 HTML 属性 |
| Pinia / Vuex | 任意组件 | ✅ | 极低 | 全局状态管理,多组件共享复杂状态 |
| Composables | 任意组件 | ✅ | 极低 | 轻量级跨组件共享状态或逻辑 |
| 事件总线 | 任意组件 | ❌ | 极低 | 极少数松耦合通信(不推荐) |
| 路由传参 | 跨页面 | ❌ | 低 | 页面跳转携带 id、查询参数等 |
| 作用域插槽 | 子→父(数据) | ✅ | 低 | 自定义子组件内容的渲染逻辑 |
六、如何选择?
没有银弹。实际开发中可以遵循以下准则:
- 首选最局部的方式 :父子通信优先用
props+emits;需要双向绑定就用v-model,避免过早使用全局状态。 - 跨层级但不频繁变动的数据 ,用
provide/inject,再配合readonly或回调限定修改权限。 - 多组件共享同一业务状态 ,毫不犹豫使用 Pinia,它的结构化管理和调试能力会省去大量排查时间。
- 封装基础组件 时,通过
useAttrs()和v-bind="$attrs"透传属性,保持灵活性。 - 避免事件总线,除非你十分清楚自己在做什么,且能将全局事件控制在一个极小范围内。
理解每种方式的边界和开销,能让你在编写组件时更加游刃有余。Vue3 提供的组合式 API 配合其丰富的通信机制,几乎能够优雅地解决所有组件通信难题。