Skip to content

L14 · 组件通信全景:provide/inject + defineExpose ​

🎯 本节目标:掌握 Vue 3 常用组件通信方式,在正确的场景选择正确的方案
📦 本节产出:组件通信方式速查表 + 重构现有通信逻辑 + 可复用 InjectionKey 类型
🔗 前置钩子:L12-L13 的多层组件嵌套(通信痛点真实出现)
🔗 后续钩子:L15 异步组件需要更灵活的通信、L16 自定义指令

1. 通信方式全景图 ​

经历了 L09-L13,我们的组件层级已经很深了。不同层级、不同方向的通信需要不同的方案:


2. 回顾:Props + Emits(L02-L05 已讲) ​

vue
<!-- 父组件 -->
<TodoItem
  :id="todo.id"
  :text="todo.text"
  :done="todo.done"
  @toggle="handleToggle"
  @delete="handleDelete"
/>

<!-- 子组件 -->
<script setup lang="ts">
defineProps<{ id: number; text: string; done: boolean }>()
const emit = defineEmits<{
  toggle: [id: number]
  delete: [id: number]
}>()
</script>

这是最基本、最常用的通信方式。父子通信可以优先从 Props + Emits 开始。

问题在于:当中间组件只负责转发数据时,Props 逐层传递会增加维护成本→


3. Prop Drilling 问题 ​

3.1 问题复现 ​

中间 3 个组件都不需要 theme/locale,却必须接收并转发。 这就是 Prop Drilling(Props 穿透)。

问题:

  1. 中间组件被污染——承担了不属于它的职责
  2. 修改参数需要同时改 N 个组件
  3. 增加新的跨层级参数很痛苦

4. provide / inject ​

4.1 基本用法 ​

typescript
// 祖先组件 provide
import { provide, ref } from 'vue'

const theme = ref<'light' | 'dark'>('dark')
const locale = ref('zh-CN')

provide('theme', theme)    // key, value
provide('locale', locale)
typescript
// 任意后代组件 inject
import { inject, ref, type Ref } from 'vue'

const theme = inject<Ref<'light' | 'dark'>>('theme')

// 提供默认值(当没有祖先 provide 时)
const themeWithFallback = inject('theme', ref<'light' | 'dark'>('light'))

中间组件完全不需要参与——provide 可以穿越任意层级。

4.2 类型安全的 InjectionKey ​

用字符串 key 的问题:类型不安全,容易拼写出错。

typescript
// src/types/injection-keys.ts
import type { InjectionKey, Ref, DeepReadonly } from 'vue'

// 简单场景:只传 Ref
export const LocaleKey: InjectionKey<Ref<string>> = Symbol('locale')
export const UserKey: InjectionKey<Ref<{ name: string; role: string } | null>> = Symbol('user')

// 复杂场景:传数据 + 方法的组合(§4.3 使用)
export interface ThemeContext {
  theme: DeepReadonly<Ref<'light' | 'dark'>>
  toggleTheme: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
typescript
// provide(类型安全)
import { provide, ref } from 'vue'
import { LocaleKey } from '@/types/injection-keys'
const locale = ref('zh-CN')
provide(LocaleKey, locale) // 类型匹配 Ref<string>
typescript
// inject(自动类型推断)
// 以下在后代组件中使用
import { inject } from 'vue'
import { LocaleKey } from '@/types/injection-keys'
const locale = inject(LocaleKey)
// 类型自动推断为 Ref<string> | undefined

4.3 provide 响应式数据 + 方法 ​

typescript
// App.vue — provide 不仅可以传数据,也可以传方法
import { provide, readonly, ref } from 'vue'
import { ThemeKey } from '@/types/injection-keys'

const theme = ref<'light' | 'dark'>('light')

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// 同时 provide 数据和修改方法
provide(ThemeKey, {
  theme: readonly(theme),      // DeepReadonly<Ref>,防止后代直接篡改
  toggleTheme,                 // 修改必须通过提供的方法
})  // ✅ 类型与 ThemeKey: InjectionKey<ThemeContext> 一致
typescript
// 后代组件 inject
import { inject } from 'vue'
import { ThemeKey } from '@/types/injection-keys'
const context = inject(ThemeKey)
if (!context) throw new Error('当前组件需要 Theme Provider')
const { theme, toggleTheme } = context
// theme 是 readonly → 后代只能读不能写
// toggleTheme 是唯一的修改入口

设计原则: provide 响应式数据时,用 readonly() 包装,只暴露方法来修改。这保持了清晰的数据流——谁 provide 谁管数据。

4.4 provide/inject vs Pinia ​

provide/injectPinia
作用范围组件子树(树状)全局(平铺)
适用场景组件库内部状态、局部主题跨页面共享的业务数据
调试结合组件状态和注入链检查专门的 Store / Action 调试集成
持久化需要手动插件支持
测试测试时提供对应注入值为每个测试创建独立 Pinia

选型建议:

  • 需要 DevTools 调试、持久化、跨页面 → Pinia
  • 组件库内部、局部配置注入 → provide/inject

5. defineExpose:父组件访问子组件方法 ​

5.1 使用场景 ​

有时父组件需要命令式地调用子组件的方法——例如让输入框聚焦、让表单重置、让组件刷新。

vue
<!-- 子组件 TodoInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const inputRef = ref<HTMLInputElement>()
const inputValue = ref('')
const emit = defineEmits<{ add: [text: string] }>()

function handleAdd() {
  const text = inputValue.value.trim()
  if (!text) return
  emit('add', text)
  inputValue.value = ''
}

function focus() {
  inputRef.value?.focus()
}

function clear() {
  inputValue.value = ''
}

function getValue() {
  return inputValue.value
}

// ⚠️ <script setup> 组件默认是封闭的——父组件拿不到任何内部绑定
// 必须用 defineExpose 显式暴露
defineExpose({
  focus,
  clear,
  getValue,
})
</script>

<template>
  <div class="todo-input-wrapper">
    <input ref="inputRef" class="todo-input" v-model="inputValue" @keydown.enter="!$event.isComposing && handleAdd()" placeholder="添加任务..." />
    <button type="button" class="add-btn" @click="handleAdd" :disabled="!inputValue.trim()">添加</button>
  </div>
</template>
vue
<!-- 父组件 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import TodoInput from '@/components/todo/TodoInput.vue'

// 用 ref 获取子组件实例
const todoInputRef = ref<InstanceType<typeof TodoInput>>()

onMounted(() => {
  // 页面加载后自动聚焦
  todoInputRef.value?.focus()
  window.addEventListener('keydown', handleKeyboardShortcut)
})
onUnmounted(() => window.removeEventListener('keydown', handleKeyboardShortcut))

function handleKeyboardShortcut(e: KeyboardEvent) {
  if (!e.isComposing && e.altKey && e.key.toLowerCase() === 'n') {
    e.preventDefault()
    todoInputRef.value?.focus()
  }
}

function handleReset() {
  todoInputRef.value?.clear()
}
</script>

<template>
  <TodoInput ref="todoInputRef" />
  <button @click="handleReset">清空</button>
</template>

示例快捷键为 Alt+N;需在目标系统上检查冲突。接入 HomeView 时保留原来的 @add="addTodo",并保留 TodoInput 的既有样式。这里仅增加公开方法,不移除原有添加事件。

5.2 InstanceType 类型技巧 ​

typescript
// 获得子组件暴露的方法的类型
const inputRef = ref<InstanceType<typeof TodoInput>>()

// 这样 inputRef.value?.focus() 就有类型提示和检查

5.3 何时用 defineExpose vs emit ​

场景推荐方案
子组件通知父组件"发生了什么"emit(声明式)
父组件命令子组件"做某件事"defineExpose(命令式)
表单提交emit('submit', data)
表单重置 / 聚焦 / 滚动到位defineExpose({ reset, focus })

6. $attrs 透传 ​

6.1 场景:封装原生元素 ​

当你封装一个组件库的按钮时,使用者可能传入 disabled、aria-label、@click 等原生属性。你不可能在 Props 里声明所有可能的原生属性。

vue
<!-- BaseButton.vue -->
<script setup lang="ts">
withDefaults(defineProps<{
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
}>(), { variant: 'primary', size: 'md' })

defineOptions({ inheritAttrs: false })

// 没有在 Props 中声明的属性自动放入 $attrs
// 包括未在 emits 中声明的事件监听器
</script>

<template>
  <button
    :class="['base-btn', `btn-${variant}`, `btn-${size}`]"
    v-bind="$attrs"
  >
    <slot />
  </button>
</template>
vue
<!-- 使用 -->
<BaseButton
  variant="primary"
  size="lg"
  disabled
  aria-label="保存更改"
  @click="save"
  @mouseenter="showTooltip"
>
  保存
</BaseButton>

<!--
  variant, size → Props(组件自己处理)
  disabled, aria-label, @click, @mouseenter → $attrs → 透传到 <button>
-->

6.2 useAttrs() 在 script setup 中使用 ​

typescript
import { useAttrs } from 'vue'

const attrs = useAttrs()
console.log(attrs) // { disabled: true, 'aria-label': '保存更改', onClick: fn, ... }

7. 通信方式速查表 ​

场景推荐方案为什么
父 → 子传数据Props最直接,类型安全
子 → 父通知事件emit / defineEmits保持单向数据流
表单双向绑定v-model / defineModel语法糖,简洁
祖先 → 任意后代provide / inject + InjectionKey避免 prop drilling
不相关组件共享数据Pinia Store全局单例,DevTools
父命令式调用子方法ref + defineExpose聚焦/重置等场景
封装透传原生属性$attrs + v-bind组件库标准做法
极端解耦(不推荐)mitt 事件总线调试困难,最后手段

useAttrs() 返回对象会随组件更新反映最新属性,但不是可 watch 的响应式对象;需要观察某个输入时应把它声明为 prop。注入和属性透传规则见 provide/inject 与 透传 Attributes。

8. 本节总结 ​

检查清单 ​

  • [ ] 能解释 Prop Drilling 问题以及什么时候它会出现
  • [ ] 能用 provide/inject 实现跨层级通信
  • [ ] 能用 InjectionKey + Symbol 实现类型安全的注入
  • [ ] 知道 provide 响应式数据时用 readonly 包装的原因
  • [ ] 能用 defineExpose 暴露子组件方法给父组件
  • [ ] 能用 InstanceType<typeof Component> 获取组件类型
  • [ ] 能用 $attrs + v-bind 透传原生属性
  • [ ] 能根据场景在速查表中选择正确的通信方式

🐞 防坑指南 ​

坑说明正确做法
provide 非响应式值provide('count', count.value) 传了快照provide('count', count) 传 ref 对象
必需的注入缺失没有对应 provider 时返回 undefined;InjectionKey 不会自动补值可选依赖设默认值,必需依赖显式检查并报错
defineExpose 暴露太多整个组件内部都能被父组件操控只暴露必要的方法(reset/validate)
多根组件未指定透传位置Vue 无法决定 attrs 应落在哪个根元素用 inheritAttrs: false 与 v-bind="$attrs" 指定位置;class/style 通常会合并

📐 最佳实践 ​

  1. 通信方式选择规则:父子 → props/emit;跨层 → provide/inject;全局 → Pinia
  2. provide 用 readonly:provide('todos', readonly(todos)) 防止下层组件意外修改
  3. InjectionKey 类型安全:用 Symbol() + InjectionKey<T> 确保 inject 的类型推断
  4. defineExpose 加注释:暴露的 API 是组件的"公开接口",要像函数签名一样写文档

Git 提交 ​

bash
git add .
git commit -m "L14: 组件通信全景 - provide/inject/expose/$attrs"

🔗 → 下一节 ​

L15 将学习异步组件和 Suspense——当组件需要异步加载或数据获取时,如何优雅地显示 loading 和 error 状态。