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 或其他明确状态容器里。

七、选择通信方式的判断顺序

遇到通信需求时,可以依次问:

  1. 数据是否只属于一个父组件?使用 Props / Emits。
  2. 是否是受控输入?使用 defineModel()
  3. 是否只是让父组件决定渲染结构?使用插槽。
  4. 是否是某棵子树的上下文?使用 provide/inject
  5. 是否跨路由、需要长期存在和调试?使用 Pinia。
  6. 是否是无需保存、可能有多个监听者的瞬时广播?最后再考虑事件总线。
  7. 是否必须调用 DOM 或子组件命令?使用模板引用,并缩小公开 API。

小结

Vue 3.5 的 useTemplateRef() 是访问 DOM 和公开组件能力的现代方式;provide/inject 适合树形上下文,Pinia 适合应用级业务状态。事件总线应是少数瞬时广播的工具,而不是默认通信方案。

官方资料

系列导航:目录 · 上一篇 · 下一篇