作为 Vue 开发者,组件通信是我们每天都要面对的核心问题。经过多个大型项目的实战积累,我系统梳理了 Vue3 中最实用的 8 种通信方式及其适用场景。本文将结合具体案例,带你深入理解每种方案的实现原理和最佳实践。
在现代前端开发中,组件化已成为主流范式。Vue 的单文件组件(SFC)设计让我们能够将 UI 拆分为独立、可复用的模块。但随之而来的挑战是:这些组件如何高效地交换数据和事件?
良好的通信机制能带来三大优势:
下面我们就从最基础的父子通信开始,逐步深入各种高级场景的解决方案。
Props 是 Vue 官方推荐的基础通信方式,遵循单向数据流原则。这种设计确保了数据流向的可预测性,是构建可维护组件架构的基础。
典型应用场景:
vue复制<!-- ParentComponent.vue -->
<template>
<div>
<ChildComponent
title="用户资料卡"
:user="currentUser"
:loading="isLoading"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const currentUser = ref({
name: '李四',
age: 28,
email: 'lisi@example.com'
})
const isLoading = ref(true)
</script>
在子组件中,我们应该始终对 Props 进行严格验证:
vue复制<!-- ChildComponent.vue -->
<script setup>
defineProps({
title: {
type: String,
required: true,
validator: (value) => value.length <= 30
},
user: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
</script>
类型验证的最佳实践:
required: true当子组件需要与父组件交互时,应该通过触发自定义事件来实现,而不是直接修改父组件数据。
典型应用场景:
vue复制<!-- ChildComponent.vue -->
<template>
<button @click="handleClick">提交</button>
</template>
<script setup>
const emit = defineEmits(['submit', 'update'])
const handleClick = () => {
emit('submit', {
timestamp: new Date(),
status: 'success'
})
}
</script>
父组件监听事件:
vue复制<!-- ParentComponent.vue -->
<template>
<ChildComponent @submit="handleSubmit" />
</template>
<script setup>
const handleSubmit = (payload) => {
console.log('收到提交事件:', payload)
// 处理业务逻辑...
}
</script>
事件命名规范建议:
form-submit)update: 前缀v-model 在 Vue3 中得到了显著增强,它实质上是 :modelValue 和 @update:modelValue 的语法糖。
vue复制<!-- ParentComponent.vue -->
<template>
<CustomInput v-model="username" />
</template>
<script setup>
const username = ref('')
</script>
等价于:
vue复制<CustomInput
:modelValue="username"
@update:modelValue="newValue => username = newValue"
/>
Vue3 支持对单个组件绑定多个 v-model,这在复杂表单场景中非常实用:
vue复制<UserForm
v-model:name="formData.name"
v-model:email="formData.email"
v-model:age="formData.age"
/>
子组件实现:
vue复制<!-- UserForm.vue -->
<script setup>
defineProps(['name', 'email', 'age'])
defineEmits(['update:name', 'update:email', 'update:age'])
</script>
<template>
<input
:value="name"
@input="$emit('update:name', $event.target.value)"
/>
<!-- 其他字段... -->
</template>
我们可以通过 modelModifiers 实现自定义修饰符:
vue复制<CustomInput v-model.capitalize="text" />
子组件处理:
vue复制<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: {
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue'])
const handleInput = (e) => {
let value = e.target.value
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
适用场景建议:
通过 ref 我们可以直接访问子组件实例或 DOM 元素:
vue复制<template>
<ChildComponent ref="childRef" />
<input ref="inputRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref(null)
const inputRef = ref(null)
onMounted(() => {
console.log(childRef.value) // 子组件实例
inputRef.value.focus() // DOM 操作
})
</script>
子组件可以通过 defineExpose 明确暴露哪些方法和属性:
vue复制<!-- ChildComponent.vue -->
<script setup>
const internalData = ref('私有数据')
const publicMethod = () => {
console.log('这是公开方法')
}
defineExpose({
publicMethod,
internalData
})
</script>
在循环中创建动态 ref:
vue复制<template>
<div v-for="item in list" :key="item.id">
<ChildComponent :ref="el => setItemRef(el, item.id)" />
</div>
</template>
<script setup>
const itemRefs = new Map()
const setItemRef = (el, id) => {
if (el) {
itemRefs.set(id, el)
} else {
itemRefs.delete(id)
}
}
</script>
注意事项:
toRefProvide 和 Inject 解决了 prop 逐级透传的问题,特别适合深层嵌套组件通信。
vue复制<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数是默认值
</script>
为确保注入的值保持响应性,应该提供 ref 或 reactive 对象:
vue复制<script setup>
import { provide, reactive } from 'vue'
const user = reactive({
name: '张三',
role: 'admin'
})
provide('user', user)
</script>
最佳实践是同时提供状态和修改方法:
vue复制<script setup>
import { provide, ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value++
}
provide('count', readonly(count))
provide('increment', increment)
</script>
为避免命名冲突,建议使用 Symbol 作为注入名:
js复制// keys.js
export const THEME_KEY = Symbol('theme')
// 提供者
import { THEME_KEY } from './keys'
provide(THEME_KEY, 'dark')
// 注入者
const theme = inject(THEME_KEY)
适用场景:
js复制// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
vue复制<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<div>{{ counter.count }}</div>
<button @click="counter.increment">+</button>
</template>
对于复杂逻辑,可以使用 setup 语法:
js复制export const useUserStore = defineStore('user', () => {
const user = ref(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(credentials) {
// 登录逻辑...
}
return { user, isLoggedIn, login }
})
Pinia 支持通过插件扩展功能:
js复制function localStoragePlugin(context) {
const key = `pinia-${context.store.$id}`
// 从 localStorage 恢复状态
const savedState = localStorage.getItem(key)
if (savedState) {
context.store.$patch(JSON.parse(savedState))
}
// 订阅状态变化
context.store.$subscribe((mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}
Pinia 优势:
虽然 Vue3 移除了内置事件总线,但我们可以使用 mitt 库:
js复制// eventBus.js
import mitt from 'mitt'
export default mitt()
vue复制<!-- Publisher.vue -->
<script setup>
import eventBus from './eventBus'
const publishEvent = () => {
eventBus.emit('custom-event', { data: 'test' })
}
</script>
<!-- Subscriber.vue -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import eventBus from './eventBus'
const handler = (data) => {
console.log('收到事件:', data)
}
onMounted(() => {
eventBus.on('custom-event', handler)
})
onUnmounted(() => {
eventBus.off('custom-event', handler)
})
</script>
配合 TypeScript 实现类型安全:
ts复制// typedEventBus.ts
import mitt, { Emitter } from 'mitt'
type Events = {
'user-login': { userId: string }
'notification': { message: string }
}
export const eventBus: Emitter<Events> = mitt<Events>()
使用建议:
js复制// composables/useSharedState.js
import { ref, readonly } from 'vue'
export function useSharedState() {
const state = ref({
message: '',
timestamp: null
})
const updateState = (newMessage) => {
state.value = {
message: newMessage,
timestamp: new Date()
}
}
return {
state: readonly(state),
updateState
}
}
vue复制<!-- ComponentA.vue -->
<script setup>
import { useSharedState } from './useSharedState'
const { updateState } = useSharedState()
const sendMessage = () => {
updateState('Hello from A')
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { useSharedState } from './useSharedState'
const { state } = useSharedState()
</script>
<template>
<div>收到消息: {{ state.message }}</div>
</template>
js复制// composables/useWebSocket.js
import { ref, onUnmounted } from 'vue'
export function useWebSocket(url) {
const messages = ref([])
const isConnected = ref(false)
let socket = null
const connect = () => {
socket = new WebSocket(url)
socket.onopen = () => {
isConnected.value = true
}
socket.onmessage = (event) => {
messages.value.push(JSON.parse(event.data))
}
socket.onclose = () => {
isConnected.value = false
}
}
const send = (message) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message))
}
}
onUnmounted(() => {
socket?.close()
})
return {
messages,
isConnected,
connect,
send
}
}
最佳实践:
问题1:Prop 变化但组件不更新
问题2:事件监听内存泄漏
问题3:Provide 数据失去响应性
问题4:Pinia Store 过大
在长期 Vue 开发中,我总结了以下经验法则:
一个典型的项目通信架构可能是:
记住,没有放之四海而皆准的方案,最重要的是理解每种模式的适用场景和权衡取舍。希望本文能帮助你在 Vue 项目中构建更优雅、更可维护的组件通信体系。