以下是 Vue3 中跨组件通信的教程,结合不同场景和最佳实践,附实例代码和原理说明:


一、依赖注入(Provide/Inject)

适用场景:祖先组件与任意层级后代组件通信(如主题配置、全局用户信息)

实现步骤

  1. 祖先组件提供数据

    <!-- Ancestor.vue -->
    <script setup>
    import { provide, ref } from 'vue'
    const theme = ref('dark')
    const toggleTheme = () => theme.value = theme.value === 'dark' ? 'light' : 'dark'
    
    // 提供响应式数据和方法
    provide('themeContext', { 
      theme,
      toggleTheme
    })
    </script>
    
  2. 后代组件注入使用

    <!-- Descendant.vue -->
    <script setup>
    import { inject } from 'vue'
    const { theme, toggleTheme } = inject('themeContext')
    </script>
    
    <template>
      <button @click="toggleTheme">当前主题:{{ theme }}</button>
    </template>
    

注意事项

  • 推荐使用 readonly() 包装数据避免意外修改
  • 可结合 Symbol 避免命名冲突:

以下是 Vue3 中 provide/inject 依赖注入机制的 深度注意事项解析,结合最佳实践和原理说明:


一、使用 readonly() 避免数据意外修改

为什么需要?

当父组件提供响应式数据(如 ref/reactive)时,子组件可以直接修改该数据,这会破坏 单向数据流原则,导致状态难以追踪。

解决方案

<!-- 父组件 -->
<script setup>
import { ref, provide, readonly } from 'vue'
const counter = ref(0)
provide('counter', readonly(counter)) // 包装为只读
</script>

<!-- 子组件 -->
<script setup>
const counter = inject('counter')
counter.value++ // 触发警告且修改无效!
</script>
  • 效果:子组件只能读取数据,修改时会触发 Vue 的警告提示
  • 适用场景:全局配置、主题信息等需要保护的数据

二、使用 Symbol 避免命名冲突

为什么需要?

在大型项目中,多个组件可能使用相同的字符串作为注入名(如 "user"),导致 数据覆盖意外注入

解决方案

// 创建独立的 symbols 管理文件(如 symbols.js)
export const USER_KEY = Symbol('user') // 唯一标识
export const THEME_KEY = Symbol('theme')
<!-- 父组件 -->
<script setup>
import { USER_KEY } from './symbols'
import { ref, provide } from 'vue'

const user = ref({ name: 'Alice' })
provide(USER_KEY, user) // 使用 Symbol 作为键
</script>

<!-- 子组件 -->
<script setup>
import { USER_KEY } from './symbols'
const user = inject(USER_KEY) // 安全注入
</script>
  • 优势:Symbol 具有唯一性,彻底避免命名冲突
  • 扩展技巧:结合 TypeScript 的 InjectionKey 类型增强类型安全:
    // symbols.ts
    import type { InjectionKey } from 'vue'
    export const USER_KEY = Symbol() as InjectionKey<Ref<User>>
    

三、其他关键注意事项

1. 响应式数据传递

  • 若父组件提供 refreactive 数据,注入的值 自动保持响应式,无需额外处理
  • 反模式:直接传递普通对象会导致响应性丢失
    <!-- 错误示例 -->
    <script setup>
    provide('user', { name: 'Bob' }) // 非响应式!
    </script>
    

2. 多层覆盖规则

  • 当组件链中存在多个同名 provide 时,就近原则生效(子组件的 provide 覆盖祖先)
  • 调试技巧:通过 Vue Devtools 查看注入链

3. 默认值处理

// 注入时设置默认值(支持函数返回复杂对象)
const config = inject('config', () => ({ theme: 'light' }))

4. 生命周期限制

  • provide/inject 必须在 setup()<script setup>同步调用,不可在异步回调中使用

5. 单元测试陷阱

  • 依赖注入的数据难以独立模拟,建议通过 props 覆盖全局 mock 处理

四、最佳实践总结

场景 推荐方案 相关文献
防止数据篡改 readonly() 包装响应式数据
大型项目命名安全 Symbol + 独立管理文件
类型安全 TypeScript + InjectionKey
复杂默认值 工厂函数返回默认值
响应式数据传递 始终使用 ref/reactive

综合示例:安全依赖注入

// symbols.ts
import type { InjectionKey, Ref } from 'vue'
export const AUTH_KEY = Symbol() as InjectionKey<Readonly<Ref<User>>>

// 父组件
<script setup lang="ts">
import { AUTH_KEY } from './symbols'
import { ref, provide, readonly } from 'vue'

const currentUser = ref({ name: 'Admin', role: 'admin' })
provide(AUTH_KEY, readonly(currentUser)) // 只读 + Symbol
</script>

// 子组件
<script setup lang="ts">
import { AUTH_KEY } from './symbols'
const user = inject(AUTH_KEY, () => ({ name: 'Guest', role: 'guest' }))
</script>

通过此方案,实现了 类型安全、防篡改、无命名冲突 的依赖注入。


若需进一步了解响应式原理或 TypeScript 深度集成,可参考文献 中的高级用法。

二、全局状态管理(Pinia)

适用场景:复杂应用中的全局状态共享(如用户登录态、购物车数据)

实现步骤

  1. 安装并创建 Store

    npm install pinia
    
    // stores/counter.js
    import { defineStore } from 'pinia'
    
    export const useCounterStore = defineStore('counter', () => {
      const count = ref(0)
      const increment = () => count.value++
      return { count, increment }
    })
    
  2. 组件中使用

    <!-- ComponentA.vue -->
    <script setup>
    import { useCounterStore } from '@/stores/counter'
    const counter = useCounterStore()
    </script>
    
    <template>
      <button @click="counter.increment">{{ counter.count }}</button>
    </template>
    

优势

  • 支持 TypeScript
  • 响应式自动解包
  • 模块化设计

三、事件总线(Mitt)

适用场景:任意组件间简单事件通信(替代 Vue2 的 EventBus)

实现步骤

  1. 安装并创建事件中心

    npm install mitt
    
    // utils/eventBus.js
    import mitt from 'mitt'
    export const emitter = mitt()
    
  2. 组件通信示例

    <!-- Sender.vue -->
    <script setup>
    import { emitter } from '@/utils/eventBus'
    
    const sendMessage = () => {
      emitter.emit('global-message', 'Hello from Sender!')
    }
    </script>
    
    <!-- Receiver.vue -->
    <script setup>
    import { onMounted, onUnmounted } from 'vue'
    import { emitter } from '@/utils/eventBus'
    
    const handleMessage = (msg) => console.log(msg)
    
    onMounted(() => emitter.on('global-message', handleMessage))
    onUnmounted(() => emitter.off('global-message', handleMessage))
    </script>
    

最佳实践

  • 使用命名规范(如 module/event
  • 及时移除事件监听

四、属性透传($attrs)

适用场景:多层组件属性透传(如高阶组件封装)

实现示例

<!-- MiddleComponent.vue -->
<template>
  <FinalComponent v-bind="$attrs" />
</template>

<!-- FinalComponent.vue -->
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs) // 接收所有未声明的 props
</script>

五、模板引用(Ref + defineExpose)

适用场景:父组件直接调用子组件方法

实现示例

<!-- Parent.vue -->
<template>
  <Child ref="childRef" />
  <button @click="childRef?.refresh()">刷新子组件</button>
</template>

<script setup>
import { ref } from 'vue'
const childRef = ref(null)
</script>

<!-- Child.vue -->
<script setup>
import { defineExpose } from 'vue'

const refresh = () => console.log('刷新数据')
defineExpose({ refresh })
</script>

六、双向绑定(v-model)

适用场景:父子组件数据同步

实现示例

<!-- Parent.vue -->
<template>
  <Child v-model:username="name" />
</template>

<!-- Child.vue -->
<script setup>
defineProps(['username'])
const emit = defineEmits(['update:username'])

const update = (e) => {
  emit('update:username', e.target.value)
}
</script>

方法对比与选型建议

方法 适用场景 优点 缺点
Provide/Inject 深层嵌套组件 避免逐层传递 数据流向不够直观
Pinia 复杂全局状态 类型安全、调试工具支持 需要学习额外概念
Mitt 简单事件通信 轻量快速 需要手动管理事件监听
$attrs 高阶组件封装 自动透传未声明属性 需要理解透传机制
Ref+Expose 父调子方法 直接访问子组件 破坏组件封装性

综合实例:购物车系统

结合 Pinia 和 Mitt 实现跨组件更新:

// stores/cart.js
export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  const addItem = (item) => items.value.push(item)
  return { items, addItem }
})

// utils/eventBus.js
export const emitter = mitt()
<!-- ProductList.vue -->
<script setup>
import { emitter } from '@/utils/eventBus'

const addToCart = (item) => {
  emitter.emit('cart/add', item)
}
</script>

<!-- CartIcon.vue -->
<script setup>
import { onMounted } from 'vue'
import { emitter } from '@/utils/eventBus'

onMounted(() => {
  emitter.on('cart/add', () => {
    // 触发图标动画
  })
})
</script>

通过合理选择通信方式,可以构建出高维护性的 Vue3 应用。简单场景推荐使用 Provide/Inject,复杂状态管理优先选择 Pinia,事件驱动场景使用 Mitt,需要直接操作子组件时采用 Ref 暴露方法。