文章目录
- 前言
- 一、通信方式总览
-
- [1.1 选型指南](#1.1 选型指南)
- [二、Props / Emit(父子通信)](#二、Props / Emit(父子通信))
-
- [2.1 单向数据流](#2.1 单向数据流)
- [2.2 v-model 本质](#2.2 v-model 本质)
- [2.3 常见场景](#2.3 常见场景)
- [三、provide / inject(跨层级通信)](#三、provide / inject(跨层级通信))
-
- [3.1 基本用法](#3.1 基本用法)
- [3.2 响应式 provide](#3.2 响应式 provide)
- [3.3 应用场景](#3.3 应用场景)
- [3.4 易混淆点](#3.4 易混淆点)
- [四、事件总线 mitt](#四、事件总线 mitt)
-
- [4.1 Vue 3 的变化](#4.1 Vue 3 的变化)
- [4.2 封装与使用](#4.2 封装与使用)
- [4.3 适用场景](#4.3 适用场景)
- [五、Pinia / Vuex(全局状态)](#五、Pinia / Vuex(全局状态))
-
- [5.1 何时使用](#5.1 何时使用)
- [5.2 Pinia 基本用法](#5.2 Pinia 基本用法)
- [5.3 与其他方式对比](#5.3 与其他方式对比)
- 六、组件注册方式
-
- [6.1 全局注册](#6.1 全局注册)
- [6.2 局部注册](#6.2 局部注册)
- [6.3 异步组件注册](#6.3 异步组件注册)
- [6.4 全局 vs 局部](#6.4 全局 vs 局部)
- 七、通信方式对比总结
- 八、面试聚焦
-
- [8.1 Props 单向数据流](#8.1 Props 单向数据流)
- [8.2 provide/inject 响应式](#8.2 provide/inject 响应式)
- [8.3 全局注册无法 Tree-shaking](#8.3 全局注册无法 Tree-shaking)
- [8.4 Vue 3 事件总线](#8.4 Vue 3 事件总线)
- 九、易混淆点
- 十、思考与练习
- 总结
前言
组件化开发的核心问题之一,就是组件之间如何传递数据和触发行为。Vue 提供了多种通信方式,本篇会讲清楚:
- Props / Emit(父子通信)
- provide / inject(跨层级通信)
- 事件总线 mitt
- Pinia / Vuex(全局状态)
- 组件注册方式(全局 / 局部 / 异步)
一、通信方式总览
1.1 选型指南
| 方式 | 适用场景 | 关系 |
|---|---|---|
| Props / Emit | 父子数据传递、子通知父 | 直接父子 |
| provide / inject | 主题、语言包、表单上下文 | 祖孙跨层级 |
| mitt(事件总线) | 兄弟组件、无关联组件 | 任意组件 |
| Pinia / Vuex | 用户状态、权限、购物车 | 全局共享 |
javascript
// 选型原则:
// 1. 能用 Props/Emit 解决的,优先用 Props/Emit(数据流清晰)
// 2. 跨多层级透传 → provide/inject
// 3. 无关联组件 → mitt 或 Pinia
// 4. 多处共享的全局状态 → Pinia
二、Props / Emit(父子通信)
2.1 单向数据流
vue
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const count = ref(0)
const handleChange = (val) => {
count.value = val // 父组件修改数据
}
</script>
<template>
<Child :count="count" @change="handleChange" />
</template>
vue
<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps({
count: { type: Number, default: 0 }
})
const emit = defineEmits(['change'])
const increment = () => {
// ❌ 不能直接修改 props
// props.count++
// ✅ 通过 emit 通知父组件
emit('change', props.count + 1)
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
2.2 v-model 本质
vue
<!-- v-model 是 Props + Emit 的语法糖 -->
<MyInput v-model="text" />
<!-- 等价于 -->
<MyInput
:modelValue="text"
@update:modelValue="text = $event"
/>
<!-- 多个 v-model -->
<MyForm v-model:name="name" v-model:age="age" />
2.3 常见场景
javascript
// 1. 父传配置:列表组件接收 items 和 loading
<List :items="list" :loading="loading" />
// 2. 子通知父:表单提交后 emit submit 事件
// emit('submit', formData)
// 3. 分页:子组件 emit page-change,父组件加载数据
// emit('page-change', page)
三、provide / inject(跨层级通信)
3.1 基本用法
javascript
// 祖先组件
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
// 后代组件(任意层级)
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数是默认值
3.2 响应式 provide
javascript
// ❌ 默认不是响应式:传递普通值
provide('count', 0) // 后代无法感知变化
// ✅ 传递 ref 或 reactive 实现响应式
const count = ref(0)
provide('count', count)
// 后代组件
const count = inject('count')
// count 变化时,后代视图自动更新
3.3 应用场景
javascript
// 1. 主题配置
provide('theme', { color: 'primary', size: 'medium' })
// 2. 国际化
provide('locale', locale)
// 3. 表单上下文(Form → FormItem)
provide('formContext', {
rules,
validate
})
// 4. 全局 HTTP 实例
app.provide('http', axios.create({ baseURL: '/api' }))
3.4 易混淆点
javascript
// 1. 多个祖先 provide 同名 key → 取最近祖先的值
// 2. inject 可指定默认值,找不到 provider 不会报错
// 3. app.provide 应用级注入,任何组件都可 inject
// 4. 过度使用会导致数据流难追踪,简单场景优先 Props
四、事件总线 mitt
4.1 Vue 3 的变化
javascript
// Vue 2:实例方法
const bus = new Vue()
bus.$on('message', handler)
bus.$emit('message', data)
bus.$off('message', handler)
// Vue 3:$on/$off/$once 已移除,使用 mitt
import mitt from 'mitt'
const bus = mitt()
bus.on('message', (data) => console.log(data))
bus.emit('message', { text: 'Hello' })
bus.off('message', handler)
4.2 封装与使用
javascript
// utils/eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()
// 组件 A:发送
import { eventBus } from '@/utils/eventBus'
eventBus.emit('refresh-list')
// 组件 B:接收
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'
const handler = () => fetchList()
onMounted(() => eventBus.on('refresh-list', handler))
onUnmounted(() => eventBus.off('refresh-list', handler))
4.3 适用场景
javascript
// ✅ 适合:兄弟组件、无直接关系的组件间通信
// 如:Header 通知 Sidebar 刷新
// ❌ 不适合:复杂全局状态(用 Pinia)
// ❌ 不适合:父子通信(用 Props/Emit,更清晰)
五、Pinia / Vuex(全局状态)
5.1 何时使用
javascript
// 适合 Pinia 的场景:
// 1. 用户登录态、Token、用户信息
// 2. 购物车、收藏夹
// 3. 应用全局配置(主题、语言、侧边栏状态)
// 4. 多处页面共享的缓存数据
5.2 Pinia 基本用法
javascript
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
token: ''
}),
getters: {
isLoggedIn: (state) => !!state.token
},
actions: {
login(token) {
this.token = token
},
logout() {
this.token = ''
this.name = ''
}
}
})
// 组件中使用
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { name, isLoggedIn } = storeToRefs(userStore) // 保持响应性
userStore.login('abc123')
5.3 与其他方式对比
| 方式 | 数据范围 | 持久化 | 适用 |
|---|---|---|---|
| Props/Emit | 父子 | 否 | 局部数据 |
| provide/inject | 组件树 | 否 | 主题、上下文 |
| mitt | 任意 | 否 | 一次性通知 |
| Pinia | 全局 | 可插件持久化 | 共享状态 |
六、组件注册方式
6.1 全局注册
javascript
import { createApp } from 'vue'
import App from './App.vue'
import MyButton from './components/MyButton.vue'
const app = createApp(App)
// 全局注册:任何模板中可直接使用
app.component('MyButton', MyButton)
app.mount('#app')
vue
<!-- 任意组件模板中 -->
<template>
<MyButton>点击</MyButton>
</template>
6.2 局部注册
vue
<!-- 推荐:<script setup> 中导入即局部注册 -->
<script setup>
import MyButton from './MyButton.vue'
import UserCard from './UserCard.vue'
// 无需额外声明,导入即可在模板中使用
</script>
<template>
<MyButton />
<UserCard />
</template>
6.3 异步组件注册
javascript
import { defineAsyncComponent } from 'vue'
// 局部异步组件
const HeavyModal = defineAsyncComponent(() =>
import('./HeavyModal.vue')
)
// 全局异步注册
app.component('HeavyModal', defineAsyncComponent(() =>
import('./HeavyModal.vue')
))
// 带加载和错误状态
const AsyncComp = defineAsyncComponent({
loader: () => import('./MyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000
})
6.4 全局 vs 局部
| 对比项 | 全局注册 | 局部注册 |
|---|---|---|
| 使用范围 | 任意组件 | 当前组件 |
| Tree-shaking | 不支持,未使用也会打包 | 支持 |
| 依赖关系 | 不明确 | 清晰 |
| 适用 | 基础通用组件(Button、Icon) | 业务页面组件 |
javascript
// 全局注册必须在 app.mount() 之前完成
// <script setup> 导入的 .vue 文件自动局部注册
// 组件名推荐 PascalCase,模板中可用 kebab-case
七、通信方式对比总结
父子直接通信 → Props / Emit
跨多层级透传 → provide / inject
兄弟/无关联组件 → mitt 或 Pinia
全局共享状态 → Pinia
基础 UI 组件 → 全局注册
业务页面组件 → 局部注册 + 异步加载
八、面试聚焦
8.1 Props 单向数据流
javascript
// 子组件不能直接修改 props
// 应通过 emit 通知父组件修改
emit('update:count', newValue)
8.2 provide/inject 响应式
javascript
// 默认不是响应式
// 需要传递 ref 或 reactive
provide('theme', ref('dark'))
8.3 全局注册无法 Tree-shaking
javascript
// 全局注册的组件即使未使用也会被打包
// 业务组件应局部注册,支持 Tree-shaking
8.4 Vue 3 事件总线
javascript
// Vue 3 移除 $on/$off/$emit
// 使用 mitt 库实现事件总线
九、易混淆点
- Props 是单向数据流:子组件不能直接修改 prop,应通过 emit 通知父组件。
- provide/inject 默认非响应式 :传递
ref或reactive才能实现响应式更新。 - mitt vs Pinia:mitt 适合一次性通知,Pinia 适合需要持久化的全局状态。
- 全局注册无法 Tree-shaking:未使用的全局组件仍会打包,业务组件应局部注册。
- defineProps / defineEmits:编译器宏,无需导入,不能在条件语句中使用。
十、思考与练习
1. Vue 组件通信有哪些方式?各自适用场景?
解析:
- Props/Emit:父子直接通信
- provide/inject:跨层级(主题、表单上下文)
- mitt:兄弟或无关联组件
- Pinia:全局共享状态
2. 为什么子组件不能直接修改 props?
解析:Vue 遵循单向数据流,props 由父组件控制。子组件修改 props 会破坏数据流的可预测性,应通过 emit 通知父组件修改。
3. provide/inject 如何实现响应式?
解析:传递 ref 或 reactive 对象,而不是普通值:
javascript
provide('count', ref(0)) // ✅ 响应式
provide('count', 0) // ❌ 非响应式
4. Vue 3 如何实现事件总线?
解析:使用 mitt 库替代 Vue 2 的 $on/$off/$emit:
javascript
import mitt from 'mitt'
const bus = mitt()
bus.on('event', handler)
bus.emit('event', data)
5. 全局注册和局部注册如何选择?
解析:
- 全局注册:基础通用组件(Button、Input),减少重复导入
- 局部注册:业务组件,依赖清晰,支持 Tree-shaking
总结
- Props/Emit:父子通信,单向数据流,v-model 是其语法糖
- provide/inject:跨层级通信,传递 ref/reactive 实现响应式
- mitt :Vue 3 事件总线,替代 o n / on/ on/off
- Pinia:全局状态管理,适合登录态、购物车等
- 组件注册:全局(通用 UI)vs 局部(业务组件)vs 异步(按需加载)