Vue 3 组件通信全解:props、emit、v-model 与 provide/inject

Vue 3 组件通信全解:props、emit、v-model 与 provide/inject

admin
2026-07-08 / 0 评论 / 6 阅读

组件通信是 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> 中不声明也能用,但显式声明有两个好处:

  1. 文档化:组件的使用者一眼看清它对外暴露哪些事件
  2. 事件校验:可以像 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"> 时,typedisabled 会自动透传到原生 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>

选型决策清单

实际开发中按这个顺序决策:

  1. 父子相邻 → props + emit,没有例外
  2. 表单类、弹窗类组件 → v-model(defineModel
  3. 三层以上的共享配置(主题、国际化、当前用户) → provide/inject
  4. 跨页面、跨模块的业务状态 → Pinia
  5. 任何"绕过去"的冲动(操作 DOM、改 $parent、事件总线)→ 停下来重新设计

总结

  • props/emit 是骨架,v-model 是语法糖,provide/inject 是电梯,Pinia 是中枢
  • defineModel() 是 Vue 3.4+ 双向绑定的标准答案
  • provide 必须传 ref 本身才能保持响应性
  • InjectionKey 让 inject 拥有完整类型推导
  • expose 让组件对外 API 显式化,避免黑盒

掌握这些通信模式后,你可以应对 90% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。

0

评论 (0)

取消
0:00