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 穿透)。
问题:
- 中间组件被污染——承担了不属于它的职责
- 修改参数需要同时改 N 个组件
- 增加新的跨层级参数很痛苦
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> | undefined4.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/inject | Pinia | |
|---|---|---|
| 作用范围 | 组件子树(树状) | 全局(平铺) |
| 适用场景 | 组件库内部状态、局部主题 | 跨页面共享的业务数据 |
| 调试 | 结合组件状态和注入链检查 | 专门的 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 通常会合并 |
📐 最佳实践
- 通信方式选择规则:父子 → props/emit;跨层 → provide/inject;全局 → Pinia
- provide 用 readonly:
provide('todos', readonly(todos))防止下层组件意外修改 - InjectionKey 类型安全:用
Symbol()+InjectionKey<T>确保 inject 的类型推断 - defineExpose 加注释:暴露的 API 是组件的"公开接口",要像函数签名一样写文档
Git 提交
bash
git add .
git commit -m "L14: 组件通信全景 - provide/inject/expose/$attrs"🔗 → 下一节
L15 将学习异步组件和 Suspense——当组件需要异步加载或数据获取时,如何优雅地显示 loading 和 error 状态。