经常写 Vue 的朋友应该很熟悉,在 Vue 的应用中,组件化开发可以让我们的代码更容易维护,而组件之间的数据传递 和事件通信也是我们必须要解决的问题。
经过多个项目的实践,我逐渐摸清了Vue3中8种组件通信方式和适用场景。
下面来给大家分享一下。
1. Props / Emits:最基础的父子传值
这是 Vue 的官方推荐通信方式,遵循单向数据流原则,数据只能从上往下流,事件从下往上传。
Props:父传子的单向数据流
适用场景:当你需要把配置、用户信息、状态等数据从父组件传递给子组件时。
html
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<h2>父组件</h2>
<!-- 传递静态和动态数据 -->
<ChildComponent
title="用户信息"
:user="userData"
:count="clickCount"
/>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import ChildComponent from './ChildComponent.vue'
const userData = reactive({
name: '张三',
age: 25,
email: 'zhangsan@example.com'
})
const clickCount = ref(0)
</script>
html
<!-- 子组件 ChildComponent.vue -->
<template>
<div class="child">
<h3>{{ title }}</h3>
<div class="user-card">
<p>姓名:{{ user.name }}</p>
<p>年龄:{{ user.age }}</p>
<p>邮箱:{{ user.email }}</p>
</div>
<p>点击次数:{{ count }}</p>
</div>
</template>
<script setup>
// 方式1:简单定义
// defineProps(['title', 'user', 'count'])
// 方式2:带类型验证(推荐)
defineProps({
title: {
type: String,
required: true
},
user: {
type: Object,
default: () => ({})
},
count: {
type: Number,
default: 0
}
})
// 方式3:使用 TypeScript(最佳实践)
interface Props {
title: string
user: {
name: string
age: number
email: string
}
count?: number
}
defineProps<Props>()
</script>
为什么推荐带验证?
它能提前发现传参错误,比如把字符串传给了 count,Vue 会在控制台报错,避免线上bug。
Emits:子传父的事件机制
适用场景:子组件需要通知父组件有事发生,比如表单提交、按钮点击、输入变化等。
html
<!-- 子组件 ChildComponent.vue -->
<template>
<div class="child">
<button @click="handleButtonClick">通知父组件</button>
<input
:value="inputValue"
@input="handleInputChange"
placeholder="输入内容..."
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 定义可触发的事件
const emit = defineEmits(['button-clicked', 'input-changed', 'update:modelValue'])
const inputValue = ref('')
const handleButtonClick = () => {
// 触发事件并传递数据
emit('button-clicked', {
message: '按钮被点击了!',
timestamp: new Date().toISOString()
})
}
const handleInputChange = (event) => {
inputValue.value = event.target.value
emit('input-changed', inputValue.value)
// 支持 v-model 的更新方式
emit('update:modelValue', inputValue.value)
}
</script>
html
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<ChildComponent
@button-clicked="handleChildButtonClick"
@input-changed="handleChildInputChange"
/>
<div v-if="lastEvent">
<p>最后收到的事件:{{ lastEvent.type }}</p>
<p>数据:{{ lastEvent.data }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const lastEvent = ref(null)
const handleChildButtonClick = (data) => {
lastEvent.value = {
type: 'button-clicked',
data: data
}
console.log('收到子组件消息:', data)
}
const handleChildInputChange = (value) => {
lastEvent.value = {
type: 'input-changed',
data: value
}
console.log('输入内容:', value)
}
</script>
关键点:
- 子组件不直接修改父组件数据,而是发出请求,由父组件决定如何处理。
- 这种解耦设计让组件更可复用、更易测试。
2. v-model:双向绑定的语法糖
v-model 在 Vue3 中变得更加强大,支持多个 v-model 绑定。
基础用法
html
<!-- 父组件 -->
<template>
<div>
<CustomInput v-model="username" />
<p>当前用户名:{{ username }}</p>
<!-- 多个 v-model -->
<UserForm
v-model:name="userName"
v-model:email="userEmail"
v-model:age="userAge"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
const userName = ref('')
const userEmail = ref('')
const userAge = ref(0)
</script>
html
<!-- 子组件 CustomInput.vue -->
<template>
<div class="custom-input">
<label>用户名:</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
class="input-field"
/>
</div>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
html
<!-- 子组件 UserForm.vue -->
<template>
<div class="user-form">
<div class="form-group">
<label>姓名:</label>
<input
:value="name"
@input="$emit('update:name', $event.target.value)"
/>
</div>
<div class="form-group">
<label>邮箱:</label>
<input
:value="email"
@input="$emit('update:email', $event.target.value)"
type="email"
/>
</div>
<div class="form-group">
<label>年龄:</label>
<input
:value="age"
@input="$emit('update:age', parseInt($event.target.value) || 0)"
type="number"
/>
</div>
</div>
</template>
<script setup>
defineProps({
name: String,
email: String,
age: Number
})
defineEmits(['update:name', 'update:email', 'update:age'])
</script>
v-model的核心优势:
- 语法简洁,减少样板代码
- 符合双向绑定的直觉
- 支持多个v-model绑定
- 类型安全(配合TypeScript)
适用场景:自定义表单控件(如日期选择器、富文本编辑器)需要双向绑定。
3. Ref / 模板引用:直接操作组件
当需要直接访问子组件或 DOM 元素时,模板引用是最佳选择。
html
<!-- 父组件 -->
<template>
<div class="parent">
<ChildComponent ref="childRef" />
<CustomForm ref="formRef" />
<video ref="videoRef" controls>
<source src="./movie.mp4" type="video/mp4">
</video>
<div class="controls">
<button @click="focusInput">聚焦输入框</button>
<button @click="getChildData">获取子组件数据</button>
<button @click="playVideo">播放视频</button>
<button @click="validateForm">验证表单</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
// 创建引用
const childRef = ref(null)
const formRef = ref(null)
const videoRef = ref(null)
// 确保 DOM 更新后访问
const focusInput = async () => {
await nextTick()
childRef.value?.focusInput()
}
const getChildData = () => {
if (childRef.value) {
const data = childRef.value.getData()
console.log('子组件数据:', data)
}
}
const playVideo = () => {
videoRef.value?.play()
}
const validateForm = () => {
formRef.value?.validate()
}
// 组件挂载后访问
onMounted(() => {
console.log('子组件实例:', childRef.value)
})
</script>
html
<!-- 子组件 ChildComponent.vue -->
<template>
<div class="child">
<input ref="inputEl" type="text" placeholder="请输入..." />
<p>内部数据:{{ internalData }}</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const inputEl = ref(null)
const internalData = ref('这是内部数据')
// 暴露给父组件的方法和数据
defineExpose({
focusInput: () => {
inputEl.value?.focus()
},
getData: () => {
return {
internalData: internalData.value,
timestamp: new Date().toISOString()
}
},
internalData
})
</script>
适用场景:需要调用子组件方法(如弹窗打开)、聚焦输入框、操作原生元素(如 video 播放)。
4. Provide / Inject:跨层级数据传递
解决"prop 逐级传递"问题,实现祖先与后代组件的直接通信。
html
<!-- 根组件 App.vue -->
<template>
<div id="app">
<Header />
<div class="main-content">
<Sidebar />
<ContentArea />
</div>
<Footer />
</div>
</template>
<script setup>
import { provide, ref, reactive, computed } from 'vue'
// 提供用户信息
const currentUser = ref({
id: 1,
name: '张三',
role: 'admin',
permissions: ['read', 'write', 'delete']
})
// 提供应用配置
const appConfig = reactive({
theme: 'dark',
language: 'zh-CN',
apiBaseUrl: import.meta.env.VITE_API_URL
})
// 提供方法
const updateUser = (newUserData) => {
currentUser.value = { ...currentUser.value, ...newUserData }
}
const updateConfig = (key, value) => {
appConfig[key] = value
}
// 计算属性
const userPermissions = computed(() => currentUser.value.permissions)
// 提供数据和方法
provide('currentUser', currentUser)
provide('appConfig', appConfig)
provide('updateUser', updateUser)
provide('updateConfig', updateConfig)
provide('userPermissions', userPermissions)
</script>
html
<!-- 深层嵌套的组件 ContentArea.vue -->
<template>
<div class="content-area">
<UserProfile />
<ArticleList />
</div>
</template>
<script setup>
// 这个组件不需要处理 props,直接渲染子组件
</script>
html
<!-- 使用注入的组件 UserProfile.vue -->
<template>
<div class="user-profile">
<h3>用户信息</h3>
<div class="profile-card">
<p>姓名:{{ currentUser.name }}</p>
<p>角色:{{ currentUser.role }}</p>
<p>权限:{{ userPermissions.join(', ') }}</p>
<p>主题:{{ appConfig.theme }}</p>
</div>
<button @click="handleUpdateProfile">更新资料</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
// 注入数据和方法
const currentUser = inject('currentUser')
const appConfig = inject('appConfig')
const userPermissions = inject('userPermissions')
const updateUser = inject('updateUser')
const handleUpdateProfile = () => {
updateUser({
name: '李四',
role: 'user'
})
}
</script>
Provide/Inject的优势:
- 避免Props逐层传递的繁琐
- 实现跨层级组件通信
- 提供全局状态和方法的统一管理
- 提高代码的可维护性
适用场景:当数据需要从顶层组件传递到底层组件,中间隔了好几层(比如主题、用户信息、语言设置)。
5. Pinia:现代化状态管理
对于复杂应用,Pinia 提供了更优秀的状态管理方案。
创建 Store
javascript
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
isLoggedIn: false,
token: '',
permissions: []
}),
getters: {
userName: (state) => state.user?.name || '未登录用户',
isAdmin: (state) => state.user?.role === 'admin',
hasPermission: (state) => (permission) =>
state.permissions.includes(permission)
},
actions: {
async login(credentials) {
try {
// 模拟 API 调用
const response = await mockLoginApi(credentials)
this.user = response.user
this.token = response.token
this.isLoggedIn = true
this.permissions = response.permissions
// 保存到 localStorage
localStorage.setItem('token', this.token)
return { success: true }
} catch (error) {
console.error('登录失败:', error)
return { success: false, error: error.message }
}
},
logout() {
this.user = null
this.token = ''
this.isLoggedIn = false
this.permissions = []
localStorage.removeItem('token')
},
async updateProfile(userData) {
if (!this.isLoggedIn) {
throw new Error('请先登录')
}
this.user = { ...this.user, ...userData }
// 这里可以调用 API 更新后端数据
}
}
})
// 模拟登录 API
const mockLoginApi = (credentials) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
user: {
id: 1,
name: credentials.username,
role: 'admin'
},
token: 'mock-jwt-token',
permissions: ['read', 'write', 'delete']
})
}, 1000)
})
}
在组件中使用 Store
html
<!-- UserProfile.vue -->
<template>
<div class="user-profile">
<div v-if="userStore.isLoggedIn" class="logged-in">
<h3>欢迎回来,{{ userStore.userName }}!</h3>
<div class="user-info">
<p>角色:{{ userStore.user.role }}</p>
<p>权限:{{ userStore.permissions.join(', ') }}</p>
</div>
<div class="actions">
<button
@click="updateName"
:disabled="!userStore.hasPermission('write')"
>
更新姓名
</button>
<button @click="userStore.logout" class="logout-btn">
退出登录
</button>
</div>
</div>
<div v-else class="logged-out">
<LoginForm />
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import LoginForm from './LoginForm.vue'
const userStore = useUserStore()
const updateName = () => {
userStore.updateProfile({
name: `用户${Math.random().toString(36).substr(2, 5)}`
})
}
</script>
html
<!-- LoginForm.vue -->
<template>
<div class="login-form">
<h3>用户登录</h3>
<form @submit.prevent="handleLogin">
<div class="form-group">
<input
v-model="credentials.username"
placeholder="用户名"
required
/>
</div>
<div class="form-group">
<input
v-model="credentials.password"
type="password"
placeholder="密码"
required
/>
</div>
<button type="submit" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div v-if="message" class="message" :class="messageType">
{{ message }}
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const credentials = reactive({
username: '',
password: ''
})
const loading = ref(false)
const message = ref('')
const messageType = ref('')
const handleLogin = async () => {
loading.value = true
message.value = ''
const result = await userStore.login(credentials)
if (result.success) {
message.value = '登录成功!'
messageType.value = 'success'
} else {
message.value = `登录失败:${result.error}`
messageType.value = 'error'
}
loading.value = false
}
</script>
Pinia 优势:
- 无 mutations,直接修改 state
- 完美支持 TypeScript
- DevTools 调试友好
- 模块化设计,易于拆分
适用场景:中大型应用,多个组件需要共享复杂状态(如用户登录态、购物车、全局配置)。
6. 事件总线:轻量级全局通信
Vue3 移除了实例上的 <math xmlns="http://www.w3.org/1998/Math/MathML"> o n 、 on、 </math>on、off 方法,不再支持这种模式,但我们可以使用 mitt 库实现。
javascript
// utils/eventBus.js
import mitt from 'mitt'
// 创建全局事件总线
const eventBus = mitt()
// 定义事件类型
export const EVENTS = {
USER_LOGIN: 'user:login',
USER_LOGOUT: 'user:logout',
NOTIFICATION_SHOW: 'notification:show',
MODAL_OPEN: 'modal:open',
THEME_CHANGE: 'theme:change'
}
export default eventBus
html
<!-- 发布事件的组件 -->
<template>
<div class="publisher">
<h3>事件发布者</h3>
<div class="buttons">
<button @click="sendNotification">发送通知</button>
<button @click="openModal">打开模态框</button>
<button @click="changeTheme">切换主题</button>
</div>
</div>
</template>
<script setup>
import eventBus, { EVENTS } from '@/utils/eventBus'
const sendNotification = () => {
eventBus.emit(EVENTS.NOTIFICATION_SHOW, {
type: 'success',
title: '操作成功',
message: '这是一个来自事件总线的通知',
duration: 3000
})
}
const openModal = () => {
eventBus.emit(EVENTS.MODAL_OPEN, {
component: 'UserForm',
props: { userId: 123 },
title: '用户表单'
})
}
const changeTheme = () => {
const themes = ['light', 'dark', 'blue']
const randomTheme = themes[Math.floor(Math.random() * themes.length)]
eventBus.emit(EVENTS.THEME_CHANGE, {
theme: randomTheme,
timestamp: new Date().toISOString()
})
}
</script>
html
<!-- 监听事件的组件 -->
<template>
<div class="listener">
<h3>事件监听者</h3>
<div class="events-log">
<div
v-for="(event, index) in events"
:key="index"
class="event-item"
>
<strong>{{ event.type }}</strong>
<span>{{ event.data }}</span>
<small>{{ event.timestamp }}</small>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { EVENTS } from '@/utils/eventBus'
const events = ref([])
// 事件处理函数
const handleNotification = (data) => {
events.value.unshift({
type: EVENTS.NOTIFICATION_SHOW,
data: `通知: ${data.title} - ${data.message}`,
timestamp: new Date().toLocaleTimeString()
})
}
const handleModalOpen = (data) => {
events.value.unshift({
type: EVENTS.MODAL_OPEN,
data: `打开模态框: ${data.component}`,
timestamp: new Date().toLocaleTimeString()
})
}
const handleThemeChange = (data) => {
events.value.unshift({
type: EVENTS.THEME_CHANGE,
data: `主题切换为: ${data.theme}`,
timestamp: new Date().toLocaleTimeString()
})
}
// 注册事件监听
onMounted(() => {
eventBus.on(EVENTS.NOTIFICATION_SHOW, handleNotification)
eventBus.on(EVENTS.MODAL_OPEN, handleModalOpen)
eventBus.on(EVENTS.THEME_CHANGE, handleThemeChange)
})
// 组件卸载时移除监听
onUnmounted(() => {
eventBus.off(EVENTS.NOTIFICATION_SHOW, handleNotification)
eventBus.off(EVENTS.MODAL_OPEN, handleModalOpen)
eventBus.off(EVENTS.THEME_CHANGE, handleThemeChange)
})
</script>
不太推荐使用。为什么?
- 数据流向不透明,难以追踪
- 容易忘记 off 导致内存泄漏
- 大型项目维护困难
- 建议:优先用 Pinia 或 provide/inject
适用场景:小型项目中,两个无关联组件需要临时通信(如通知弹窗、模态框控制)。
7. 属性透传($attrs)和边界处理
当你封装一个组件,并希望把未声明的属性自动传递给内部元素时,就用 $attrs。
html
<!-- 基础组件 BaseButton.vue -->
<template>
<button
v-bind="filteredAttrs"
class="base-button"
@click="handleClick"
>
<slot></slot>
</button>
</template>
<script setup>
import { computed, useAttrs } from 'vue'
const attrs = useAttrs()
// 过滤掉不需要透传的属性
const filteredAttrs = computed(() => {
const { class: className, style, ...rest } = attrs
return rest
})
const emit = defineEmits(['click'])
const handleClick = (event) => {
emit('click', event)
}
// 也可以选择性地暴露 attrs
defineExpose({
attrs
})
</script>
</style>
html
<!-- 使用基础组件 -->
<template>
<div>
<!-- 透传 class、style、data-* 等属性 -->
<BaseButton
class="custom-btn"
style="color: red;"
data-testid="submit-button"
title="提交按钮"
@click="handleSubmit"
>
提交表单
</BaseButton>
<!-- 多个按钮使用相同的基组件 -->
<BaseButton
class="secondary-btn"
data-testid="cancel-button"
@click="handleCancel"
>
取消
</BaseButton>
</div>
</template>
<script setup>
const handleSubmit = () => {
console.log('提交表单')
}
const handleCancel = () => {
console.log('取消操作')
}
</script>
<style>
.custom-btn {
background: blue;
color: white;
}
.secondary-btn {
background: gray;
color: white;
}
</style>
特性:
- 用户传的 class 和 style 会和组件内部的样式合并(Vue 自动处理)。
- 所有 data-、title、aria- 等原生 HTML 属性都能正常生效。
- 你不用提前知道用户会传什么,也能支持!
适用场景:封装通用组件(如按钮、输入框),希望保留原生 HTML 属性(class、style、data-* 等)。
8. 组合式函数:逻辑复用
对于复杂的通信逻辑,可以使用组合式函数封装。
javascript
// composables/useCommunication.js
import { ref, onUnmounted } from 'vue'
export function useCommunication() {
const messages = ref([])
const listeners = new Map()
const sendMessage = (type, data) => {
messages.value.unshift({
type,
data,
timestamp: new Date().toISOString()
})
// 通知监听者
if (listeners.has(type)) {
listeners.get(type).forEach(callback => {
callback(data)
})
}
}
const onMessage = (type, callback) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type).add(callback)
}
const offMessage = (type, callback) => {
if (listeners.has(type)) {
listeners.get(type).delete(callback)
}
}
// 清理函数
const cleanup = () => {
listeners.clear()
}
onUnmounted(cleanup)
return {
messages,
sendMessage,
onMessage,
offMessage,
cleanup
}
}
html
<!-- 使用组合式函数 -->
<template>
<div class="communication-demo">
<div class="senders">
<MessageSender />
<EventSender />
</div>
<div class="receivers">
<MessageReceiver />
<EventReceiver />
</div>
<div class="message-log">
<h4>消息日志</h4>
<div
v-for="(msg, index) in messages"
:key="index"
class="log-entry"
>
[{{ formatTime(msg.timestamp) }}] {{ msg.type }}: {{ msg.data }}
</div>
</div>
</div>
</template>
<script setup>
import { useCommunication } from '@/composables/useCommunication'
import MessageSender from './MessageSender.vue'
import MessageReceiver from './MessageReceiver.vue'
import EventSender from './EventSender.vue'
import EventReceiver from './EventReceiver.vue'
const { messages } = useCommunication()
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString()
}
</script>
优势:
- 逻辑高度复用
- 类型安全(配合 TS)
- 易于单元测试
适用场景:将复杂的通信逻辑抽象成可复用的函数,比如 WebSocket 连接、本地存储同步等。
避坑指南
1. Props 设计原则
javascript
// 好的 Props 设计
defineProps({
// 必需属性
title: { type: String, required: true },
// 可选属性带默认值
size: { type: String, default: 'medium' },
// 复杂对象
user: {
type: Object,
default: () => ({ name: '', age: 0 })
},
// 验证函数
count: {
type: Number,
validator: (value) => value >= 0 && value <= 100
}
})
2. 事件命名规范
javascript
// 使用 kebab-case 事件名
defineEmits(['update:title', 'search-change', 'form-submit'])
// 避免使用驼峰命名
// defineEmits(['updateTitle']) // 不推荐
3. Provide/Inject 的响应性
javascript
// 保持响应性
const data = ref({})
provide('data', readonly(data))
// 提供修改方法
const updateData = (newData) => {
data.value = { ...data.value, ...newData }
}
provide('updateData', updateData)
4. 内存泄漏预防
javascript
// 及时清理事件监听
onUnmounted(() => {
eventBus.off('some-event', handler)
})
// 清理定时器
const timer = setInterval(() => {}, 1000)
onUnmounted(() => clearInterval(timer))
总结
经过上面的详细讲解,相信大家对 Vue3 的组件通信有了更深入的理解。让我最后做个总结:
- 核心原则:根据组件关系选择合适方案
- 父子组件:优先使用
Props/Emits,简单直接 - 表单控件:
v-model是最佳选择,语法优雅 - 深层嵌套:
Provide/Inject避免 prop 透传地狱 - 全局状态:
Pinia专业强大,适合复杂应用 - 临时通信:事件总线可用但需谨慎
- 组件封装:属性透传提供更好用户体验
- 逻辑复用:组合式函数提升代码质量
在实际开发中,可以这样:
- 先从
Props/Emits开始,这是基础 - 熟练掌握
v-model的表单处理 - 在需要时引入
Pinia,不要过度设计 - 保持代码的可读性和可维护性
简单的需求用简单的方案,复杂的需求才需要复杂的工具。
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计》
《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》