组件通信是 Vue 中级开发的核心分水岭。小型项目里随意用 ref 传递数据也能跑起来,但一旦组件层级变深、业务变复杂,选错通信方式会让代码迅速腐化。本文系统梳理 Vue 3 中所有主流通信手段的适用场景、实现原理与常见陷阱。
通信方式全景图
按数据流向,Vue 3 的组件通信可以分为四类:
| 类型 | 手段 | 适用关系 |
|---|---|---|
| 父传子 | props / attrs | 单向数据流 |
| 子传父 | emit / expose | 事件回调 |
| 双向绑定 | v-model | 表单、弹窗 |
| 跨层级 | provide / inject | 深层嵌套 |
| 全局状态 | Pinia | 任意组件 |
一个重要的原则:优先用简单的方向。能用 props 解决的不要上 provide,能局部的不要全局。
一、props:父传子的标准姿势
props 遵循单向数据流,父组件修改数据会自动向下流动,子组件不允许直接修改 props。
<!-- 子组件 UserCard.vue -->
<script setup>
defineProps({
user: {
type: Object,
required: true
},
showAvatar: {
type: Boolean,
default: true
}
})
</script>
<template>
<div class="user-card">
<img v-if="showAvatar" :src="user.avatar" />
<span>{{ user.name }}</span>
</div>
</template>运行时校验与类型推导
纯 JS 项目用对象语法做运行时校验;TS 项目更推荐类型声明方式,编辑器推导更完整:
<script setup lang="ts">
interface User {
id: number
name: string
avatar?: string
}
const props = defineProps<{
user: User
showAvatar?: boolean
}>()
// 带默认值时使用 withDefaults
const props2 = withDefaults(defineProps<{
user: User
showAvatar?: boolean
}>(), {
showAvatar: true
})
</script>常见陷阱:直接修改 props
子组件里改 props 对象的内部属性(如 props.user.name = 'x')虽然不报错(对象是引用传递),但会污染父组件状态,是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。
二、emit:子传父的事件通道
<!-- 子组件 ConfirmDialog.vue -->
<script setup>
const emit = defineEmits(['confirm', 'cancel'])
function handleConfirm() {
emit('confirm', { confirmedAt: Date.now() })
}
</script><!-- 父组件 -->
<ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性
很多同学习惯不声明直接 emit('xxx')。在 <script setup> 中不声明也能用,但显式声明有两个好处:
- 文档化:组件的使用者一眼看清它对外暴露哪些事件
- 事件校验:可以像 props 一样带校验逻辑
const emit = defineEmits({
// 无校验
click: null,
// 带校验
submit: (payload) => {
if (!payload || typeof payload.email !== 'string') {
console.warn('submit 事件必须携带 email 字段')
return false
}
return true
}
})三、v-model:双向绑定的本质
v-model 不是黑魔法,它是 props + emit 的语法糖。
单值绑定
<!-- 子组件 SearchInput.vue -->
<script setup>
const model = defineModel() // Vue 3.4+ 推荐写法
</script>
<template>
<input
:value="model"
@input="model = $event.target.value"
/>
</template><!-- 父组件 -->
<SearchInput v-model="keyword" />等价于老写法:
// Vue 3.0 - 3.3 的写法
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 子组件更新时
emit('update:modelValue', newValue)多值绑定:具名 v-model
一个组件可以绑定多个 v-model,通过参数名区分:
<!-- 子组件 RangePicker.vue -->
<script setup>
const start = defineModel('start')
const end = defineModel('end')
</script>
<template>
<div class="range-picker">
<input v-model="start" type="date" />
<span>至</span>
<input v-model="end" type="date" />
</div>
</template><!-- 父组件 -->
<RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符
v-model 支持自定义修饰符,通过 defineModel 的返回值读取:
<script setup>
const [model, modifiers] = defineModel({
set(value) {
// .trim 修饰符时自动去空格
if (modifiers.trim) return value.trim()
return value
}
})
</script>四、provide / inject:跨层级注入
当组件层级超过两层还硬用 props 层层传递(俗称"props 钻井"),就该用 provide/inject 了。
<!-- 顶层组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
provide('theme', {
theme,
toggleTheme
})
</script><!-- 任意深层后代组件 -->
<script setup>
import { inject } from 'vue'
const { theme, toggleTheme } = inject('theme')
</script>
<template>
<button :class="theme" @click="toggleTheme">切换主题</button>
</template>响应性保持的关键
provide 的值如果是 ref,inject 拿到的就是同一个 ref,响应性天然保持。但如果你这样写:
// ❌ 错误示范:解构后再 provide,丢失响应性
const theme = ref('dark')
provide('theme', theme.value) // 传入的是普通字符串后代组件拿到的是快照值,不会更新。务必传 ref 本身或包含 ref 的对象。
只读注入:防止子组件乱改
子组件可以直接改注入的 ref,这会破坏数据流向。更安全的模式是只暴露方法:
provide('theme', {
theme: readonly(theme), // 只读代理
toggleTheme // 修改必须走方法
})子组件试图改 theme.value 会收到警告。
TS 类型安全注入
provide/inject 的 key 是字符串或 Symbol,容易写错且无类型提示。用 InjectionKey 解决:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
theme: Ref<'dark' | 'light'>
toggleTheme: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// 提供方
provide(ThemeKey, { theme, toggleTheme })
// 注入方:自动推导类型,且默认值类型也被约束
const ctx = inject(ThemeKey)
if (!ctx) throw new Error('ThemeKey 未提供')五、attrs 与 expose:两个容易忽略的通道
$attrs:透传属性
没被声明为 props 的属性会落到 $attrs 上。封装组件时常用它透传原生属性:
<!-- BaseButton.vue -->
<template>
<button class="btn" v-bind="$attrs">
<slot />
</button>
</template>父组件写 <BaseButton type="submit" :disabled="loading"> 时,type 和 disabled 会自动透传到原生 button 上。注意 inheritAttrs: false 的设置场景:根元素有多个时需要手动绑定到正确元素。
expose:受控暴露实例
<script setup> 组件默认是"封闭"的,父组件通过 ref 拿不到内部任何东西,除非显式 expose:
<!-- 子组件 FormModal.vue -->
<script setup>
import { ref, expose } from 'vue'
const formRef = ref(null)
const visible = ref(false)
function open() { visible.value = true }
function close() { visible.value = false }
async function validate() {
return await formRef.value.validate()
}
// 只暴露三个方法,内部状态不泄露
defineExpose({ open, close, validate })
</script><!-- 父组件 -->
<script setup>
const modalRef = ref(null)
modalRef.value.open()
</script>选型决策清单
实际开发中按这个顺序决策:
- 父子相邻 → props + emit,没有例外
- 表单类、弹窗类组件 → v-model(
defineModel) - 三层以上的共享配置(主题、国际化、当前用户) → provide/inject
- 跨页面、跨模块的业务状态 → Pinia
- 任何"绕过去"的冲动(操作 DOM、改 $parent、事件总线)→ 停下来重新设计
总结
- props/emit 是骨架,v-model 是语法糖,provide/inject 是电梯,Pinia 是中枢
defineModel()是 Vue 3.4+ 双向绑定的标准答案- provide 必须传 ref 本身才能保持响应性
InjectionKey让 inject 拥有完整类型推导- expose 让组件对外 API 显式化,避免黑盒
掌握这些通信模式后,你可以应对 90% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。
评论 (0)