在 Vue 开发中,组件通信和自定义指令是构建复杂应用的重要技能。本文将深入探讨 Vue 3 中各种组件通信方式的使用场景和最佳实践,以及如何创建实用的自定义指令来提升开发效率和用户体验。
Vue 3 组件通信全景图 1. Props 和 Emits(父子通信) 基础用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <!-- 父组件 --> <template> <div> <UserCard :user="currentUser" :editable="isAdmin" @update-user="handleUserUpdate" @delete-user="handleUserDelete" /> </div> </template> <script setup> import { ref } from 'vue' import UserCard from './UserCard.vue' const currentUser = ref({ id: 1, name: 'John Doe', email: 'john@example.com', role: 'user' }) const isAdmin = ref(true) function handleUserUpdate(updatedUser) { currentUser.value = { ...currentUser.value, ...updatedUser } console.log('用户信息已更新:', updatedUser) } function handleUserDelete(userId) { console.log('删除用户:', userId) // 执行删除逻辑 } </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 <!-- 子组件 UserCard.vue --> <template> <div class="user-card"> <div class="user-info"> <h3>{{ user.name }}</h3> <p>{{ user.email }}</p> <span class="role-badge" :class="user.role">{{ user.role }}</span> </div> <div v-if="editable" class="actions"> <button @click="editUser">编辑</button> <button @click="deleteUser" class="danger">删除</button> </div> <!-- 编辑模态框 --> <div v-if="showEditModal" class="modal"> <div class="modal-content"> <h4>编辑用户</h4> <form @submit.prevent="saveUser"> <input v-model="editForm.name" placeholder="姓名" required> <input v-model="editForm.email" type="email" placeholder="邮箱" required> <select v-model="editForm.role"> <option value="user">普通用户</option> <option value="admin">管理员</option> </select> <div class="modal-actions"> <button type="submit">保存</button> <button type="button" @click="cancelEdit">取消</button> </div> </form> </div> </div> </div> </template> <script setup> import { ref, reactive } from 'vue' // Props 定义 const props = defineProps({ user: { type: Object, required: true, validator: (user) => { return user && typeof user.id !== 'undefined' && user.name && user.email } }, editable: { type: Boolean, default: false } }) // Emits 定义 const emit = defineEmits({ 'update-user': (user) => { // 验证事件参数 return user && typeof user === 'object' }, 'delete-user': (userId) => { return typeof userId === 'number' || typeof userId === 'string' } }) const showEditModal = ref(false) const editForm = reactive({ name: '', email: '', role: 'user' }) function editUser() { // 初始化编辑表单 Object.assign(editForm, props.user) showEditModal.value = true } function saveUser() { // 发送更新事件 emit('update-user', { ...editForm }) showEditModal.value = false } function cancelEdit() { showEditModal.value = false } function deleteUser() { if (confirm('确定要删除这个用户吗?')) { emit('delete-user', props.user.id) } } </script>
2. v-model 双向绑定 自定义组件的 v-model
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 <!-- 自定义输入组件 --> <template> <div class="custom-input"> <label v-if="label">{{ label }}</label> <input :value="modelValue" @input="updateValue" :type="type" :placeholder="placeholder" :disabled="disabled" :class="{ error: hasError }" > <span v-if="hasError" class="error-message">{{ errorMessage }}</span> </div> </template> <script setup> import { computed } from 'vue' const props = defineProps({ modelValue: [String, Number], label: String, type: { type: String, default: 'text' }, placeholder: String, disabled: Boolean, validator: Function, errorMessage: String }) const emit = defineEmits(['update:modelValue']) const hasError = computed(() => { if (props.validator && props.modelValue) { return !props.validator(props.modelValue) } return false }) function updateValue(event) { let value = event.target.value // 类型转换 if (props.type === 'number') { value = value === '' ? null : Number(value) } emit('update:modelValue', value) } </script>
多个 v-model 绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <!-- 用户表单组件 --> <template> <form class="user-form"> <div class="form-group"> <label>姓名</label> <input :value="firstName" @input="emit('update:firstName', $event.target.value)" > </div> <div class="form-group"> <label>姓氏</label> <input :value="lastName" @input="emit('update:lastName', $event.target.value)" > </div> <div class="form-group"> <label>邮箱</label> <input :value="email" @input="emit('update:email', $event.target.value)" type="email" > </div> </form> </template> <script setup> defineProps(['firstName', 'lastName', 'email']) const emit = defineEmits(['update:firstName', 'update:lastName', 'update:email']) </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <!-- 使用多个 v-model --> <template> <div> <UserForm v-model:first-name="user.firstName" v-model:last-name="user.lastName" v-model:email="user.email" /> <p>完整姓名: {{ fullName }}</p> </div> </template> <script setup> import { reactive, computed } from 'vue' import UserForm from './UserForm.vue' const user = reactive({ firstName: '', lastName: '', email: '' }) const fullName = computed(() => `${user.firstName} ${user.lastName}`.trim()) </script>
3. Provide/Inject(跨层级通信) 基础用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <!-- 根组件或祖先组件 --> <template> <div class="app"> <ThemeToggle /> <UserProfile /> <ProductList /> </div> </template> <script setup> import { provide, ref, readonly } from 'vue' import ThemeToggle from './ThemeToggle.vue' import UserProfile from './UserProfile.vue' import ProductList from './ProductList.vue' // 主题状态 const theme = ref('light') const toggleTheme = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' } // 用户状态 const currentUser = ref({ id: 1, name: 'John Doe', avatar: '/avatars/john.jpg', permissions: ['read', 'write'] }) // 全局配置 const appConfig = ref({ apiBaseUrl: 'https://api.example.com', version: '1.0.0', features: { darkMode: true, notifications: true } }) // 提供数据和方法 provide('theme', { current: readonly(theme), toggle: toggleTheme }) provide('user', readonly(currentUser)) provide('config', readonly(appConfig)) // 提供工具函数 provide('utils', { formatDate: (date) => new Intl.DateTimeFormat('zh-CN').format(date), formatCurrency: (amount) => new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount) }) </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <!-- 深层子组件 --> <template> <div class="product-card" :class="themeClass"> <h3>{{ product.name }}</h3> <p class="price">{{ formatCurrency(product.price) }}</p> <p class="date">发布时间: {{ formatDate(product.createdAt) }}</p> <button v-if="canEdit" @click="editProduct" class="edit-btn" > 编辑 </button> </div> </template> <script setup> import { inject, computed } from 'vue' const props = defineProps(['product']) // 注入依赖 const { current: theme } = inject('theme') const user = inject('user') const { formatDate, formatCurrency } = inject('utils') // 计算属性 const themeClass = computed(() => `theme-${theme.value}`) const canEdit = computed(() => user.value.permissions.includes('write')) function editProduct() { console.log('编辑产品:', props.product.id) } </script>
响应式 Provide/Inject
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 import { ref, provide, inject, readonly } from 'vue' const GLOBAL_STATE_KEY = Symbol ('globalState' )export function createGlobalState ( ) { const state = ref ({ user : null , notifications : [], settings : { theme : 'light' , language : 'zh-CN' } }) const actions = { setUser (user ) { state.value .user = user }, addNotification (notification ) { state.value .notifications .push ({ id : Date .now (), timestamp : new Date (), ...notification }) }, removeNotification (id ) { const index = state.value .notifications .findIndex (n => n.id === id) if (index > -1 ) { state.value .notifications .splice (index, 1 ) } }, updateSettings (newSettings ) { state.value .settings = { ...state.value .settings , ...newSettings } } } provide (GLOBAL_STATE_KEY , { state : readonly (state), ...actions }) return { state, ...actions } } export function useGlobalState ( ) { const globalState = inject (GLOBAL_STATE_KEY ) if (!globalState) { throw new Error ('useGlobalState must be used within a provider' ) } return globalState }
4. 事件总线(Event Bus) 创建类型安全的事件总线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 import { ref } from 'vue' class EventBus { constructor ( ) { this .events = new Map () } on (event, callback ) { if (!this .events .has (event)) { this .events .set (event, []) } this .events .get (event).push (callback) return () => this .off (event, callback) } off (event, callback ) { if (this .events .has (event)) { const callbacks = this .events .get (event) const index = callbacks.indexOf (callback) if (index > -1 ) { callbacks.splice (index, 1 ) } } } emit (event, ...args ) { if (this .events .has (event)) { this .events .get (event).forEach (callback => { try { callback (...args) } catch (error) { console .error (`事件处理器错误 [${event} ]:` , error) } }) } } once (event, callback ) { const unsubscribe = this .on (event, (...args ) => { callback (...args) unsubscribe () }) return unsubscribe } clear ( ) { this .events .clear () } } export const eventBus = new EventBus ()export const EventTypes = { USER_LOGIN : 'user:login' , USER_LOGOUT : 'user:logout' , NOTIFICATION_SHOW : 'notification:show' , NOTIFICATION_HIDE : 'notification:hide' , THEME_CHANGE : 'theme:change' , CART_ADD_ITEM : 'cart:addItem' , CART_REMOVE_ITEM : 'cart:removeItem' }
在组件中使用事件总线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 <!-- 购物车组件 --> <template> <div class="cart"> <h3>购物车 ({{ items.length }})</h3> <div v-for="item in items" :key="item.id" class="cart-item"> <span>{{ item.name }}</span> <span>{{ item.price }}</span> <button @click="removeItem(item.id)">移除</button> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import { eventBus, EventTypes } from '@/utils/eventBus' const items = ref([]) let unsubscribers = [] function addItem(item) { items.value.push(item) } function removeItem(itemId) { const index = items.value.findIndex(item => item.id === itemId) if (index > -1) { const removedItem = items.value.splice(index, 1)[0] eventBus.emit(EventTypes.CART_REMOVE_ITEM, removedItem) } } onMounted(() => { // 订阅添加商品事件 unsubscribers.push( eventBus.on(EventTypes.CART_ADD_ITEM, addItem) ) }) onUnmounted(() => { // 清理事件订阅 unsubscribers.forEach(unsubscribe => unsubscribe()) }) </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <!-- 产品列表组件 --> <template> <div class="product-list"> <div v-for="product in products" :key="product.id" class="product"> <h4>{{ product.name }}</h4> <p>{{ product.price }}</p> <button @click="addToCart(product)">加入购物车</button> </div> </div> </template> <script setup> import { eventBus, EventTypes } from '@/utils/eventBus' const props = defineProps(['products']) function addToCart(product) { // 发送添加到购物车事件 eventBus.emit(EventTypes.CART_ADD_ITEM, { id: product.id, name: product.name, price: product.price, quantity: 1 }) // 显示通知 eventBus.emit(EventTypes.NOTIFICATION_SHOW, { type: 'success', message: `${product.name} 已加入购物车` }) } </script>
自定义指令实战 1. 基础自定义指令 v-focus 自动聚焦指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export const vFocus = { mounted (el, binding ) { if (binding.value !== false ) { setTimeout (() => { el.focus () }, 0 ) } }, updated (el, binding ) { if (binding.value && !binding.oldValue ) { el.focus () } } }
v-click-outside 点击外部指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export const vClickOutside = { mounted (el, binding ) { el._clickOutsideHandler = (event ) => { if (!(el === event.target || el.contains (event.target ))) { if (typeof binding.value === 'function' ) { binding.value (event) } } } setTimeout (() => { document .addEventListener ('click' , el._clickOutsideHandler ) }, 0 ) }, unmounted (el ) { if (el._clickOutsideHandler ) { document .removeEventListener ('click' , el._clickOutsideHandler ) delete el._clickOutsideHandler } } }
2. 高级自定义指令 v-lazy 图片懒加载指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 const defaultOptions = { threshold : 0.1 , rootMargin : '50px 0px' } export const vLazy = { mounted (el, binding ) { const options = { ...defaultOptions, ...binding.arg } const observer = new IntersectionObserver ((entries ) => { entries.forEach (entry => { if (entry.isIntersecting ) { const img = entry.target const src = binding.value const imageLoader = new Image () imageLoader.onload = () => { img.src = src img.classList .add ('lazy-loaded' ) img.classList .remove ('lazy-loading' ) } imageLoader.onerror = () => { img.src = '/images/placeholder-error.jpg' img.classList .add ('lazy-error' ) img.classList .remove ('lazy-loading' ) } img.classList .add ('lazy-loading' ) imageLoader.src = src observer.unobserve (img) } }) }, options) el.classList .add ('lazy-image' ) el.src = el.dataset .placeholder || '/images/placeholder.jpg' observer.observe (el) el._lazyObserver = observer }, updated (el, binding ) { if (binding.value !== binding.oldValue ) { el.classList .remove ('lazy-loaded' , 'lazy-error' ) el._lazyObserver .observe (el) } }, unmounted (el ) { if (el._lazyObserver ) { el._lazyObserver .disconnect () delete el._lazyObserver } } }
v-permission 权限控制指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import { useUserStore } from '@/stores/user' export const vPermission = { mounted (el, binding ) { checkPermission (el, binding) }, updated (el, binding ) { checkPermission (el, binding) } } function checkPermission (el, binding ) { const userStore = useUserStore () const { value : requiredPermissions, arg : mode = 'some' } = binding if (!requiredPermissions) { console .warn ('v-permission 指令需要权限参数' ) return } const permissions = Array .isArray (requiredPermissions) ? requiredPermissions : [requiredPermissions] const userPermissions = userStore.permissions || [] let hasPermission = false if (mode === 'every' ) { hasPermission = permissions.every (permission => userPermissions.includes (permission) ) } else { hasPermission = permissions.some (permission => userPermissions.includes (permission) ) } if (!hasPermission) { if (binding.modifiers .remove ) { el.remove () } else { el.style .display = 'none' } } else { el.style .display = '' } }
v-loading 加载状态指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 import { createApp } from 'vue' import LoadingComponent from '@/components/Loading.vue' export const vLoading = { mounted (el, binding ) { createLoadingInstance (el, binding) }, updated (el, binding ) { if (binding.value !== binding.oldValue ) { if (binding.value ) { showLoading (el, binding) } else { hideLoading (el) } } }, unmounted (el ) { hideLoading (el) } } function createLoadingInstance (el, binding ) { const loadingText = binding.arg || '加载中...' const size = binding.modifiers .small ? 'small' : binding.modifiers .large ? 'large' : 'medium' const loadingApp = createApp (LoadingComponent , { text : loadingText, size : size }) const loadingEl = document .createElement ('div' ) loadingEl.className = 'v-loading-container' loadingApp.mount (loadingEl) el._loadingInstance = { app : loadingApp, element : loadingEl } if (binding.value ) { showLoading (el, binding) } } function showLoading (el, binding ) { const { element } = el._loadingInstance const originalPosition = getComputedStyle (el).position if (originalPosition === 'static' ) { el.style .position = 'relative' } element.style .position = 'absolute' element.style .top = '0' element.style .left = '0' element.style .width = '100%' element.style .height = '100%' element.style .backgroundColor = 'rgba(255, 255, 255, 0.8)' element.style .display = 'flex' element.style .alignItems = 'center' element.style .justifyContent = 'center' element.style .zIndex = '1000' el.appendChild (element) } function hideLoading (el ) { if (el._loadingInstance ) { const { element } = el._loadingInstance if (element.parentNode ) { element.parentNode .removeChild (element) } } }
3. 指令的注册和使用 全局注册指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { createApp } from 'vue' import App from './App.vue' import { vFocus } from './directives/focus' import { vClickOutside } from './directives/clickOutside' import { vLazy } from './directives/lazy' import { vPermission } from './directives/permission' import { vLoading } from './directives/loading' const app = createApp (App )app.directive ('focus' , vFocus) app.directive ('click-outside' , vClickOutside) app.directive ('lazy' , vLazy) app.directive ('permission' , vPermission) app.directive ('loading' , vLoading) app.mount ('#app' )
在组件中使用指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 <template> <div class="demo-page"> <!-- 自动聚焦 --> <input v-focus placeholder="自动聚焦的输入框"> <!-- 点击外部关闭 --> <div class="dropdown" v-click-outside="closeDropdown"> <button @click="showDropdown = !showDropdown">下拉菜单</button> <ul v-if="showDropdown" class="dropdown-menu"> <li>选项 1</li> <li>选项 2</li> <li>选项 3</li> </ul> </div> <!-- 图片懒加载 --> <div class="image-gallery"> <img v-for="image in images" :key="image.id" v-lazy="image.src" :data-placeholder="image.placeholder" :alt="image.alt" class="gallery-image" > </div> <!-- 权限控制 --> <button v-permission="['admin', 'editor']" @click="deleteItem"> 删除(需要管理员或编辑权限) </button> <button v-permission:every="['admin', 'super']" @click="systemConfig"> 系统配置(需要管理员和超级权限) </button> <!-- 加载状态 --> <div v-loading="isLoading" class="content-area"> <p>这里是内容区域</p> <button @click="loadData">加载数据</button> </div> </div> </template> <script setup> import { ref } from 'vue' const showDropdown = ref(false) const isLoading = ref(false) const images = ref([ { id: 1, src: 'https://example.com/image1.jpg', placeholder: '/images/placeholder.jpg', alt: '图片1' }, // 更多图片... ]) function closeDropdown() { showDropdown.value = false } function deleteItem() { console.log('删除操作') } function systemConfig() { console.log('系统配置') } async function loadData() { isLoading.value = true try { // 模拟 API 调用 await new Promise(resolve => setTimeout(resolve, 2000)) console.log('数据加载完成') } finally { isLoading.value = false } } </script>
组合式函数封装通信逻辑 useEventBus 组合式函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { onUnmounted } from 'vue' import { eventBus } from '@/utils/eventBus' export function useEventBus ( ) { const unsubscribers = [] const on = (event, callback ) => { const unsubscribe = eventBus.on (event, callback) unsubscribers.push (unsubscribe) return unsubscribe } const emit = (event, ...args ) => { eventBus.emit (event, ...args) } const once = (event, callback ) => { const unsubscribe = eventBus.once (event, callback) unsubscribers.push (unsubscribe) return unsubscribe } onUnmounted (() => { unsubscribers.forEach (unsubscribe => unsubscribe ()) }) return { on, emit, once } }
useParentChild 组合式函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 import { getCurrentInstance, provide, inject } from 'vue' const PARENT_CHILD_KEY = Symbol ('parentChild' )export function useParent ( ) { const children = new Set () const addChild = (child ) => { children.add (child) } const removeChild = (child ) => { children.delete (child) } const broadcastToChildren = (method, ...args ) => { children.forEach (child => { if (child[method]) { child[method](...args) } }) } provide (PARENT_CHILD_KEY , { addChild, removeChild }) return { children, broadcastToChildren } } export function useChild ( ) { const instance = getCurrentInstance () const parent = inject (PARENT_CHILD_KEY , null ) if (parent) { parent.addChild (instance.proxy ) onUnmounted (() => { parent.removeChild (instance.proxy ) }) } return { parent : parent ? parent : null } }
总结 Vue 3 提供了丰富的组件通信方式和强大的自定义指令系统,让我们能够构建复杂而灵活的应用。关键要点包括:
组件通信最佳实践:
Props/Emits :适用于父子组件直接通信
v-model :适用于双向数据绑定场景
Provide/Inject :适用于跨层级的依赖注入
Event Bus :适用于兄弟组件或远距离组件通信
状态管理 :适用于复杂的全局状态管理
自定义指令最佳实践:
关注 DOM 操作 :指令主要用于 DOM 相关的操作
生命周期管理 :正确处理指令的挂载、更新和卸载
性能优化 :避免在指令中进行重复的计算和 DOM 操作
错误处理 :添加适当的错误处理和边界情况处理
可复用性 :设计通用的指令,通过参数和修饰符提供灵活性
通过合理选择通信方式和创建实用的自定义指令,可以大大提升 Vue 应用的开发效率和用户体验。