组件通信
在 Vue 应用中,组件之间需要进行数据传递和事件通信。Vue 提供了多种组件通信方式,适用于不同的场景。
父子组件通信
Props Down, Events Up
这是 Vue 中最基本的通信模式:父组件通过 props 向子组件传递数据,子组件通过事件向父组件发送消息。
vue
<!-- 父组件 ParentComponent.vue -->
<template>
<div class="parent-component">
<h2>父子组件通信示例</h2>
<div class="demo-section">
<h3>用户管理</h3>
<!-- 用户列表 -->
<div class="user-list">
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
:selected="selectedUserId === user.id"
@select="handleUserSelect"
@edit="handleUserEdit"
@delete="handleUserDelete"
/>
</div>
<!-- 用户表单 -->
<div class="user-form-section">
<h4>{{ editingUser ? '编辑用户' : '添加用户' }}</h4>
<UserForm
:user="editingUser"
:loading="formLoading"
@submit="handleUserSubmit"
@cancel="handleFormCancel"
/>
</div>
<!-- 操作日志 -->
<div class="action-logs">
<h4>操作日志</h4>
<div class="logs-container">
<div
v-for="(log, index) in actionLogs"
:key="index"
class="log-entry"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-action">{{ log.action }}</span>
<span class="log-details">{{ log.details }}</span>
</div>
</div>
<button @click="clearLogs" class="clear-logs-btn">
清空日志
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import UserCard from './UserCard.vue'
import UserForm from './UserForm.vue'
// 响应式数据
const users = ref([
{
id: 1,
name: '张三',
email: 'zhangsan@example.com',
role: 'admin',
avatar: '',
status: 'active'
},
{
id: 2,
name: '李四',
email: 'lisi@example.com',
role: 'user',
avatar: '',
status: 'active'
},
{
id: 3,
name: '王五',
email: 'wangwu@example.com',
role: 'moderator',
avatar: '',
status: 'inactive'
}
])
const selectedUserId = ref(null)
const editingUser = ref(null)
const formLoading = ref(false)
const actionLogs = ref([])
// 方法
function addLog(action, details) {
actionLogs.value.unshift({
time: new Date().toLocaleTimeString(),
action,
details
})
// 限制日志数量
if (actionLogs.value.length > 50) {
actionLogs.value = actionLogs.value.slice(0, 50)
}
}
function handleUserSelect(userId) {
selectedUserId.value = userId
const user = users.value.find(u => u.id === userId)
addLog('选择用户', `选择了用户: ${user?.name}`)
}
function handleUserEdit(user) {
editingUser.value = { ...user }
addLog('编辑用户', `开始编辑用户: ${user.name}`)
}
function handleUserDelete(userId) {
const user = users.value.find(u => u.id === userId)
if (user && confirm(`确定要删除用户 "${user.name}" 吗?`)) {
users.value = users.value.filter(u => u.id !== userId)
if (selectedUserId.value === userId) {
selectedUserId.value = null
}
addLog('删除用户', `删除了用户: ${user.name}`)
}
}
function handleUserSubmit(userData) {
formLoading.value = true
// 模拟 API 调用
setTimeout(() => {
if (editingUser.value) {
// 更新用户
const index = users.value.findIndex(u => u.id === editingUser.value.id)
if (index !== -1) {
users.value[index] = { ...userData, id: editingUser.value.id }
addLog('更新用户', `更新了用户: ${userData.name}`)
}
} else {
// 添加用户
const newUser = {
...userData,
id: Date.now(),
status: 'active'
}
users.value.push(newUser)
addLog('添加用户', `添加了用户: ${userData.name}`)
}
editingUser.value = null
formLoading.value = false
}, 1000)
}
function handleFormCancel() {
editingUser.value = null
addLog('取消操作', '取消了表单操作')
}
function clearLogs() {
actionLogs.value = []
addLog('清空日志', '清空了所有操作日志')
}
</script>
<style scoped>
.parent-component {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
background-color: #f8f9fa;
border-radius: 12px;
padding: 20px;
border: 1px solid #dee2e6;
}
.demo-section h3 {
margin: 0 0 20px 0;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.user-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.user-form-section {
background-color: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
border: 1px solid #dee2e6;
}
.user-form-section h4 {
margin: 0 0 15px 0;
color: #333;
}
.action-logs {
background-color: white;
border-radius: 8px;
padding: 20px;
border: 1px solid #dee2e6;
}
.action-logs h4 {
margin: 0 0 15px 0;
color: #333;
}
.logs-container {
max-height: 300px;
overflow-y: auto;
background-color: #f8f9fa;
border-radius: 6px;
padding: 10px;
margin-bottom: 15px;
}
.log-entry {
display: flex;
padding: 8px;
margin-bottom: 5px;
background-color: white;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
border: 1px solid #e9ecef;
}
.log-time {
color: #6c757d;
margin-right: 15px;
min-width: 80px;
}
.log-action {
color: #007bff;
font-weight: bold;
margin-right: 15px;
min-width: 80px;
}
.log-details {
color: #333;
flex: 1;
}
.clear-logs-btn {
padding: 8px 16px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.clear-logs-btn:hover {
background-color: #c82333;
}
@media (max-width: 768px) {
.user-list {
grid-template-columns: 1fr;
}
.log-entry {
flex-direction: column;
gap: 5px;
}
.log-time,
.log-action {
min-width: auto;
}
}
</style>
vue
<!-- 子组件 UserCard.vue -->
<template>
<div class="user-card" :class="{ selected: selected }">
<div class="user-avatar">
<img v-if="user.avatar" :src="user.avatar" :alt="user.name">
<div v-else class="avatar-placeholder">
{{ user.name.charAt(0).toUpperCase() }}
</div>
<span class="status-indicator" :class="statusClass"></span>
</div>
<div class="user-info">
<h4 class="user-name">{{ user.name }}</h4>
<p class="user-email">{{ user.email }}</p>
<span class="user-role" :class="roleClass">{{ user.role }}</span>
</div>
<div class="user-actions">
<button @click="handleSelect" class="select-btn" :class="{ active: selected }">
{{ selected ? '已选择' : '选择' }}
</button>
<button @click="handleEdit" class="edit-btn">
编辑
</button>
<button @click="handleDelete" class="delete-btn">
删除
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
// Props
const props = defineProps({
user: {
type: Object,
required: true
},
selected: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['select', 'edit', 'delete'])
// 计算属性
const statusClass = computed(() => {
return {
'status-active': props.user.status === 'active',
'status-inactive': props.user.status === 'inactive'
}
})
const roleClass = computed(() => {
return `role-${props.user.role}`
})
// 方法
function handleSelect() {
emit('select', props.user.id)
}
function handleEdit() {
emit('edit', props.user)
}
function handleDelete() {
emit('delete', props.user.id)
}
</script>
<style scoped>
.user-card {
background-color: white;
border-radius: 8px;
padding: 15px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
position: relative;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.user-card.selected {
border-color: #007bff;
background-color: #f8f9ff;
}
.user-avatar {
position: relative;
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.user-avatar img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #007bff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
}
.status-indicator {
position: absolute;
bottom: 0;
right: calc(50% - 35px);
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
}
.status-active {
background-color: #28a745;
}
.status-inactive {
background-color: #dc3545;
}
.user-info {
text-align: center;
margin-bottom: 15px;
}
.user-name {
margin: 0 0 5px 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.user-email {
margin: 0 0 8px 0;
font-size: 14px;
color: #6c757d;
}
.user-role {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.role-admin {
background-color: #dc3545;
color: white;
}
.role-moderator {
background-color: #ffc107;
color: #212529;
}
.role-user {
background-color: #28a745;
color: white;
}
.user-actions {
display: flex;
flex-direction: column;
gap: 5px;
}
.user-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.select-btn {
background-color: #e9ecef;
color: #495057;
}
.select-btn.active {
background-color: #007bff;
color: white;
}
.select-btn:hover {
background-color: #007bff;
color: white;
}
.edit-btn {
background-color: #ffc107;
color: #212529;
}
.edit-btn:hover {
background-color: #e0a800;
}
.delete-btn {
background-color: #dc3545;
color: white;
}
.delete-btn:hover {
background-color: #c82333;
}
</style>
v-model 双向绑定
v-model
是 props 和 events 的语法糖,用于实现双向数据绑定。
vue
<!-- CustomInput.vue -->
<template>
<div class="custom-input" :class="inputClass">
<label v-if="label" class="input-label">
{{ label }}
<span v-if="required" class="required-mark">*</span>
</label>
<div class="input-wrapper">
<input
:value="modelValue"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
class="input-field"
ref="inputRef"
>
<div v-if="showClearButton" class="input-actions">
<button @click="clearInput" class="clear-btn" type="button">
✕
</button>
</div>
</div>
<div v-if="error || helperText" class="input-footer">
<span v-if="error" class="error-message">{{ error }}</span>
<span v-else-if="helperText" class="helper-text">{{ helperText }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
label: String,
type: {
type: String,
default: 'text'
},
placeholder: String,
disabled: Boolean,
readonly: Boolean,
required: Boolean,
error: String,
helperText: String,
clearable: Boolean
})
// Emits
const emit = defineEmits({
'update:modelValue': (value) => true,
'focus': (event) => event instanceof Event,
'blur': (event) => event instanceof Event,
'clear': () => true
})
// 响应式数据
const inputRef = ref(null)
const isFocused = ref(false)
// 计算属性
const inputClass = computed(() => {
return {
'custom-input--focused': isFocused.value,
'custom-input--error': !!props.error,
'custom-input--disabled': props.disabled
}
})
const showClearButton = computed(() => {
return props.clearable && props.modelValue && !props.disabled && !props.readonly
})
// 方法
function handleInput(event) {
const value = event.target.value
emit('update:modelValue', value)
}
function handleFocus(event) {
isFocused.value = true
emit('focus', event)
}
function handleBlur(event) {
isFocused.value = false
emit('blur', event)
}
function clearInput() {
emit('update:modelValue', '')
emit('clear')
inputRef.value?.focus()
}
// 暴露方法给父组件
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur()
})
</script>
<style scoped>
.custom-input {
margin-bottom: 15px;
}
.input-label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
font-size: 14px;
}
.required-mark {
color: #dc3545;
margin-left: 2px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-field {
width: 100%;
padding: 10px 12px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 16px;
transition: all 0.3s ease;
background-color: white;
}
.input-field:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.custom-input--error .input-field {
border-color: #dc3545;
}
.custom-input--error .input-field:focus {
border-color: #dc3545;
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
}
.custom-input--disabled .input-field {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
}
.input-actions {
position: absolute;
right: 8px;
display: flex;
align-items: center;
}
.clear-btn {
width: 20px;
height: 20px;
border: none;
background-color: #6c757d;
color: white;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.clear-btn:hover {
background-color: #495057;
}
.input-footer {
margin-top: 5px;
font-size: 12px;
}
.error-message {
color: #dc3545;
}
.helper-text {
color: #6c757d;
}
</style>
使用自定义 v-model 组件:
vue
<template>
<div class="form-demo">
<h3>自定义表单组件</h3>
<form @submit.prevent="handleSubmit">
<CustomInput
v-model="form.username"
label="用户名"
placeholder="请输入用户名"
required
clearable
:error="errors.username"
@blur="validateUsername"
/>
<CustomInput
v-model="form.email"
label="邮箱"
type="email"
placeholder="请输入邮箱地址"
required
clearable
:error="errors.email"
@blur="validateEmail"
/>
<CustomInput
v-model="form.password"
label="密码"
type="password"
placeholder="请输入密码"
required
:error="errors.password"
helper-text="密码至少8个字符,包含字母和数字"
@blur="validatePassword"
/>
<button type="submit" :disabled="!isFormValid" class="submit-btn">
提交
</button>
</form>
<div class="form-data">
<h4>表单数据:</h4>
<pre>{{ JSON.stringify(form, null, 2) }}</pre>
</div>
</div>
</template>
<script setup>
import { reactive, computed } from 'vue'
import CustomInput from './CustomInput.vue'
const form = reactive({
username: '',
email: '',
password: ''
})
const errors = reactive({
username: '',
email: '',
password: ''
})
const isFormValid = computed(() => {
return form.username &&
form.email &&
form.password &&
!errors.username &&
!errors.email &&
!errors.password
})
function validateUsername() {
if (!form.username) {
errors.username = '用户名不能为空'
} else if (form.username.length < 3) {
errors.username = '用户名至少3个字符'
} else {
errors.username = ''
}
}
function validateEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!form.email) {
errors.email = '邮箱不能为空'
} else if (!emailRegex.test(form.email)) {
errors.email = '请输入有效的邮箱地址'
} else {
errors.email = ''
}
}
function validatePassword() {
if (!form.password) {
errors.password = '密码不能为空'
} else if (form.password.length < 8) {
errors.password = '密码至少8个字符'
} else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(form.password)) {
errors.password = '密码必须包含字母和数字'
} else {
errors.password = ''
}
}
function handleSubmit() {
validateUsername()
validateEmail()
validatePassword()
if (isFormValid.value) {
console.log('表单提交:', form)
alert('表单提交成功!')
}
}
</script>
Provide / Inject
用于跨层级组件通信,避免 prop drilling(属性钻取)。
vue
<!-- 祖先组件 App.vue -->
<template>
<div class="app">
<h1>应用主题管理</h1>
<div class="theme-controls">
<button
v-for="theme in themes"
:key="theme.name"
@click="setTheme(theme)"
:class="{ active: currentTheme.name === theme.name }"
class="theme-btn"
:style="{ backgroundColor: theme.primary }"
>
{{ theme.label }}
</button>
</div>
<UserDashboard />
</div>
</template>
<script setup>
import { ref, provide, computed } from 'vue'
import UserDashboard from './UserDashboard.vue'
// 主题配置
const themes = [
{
name: 'light',
label: '浅色主题',
primary: '#007bff',
secondary: '#6c757d',
background: '#ffffff',
surface: '#f8f9fa',
text: '#333333'
},
{
name: 'dark',
label: '深色主题',
primary: '#0d6efd',
secondary: '#6c757d',
background: '#1a1a1a',
surface: '#2d2d2d',
text: '#ffffff'
},
{
name: 'blue',
label: '蓝色主题',
primary: '#0066cc',
secondary: '#004499',
background: '#f0f8ff',
surface: '#e6f3ff',
text: '#003366'
}
]
const currentTheme = ref(themes[0])
// 用户数据
const currentUser = ref({
id: 1,
name: '张三',
email: 'zhangsan@example.com',
role: 'admin',
avatar: '',
preferences: {
language: 'zh-CN',
notifications: true
}
})
// 应用配置
const appConfig = ref({
name: 'Vue 学习宝典',
version: '1.0.0',
features: {
darkMode: true,
notifications: true,
analytics: false
}
})
// 计算属性
const themeVars = computed(() => {
return {
'--primary-color': currentTheme.value.primary,
'--secondary-color': currentTheme.value.secondary,
'--background-color': currentTheme.value.background,
'--surface-color': currentTheme.value.surface,
'--text-color': currentTheme.value.text
}
})
// 方法
function setTheme(theme) {
currentTheme.value = theme
}
function updateUser(userData) {
currentUser.value = { ...currentUser.value, ...userData }
}
function updateConfig(config) {
appConfig.value = { ...appConfig.value, ...config }
}
// 提供数据给后代组件
provide('theme', {
current: currentTheme,
themes,
setTheme
})
provide('user', {
current: currentUser,
updateUser
})
provide('appConfig', {
config: appConfig,
updateConfig
})
</script>
<style>
:root {
--primary-color: v-bind('themeVars["--primary-color"]');
--secondary-color: v-bind('themeVars["--secondary-color"]');
--background-color: v-bind('themeVars["--background-color"]');
--surface-color: v-bind('themeVars["--surface-color"]');
--text-color: v-bind('themeVars["--text-color"]');
}
body {
background-color: var(--background-color);
color: var(--text-color);
transition: all 0.3s ease;
}
.app {
min-height: 100vh;
padding: 20px;
}
.theme-controls {
display: flex;
gap: 10px;
margin-bottom: 30px;
justify-content: center;
}
.theme-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
opacity: 0.8;
}
.theme-btn:hover {
opacity: 1;
transform: translateY(-1px);
}
.theme-btn.active {
opacity: 1;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>
vue
<!-- 中间组件 UserDashboard.vue -->
<template>
<div class="user-dashboard">
<h2>用户仪表板</h2>
<div class="dashboard-grid">
<UserProfile />
<UserSettings />
<UserNotifications />
</div>
</div>
</template>
<script setup>
import UserProfile from './UserProfile.vue'
import UserSettings from './UserSettings.vue'
import UserNotifications from './UserNotifications.vue'
</script>
<style scoped>
.user-dashboard {
background-color: var(--surface-color);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
</style>
vue
<!-- 后代组件 UserProfile.vue -->
<template>
<div class="user-profile">
<h3>用户资料</h3>
<div class="profile-content">
<div class="user-avatar">
<img v-if="user.current.value.avatar" :src="user.current.value.avatar" :alt="user.current.value.name">
<div v-else class="avatar-placeholder">
{{ user.current.value.name.charAt(0).toUpperCase() }}
</div>
</div>
<div class="user-details">
<h4>{{ user.current.value.name }}</h4>
<p>{{ user.current.value.email }}</p>
<span class="user-role">{{ user.current.value.role }}</span>
</div>
<div class="profile-actions">
<button @click="editProfile" class="edit-btn">
编辑资料
</button>
<button @click="changeTheme" class="theme-btn">
切换主题
</button>
</div>
</div>
<div class="theme-info">
<p>当前主题: <strong>{{ theme.current.value.label }}</strong></p>
<p>应用版本: <strong>{{ appConfig.config.value.version }}</strong></p>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
// 注入祖先组件提供的数据
const theme = inject('theme')
const user = inject('user')
const appConfig = inject('appConfig')
// 方法
function editProfile() {
const newName = prompt('请输入新的用户名:', user.current.value.name)
if (newName && newName.trim()) {
user.updateUser({ name: newName.trim() })
}
}
function changeTheme() {
const currentIndex = theme.themes.findIndex(t => t.name === theme.current.value.name)
const nextIndex = (currentIndex + 1) % theme.themes.length
theme.setTheme(theme.themes[nextIndex])
}
</script>
<style scoped>
.user-profile {
background-color: var(--background-color);
border: 1px solid var(--secondary-color);
border-radius: 8px;
padding: 20px;
}
.user-profile h3 {
margin: 0 0 15px 0;
color: var(--text-color);
}
.profile-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 20px;
}
.user-avatar {
margin-bottom: 15px;
}
.user-avatar img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: bold;
}
.user-details h4 {
margin: 0 0 5px 0;
color: var(--text-color);
}
.user-details p {
margin: 0 0 10px 0;
color: var(--secondary-color);
}
.user-role {
display: inline-block;
padding: 4px 12px;
background-color: var(--primary-color);
color: white;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.profile-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.edit-btn,
.theme-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.edit-btn {
background-color: var(--primary-color);
color: white;
}
.theme-btn {
background-color: var(--secondary-color);
color: white;
}
.edit-btn:hover,
.theme-btn:hover {
opacity: 0.8;
transform: translateY(-1px);
}
.theme-info {
padding: 15px;
background-color: var(--surface-color);
border-radius: 6px;
border: 1px solid var(--secondary-color);
}
.theme-info p {
margin: 5px 0;
font-size: 14px;
color: var(--text-color);
}
</style>
事件总线 (Event Bus)
对于兄弟组件或跨层级组件通信,可以使用事件总线模式。
javascript
// eventBus.js
import { ref } from 'vue'
class EventBus {
constructor() {
this.events = {}
}
// 监听事件
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
// 返回取消监听的函数
return () => {
this.off(event, callback)
}
}
// 触发事件
emit(event, ...args) {
if (this.events[event]) {
this.events[event].forEach(callback => {
callback(...args)
})
}
}
// 取消监听
off(event, callback) {
if (this.events[event]) {
const index = this.events[event].indexOf(callback)
if (index > -1) {
this.events[event].splice(index, 1)
}
}
}
// 只监听一次
once(event, callback) {
const onceCallback = (...args) => {
callback(...args)
this.off(event, onceCallback)
}
this.on(event, onceCallback)
}
// 清除所有监听器
clear() {
this.events = {}
}
}
export const eventBus = new EventBus()
// 提供一些常用的事件名称
export const EVENTS = {
USER_LOGIN: 'user:login',
USER_LOGOUT: 'user:logout',
THEME_CHANGE: 'theme:change',
NOTIFICATION_SHOW: 'notification:show',
MODAL_OPEN: 'modal:open',
MODAL_CLOSE: 'modal:close'
}
使用事件总线:
vue
<!-- 组件 A -->
<template>
<div class="component-a">
<h3>组件 A - 消息发送者</h3>
<div class="message-form">
<input
v-model="message"
placeholder="输入消息..."
@keyup.enter="sendMessage"
class="message-input"
>
<button @click="sendMessage" :disabled="!message.trim()" class="send-btn">
发送消息
</button>
</div>
<div class="quick-actions">
<button @click="sendNotification" class="notification-btn">
发送通知
</button>
<button @click="openModal" class="modal-btn">
打开模态框
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { eventBus, EVENTS } from './eventBus.js'
const message = ref('')
function sendMessage() {
if (message.value.trim()) {
eventBus.emit('message:send', {
id: Date.now(),
text: message.value,
sender: '组件 A',
timestamp: new Date().toLocaleTimeString()
})
message.value = ''
}
}
function sendNotification() {
eventBus.emit(EVENTS.NOTIFICATION_SHOW, {
type: 'info',
title: '来自组件 A 的通知',
message: '这是一条测试通知消息',
duration: 3000
})
}
function openModal() {
eventBus.emit(EVENTS.MODAL_OPEN, {
title: '模态框标题',
content: '这是从组件 A 打开的模态框',
type: 'info'
})
}
</script>
vue
<!-- 组件 B -->
<template>
<div class="component-b">
<h3>组件 B - 消息接收者</h3>
<div class="messages-container">
<h4>接收到的消息 ({{ messages.length }}):</h4>
<div class="messages-list">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
>
<div class="message-header">
<span class="message-sender">{{ msg.sender }}</span>
<span class="message-time">{{ msg.timestamp }}</span>
</div>
<div class="message-text">{{ msg.text }}</div>
</div>
</div>
<button @click="clearMessages" class="clear-btn">
清空消息
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from './eventBus.js'
const messages = ref([])
let unsubscribe = null
function handleMessage(messageData) {
messages.value.unshift(messageData)
// 限制消息数量
if (messages.value.length > 50) {
messages.value = messages.value.slice(0, 50)
}
}
function clearMessages() {
messages.value = []
}
onMounted(() => {
// 监听消息事件
unsubscribe = eventBus.on('message:send', handleMessage)
})
onUnmounted(() => {
// 清理事件监听器
if (unsubscribe) {
unsubscribe()
}
})
</script>
状态管理 (Pinia)
对于复杂的应用状态管理,推荐使用 Pinia。
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// 状态
const currentUser = ref(null)
const users = ref([])
const loading = ref(false)
const error = ref('')
// 计算属性
const isLoggedIn = computed(() => !!currentUser.value)
const userCount = computed(() => users.value.length)
const activeUsers = computed(() =>
users.value.filter(user => user.status === 'active')
)
// 方法
async function login(credentials) {
loading.value = true
error.value = ''
try {
// 模拟 API 调用
const response = await mockLogin(credentials)
currentUser.value = response.user
return response
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
function logout() {
currentUser.value = null
}
async function fetchUsers() {
loading.value = true
try {
const response = await mockFetchUsers()
users.value = response.users
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function updateUser(userId, userData) {
const index = users.value.findIndex(user => user.id === userId)
if (index !== -1) {
users.value[index] = { ...users.value[index], ...userData }
}
if (currentUser.value?.id === userId) {
currentUser.value = { ...currentUser.value, ...userData }
}
}
function addUser(userData) {
const newUser = {
id: Date.now(),
...userData,
createdAt: new Date().toISOString()
}
users.value.push(newUser)
return newUser
}
function removeUser(userId) {
users.value = users.value.filter(user => user.id !== userId)
}
return {
// 状态
currentUser,
users,
loading,
error,
// 计算属性
isLoggedIn,
userCount,
activeUsers,
// 方法
login,
logout,
fetchUsers,
updateUser,
addUser,
removeUser
}
})
// 模拟 API 函数
function mockLogin(credentials) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (credentials.username === 'admin' && credentials.password === 'password') {
resolve({
user: {
id: 1,
username: 'admin',
name: '管理员',
email: 'admin@example.com',
role: 'admin'
},
token: 'mock-jwt-token'
})
} else {
reject(new Error('用户名或密码错误'))
}
}, 1000)
})
}
function mockFetchUsers() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
users: [
{
id: 1,
username: 'admin',
name: '管理员',
email: 'admin@example.com',
role: 'admin',
status: 'active'
},
{
id: 2,
username: 'user1',
name: '用户一',
email: 'user1@example.com',
role: 'user',
status: 'active'
},
{
id: 3,
username: 'user2',
name: '用户二',
email: 'user2@example.com',
role: 'user',
status: 'inactive'
}
]
})
}, 800)
})
}
在组件中使用 Pinia store:
vue
<template>
<div class="user-management">
<h2>用户管理</h2>
<div v-if="!userStore.isLoggedIn" class="login-section">
<h3>请登录</h3>
<form @submit.prevent="handleLogin">
<input
v-model="loginForm.username"
placeholder="用户名"
required
>
<input
v-model="loginForm.password"
type="password"
placeholder="密码"
required
>
<button type="submit" :disabled="userStore.loading">
{{ userStore.loading ? '登录中...' : '登录' }}
</button>
</form>
<p v-if="userStore.error" class="error">{{ userStore.error }}</p>
</div>
<div v-else class="user-dashboard">
<div class="user-info">
<h3>欢迎, {{ userStore.currentUser.name }}!</h3>
<button @click="userStore.logout" class="logout-btn">
退出登录
</button>
</div>
<div class="users-section">
<div class="users-header">
<h4>用户列表 ({{ userStore.userCount }} 个用户)</h4>
<p>活跃用户: {{ userStore.activeUsers.length }}</p>
<button @click="userStore.fetchUsers" :disabled="userStore.loading">
{{ userStore.loading ? '加载中...' : '刷新用户' }}
</button>
</div>
<div class="users-grid">
<div
v-for="user in userStore.users"
:key="user.id"
class="user-card"
>
<h5>{{ user.name }}</h5>
<p>{{ user.email }}</p>
<span class="user-role">{{ user.role }}</span>
<span class="user-status" :class="user.status">
{{ user.status === 'active' ? '活跃' : '非活跃' }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loginForm = reactive({
username: '',
password: ''
})
async function handleLogin() {
try {
await userStore.login(loginForm)
// 登录成功后获取用户列表
await userStore.fetchUsers()
} catch (error) {
console.error('登录失败:', error)
}
}
onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUsers()
}
})
</script>
组件通信最佳实践
1. 选择合适的通信方式
javascript
// ✅ 父子组件:使用 props 和 events
// 父组件传递数据给子组件
<ChildComponent :data="parentData" @update="handleUpdate" />
// ✅ 跨层级组件:使用 provide/inject
provide('theme', themeData)
const theme = inject('theme')
// ✅ 兄弟组件:使用事件总线或状态管理
eventBus.emit('message', data)
const store = useStore()
// ✅ 复杂状态:使用 Pinia
const userStore = useUserStore()
2. 避免过度通信
javascript
// ❌ 避免:过度的 prop drilling
<GrandChild :data="data" :config="config" :theme="theme" :user="user" />
// ✅ 推荐:使用 provide/inject 或状态管理
provide('appContext', { data, config, theme, user })
3. 保持数据流向清晰
javascript
// ✅ 推荐:单向数据流
// 父组件 -> 子组件:props
// 子组件 -> 父组件:events
// ❌ 避免:直接修改 props
props.data.value = newValue // 错误!
// ✅ 正确:通过事件通知父组件
emit('update:data', newValue)