Vue 3 跨层通信:模板引用、provide/inject 与事件总线
Vue 3 跨层通信:模板引用、provide/inject 与事件总线
系列导航:Vue 3 现代开发指南
上一篇:组件契约:Props、Emits、defineModel与插槽
下一篇:Vue Router 5
大多数组件通信都应先使用 Props、Emits、v-model 和插槽。只有当需求确实越过普通父子契约时,才需要模板引用、provide/inject、Pinia 或事件总线。选择标准不是“哪个 API 最省代码”,而是谁拥有状态、谁能修改状态,以及依赖是否足够明确。
一、Vue 3.5 使用 useTemplateRef()
模板引用适合访问 DOM 或组件公开的方法。Vue 3.5 引入的 useTemplateRef() 比“变量名必须和 ref 字符串同名”的旧写法更清楚,也能得到更好的类型推断。
访问输入框:
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const searchInput = useTemplateRef<HTMLInputElement>('search-input')
onMounted(() => {
searchInput.value?.focus()
})
</script>
<template>
<input ref="search-input" type="search" />
</template>
引用值在挂载前是 null,元素被 v-if 卸载后也会回到 null,所以访问时要使用可选链或类型守卫。
旧项目常见写法仍然有效:
const searchInput = ref<HTMLInputElement | null>(null)
新代码优先使用 useTemplateRef(),除非项目需要兼容 Vue 3.4 或更早版本。
二、组件引用只暴露必要能力
使用 <script setup> 的组件默认是私有的。子组件通过 defineExpose() 显式声明公开 API。
子组件 UserForm.vue:
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({ name: '', email: '' })
function validate() {
return form.name.trim() !== '' && form.email.includes('@')
}
function reset() {
form.name = ''
form.email = ''
}
defineExpose({ validate, reset })
</script>
<template>
<input v-model="form.name" placeholder="姓名" />
<input v-model="form.email" type="email" placeholder="邮箱" />
</template>
父组件:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import UserForm from './UserForm.vue'
const userForm = useTemplateRef<InstanceType<typeof UserForm>>('user-form')
function submit() {
if (userForm.value?.validate()) {
console.log('可以提交')
}
}
</script>
<template>
<UserForm ref="user-form" />
<button type="button" @click="submit">提交</button>
</template>
组件引用适合“聚焦、滚动、播放、校验、重置”等命令式能力,不适合把子组件内部状态全部暴露给父组件。能通过 Props 和 Emits 表达的数据流,仍应优先使用显式契约。
三、不要把 $parent 和 $refs 当状态管理
通过 $parent 修改父组件,或遍历 $refs 批量修改子组件,看似直接,却把组件和当前树结构绑死:一旦中间增加包装组件、重构布局或异步加载,关系就可能失效。
推荐替代方式:
- 父子数据:Props、Emits、
defineModel(); - 祖先提供能力:
provide/inject; - 跨页面业务状态:Pinia;
- 少量命令式组件 API:模板引用 +
defineExpose()。
四、类型安全的 provide/inject
provide/inject 适合一棵组件子树共享上下文,例如表单、主题、当前用户能力或组件库内部状态。使用 InjectionKey 能让提供方和注入方共享类型。
先定义上下文:
// context/counter.ts
import type { InjectionKey, Ref } from 'vue'
export interface CounterContext {
count: Readonly<Ref<number>>
increment: () => void
}
export const counterKey: InjectionKey<CounterContext> = Symbol('counter')
祖先组件提供只读状态和修改动作:
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { counterKey } from '@/context/counter'
const count = ref(0)
function increment() {
count.value++
}
provide(counterKey, {
count: readonly(count),
increment,
})
</script>
<template>
<slot />
</template>
任意后代组件注入:
<script setup lang="ts">
import { inject } from 'vue'
import { counterKey } from '@/context/counter'
const counter = inject(counterKey)
if (!counter) {
throw new Error('CounterButton 必须在 CounterProvider 内使用')
}
</script>
<template>
<button type="button" @click="counter.increment">
{{ counter.count }}
</button>
</template>
让提供方保留修改权,并把动作一起提供出去,比后代任意修改共享 ref 更容易维护。
五、什么时候用 Pinia,而不是 provide/inject
provide/inject 的作用域跟组件树绑定;Pinia 的 Store 跟应用绑定,并有 DevTools、插件、SSR 约定和更明确的业务状态模型。
| 场景 | 推荐 |
|---|---|
| 组件库内部上下文 | provide/inject |
| 某个页面子树的临时共享能力 | provide/inject |
| 当前用户、购物车、跨路由草稿 | Pinia |
| 需要 DevTools、持久化插件或 SSR 水合 | Pinia |
| 普通父子通信 | Props / Emits |
不要仅仅为了避免一层 Props 就引入全局 Store;也不要用 provide/inject 隐藏整个应用的业务状态。
六、事件总线只处理真正的瞬时事件
Vue 3 移除了实例上的 $on、$off 和 $once。如果极少数场景确实需要无直接关系组件之间广播瞬时事件,可以使用 mitt:
npm install mitt
创建类型安全的事件器:
// events/app-events.ts
import mitt from 'mitt'
export interface AppEvents {
'toast:show': {
message: string
kind: 'success' | 'error'
}
'session:expired': void
}
export const appEvents = mitt<AppEvents>()
发送事件:
appEvents.emit('toast:show', {
message: '保存成功',
kind: 'success',
})
订阅组件必须使用同一个函数引用解绑:
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { appEvents, type AppEvents } from '@/events/app-events'
function showToast(payload: AppEvents['toast:show']) {
console.log(payload.message)
}
onMounted(() => {
appEvents.on('toast:show', showToast)
})
onUnmounted(() => {
appEvents.off('toast:show', showToast)
})
</script>
事件总线不应保存状态。组件晚于事件挂载时会错过消息,事件来源也难以追踪。当前用户、加载结果等“需要随时读取的事实”应放在 Props、Pinia 或其他明确状态容器里。
七、选择通信方式的判断顺序
遇到通信需求时,可以依次问:
- 数据是否只属于一个父组件?使用 Props / Emits。
- 是否是受控输入?使用
defineModel()。 - 是否只是让父组件决定渲染结构?使用插槽。
- 是否是某棵子树的上下文?使用
provide/inject。 - 是否跨路由、需要长期存在和调试?使用 Pinia。
- 是否是无需保存、可能有多个监听者的瞬时广播?最后再考虑事件总线。
- 是否必须调用 DOM 或子组件命令?使用模板引用,并缩小公开 API。
小结
Vue 3.5 的 useTemplateRef() 是访问 DOM 和公开组件能力的现代方式;provide/inject 适合树形上下文,Pinia 适合应用级业务状态。事件总线应是少数瞬时广播的工具,而不是默认通信方案。