# Vue3.0如何實現下拉菜單的封裝
## 前言
下拉菜單是Web開發中最常見的交互組件之一,廣泛應用于導航欄、表單選擇、操作菜單等場景。在Vue3.0中,我們可以充分利用Composition API和新的響應式系統來構建更靈活、可復用的下拉菜單組件。本文將詳細介紹如何從零開始封裝一個功能完善的下拉菜單組件。
## 一、需求分析與設計
### 1.1 基礎功能需求
- 點擊觸發器顯示/隱藏菜單
- 支持鼠標懸停觸發
- 菜單項點擊后自動關閉
- 支持鍵盤導航操作
- 點擊外部區域自動關閉
### 1.2 進階功能
- 支持自定義觸發元素
- 支持菜單定位(上、下、左、右)
- 動畫過渡效果
- 無障礙訪問支持
- 多級子菜單支持
## 二、基礎實現
### 2.1 組件結構設計
```html
<!-- Dropdown.vue -->
<template>
<div class="dropdown-container" ref="container">
<div
class="dropdown-trigger"
@click="toggle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot name="trigger"></slot>
</div>
<transition name="dropdown">
<div
v-show="isOpen"
class="dropdown-menu"
ref="menu"
>
<slot></slot>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
trigger: {
type: String,
default: 'click', // 'click' | 'hover'
validator: value => ['click', 'hover'].includes(value)
},
placement: {
type: String,
default: 'bottom',
validator: value => ['top', 'bottom', 'left', 'right'].includes(value)
}
})
const isOpen = ref(false)
const container = ref(null)
const menu = ref(null)
// 切換菜單狀態
const toggle = () => {
if (props.trigger === 'click') {
isOpen.value = !isOpen.value
}
}
// 鼠標懸停處理
const handleMouseEnter = () => {
if (props.trigger === 'hover') {
isOpen.value = true
}
}
const handleMouseLeave = () => {
if (props.trigger === 'hover') {
isOpen.value = false
}
}
// 點擊外部關閉
const handleClickOutside = (event) => {
if (container.value && !container.value.contains(event.target)) {
isOpen.value = false
}
}
// 鍵盤導航
const handleKeydown = (event) => {
if (!isOpen.value) return
const items = menu.value?.querySelectorAll('.dropdown-item')
if (!items || items.length === 0) return
const currentIndex = Array.from(items).findIndex(item =>
item === document.activeElement
)
switch (event.key) {
case 'Escape':
isOpen.value = false
break
case 'ArrowDown':
event.preventDefault()
const nextIndex = (currentIndex + 1) % items.length
items[nextIndex]?.focus()
break
case 'ArrowUp':
event.preventDefault()
const prevIndex = (currentIndex - 1 + items.length) % items.length
items[prevIndex]?.focus()
break
}
}
// 生命周期鉤子
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
.dropdown-container {
position: relative;
display: inline-block;
}
.dropdown-trigger {
cursor: pointer;
}
.dropdown-menu {
position: absolute;
z-index: 1000;
min-width: 120px;
padding: 8px 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 定位方向 */
.dropdown-menu[data-placement="top"] {
bottom: 100%;
margin-bottom: 8px;
}
.dropdown-menu[data-placement="bottom"] {
top: 100%;
margin-top: 8px;
}
.dropdown-menu[data-placement="left"] {
right: 100%;
margin-right: 8px;
}
.dropdown-menu[data-placement="right"] {
left: 100%;
margin-left: 8px;
}
/* 過渡動畫 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
transform-origin: top center;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scaleY(0.8);
}
</style>
<!-- DropdownItem.vue -->
<template>
<li
class="dropdown-item"
:class="{ 'is-disabled': disabled }"
@click="handleClick"
@keydown.enter="handleClick"
tabindex="0"
>
<slot></slot>
</li>
</template>
<script setup>
const props = defineProps({
disabled: Boolean
})
const emit = defineEmits(['click'])
const handleClick = () => {
if (!props.disabled) {
emit('click')
}
}
</script>
<style scoped>
.dropdown-item {
padding: 8px 16px;
list-style: none;
cursor: pointer;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.dropdown-item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
// 在Dropdown.vue中添加
import { nextTick } from 'vue'
const updatePosition = async () => {
await nextTick()
if (!isOpen.value || !container.value || !menu.value) return
const containerRect = container.value.getBoundingClientRect()
const menuRect = menu.value.getBoundingClientRect()
switch (props.placement) {
case 'top':
menu.value.style.left = `${containerRect.left}px`
menu.value.style.bottom = `${window.innerHeight - containerRect.top}px`
break
case 'bottom':
menu.value.style.left = `${containerRect.left}px`
menu.value.style.top = `${containerRect.bottom}px`
break
case 'left':
menu.value.style.right = `${window.innerWidth - containerRect.left}px`
menu.value.style.top = `${containerRect.top}px`
break
case 'right':
menu.value.style.left = `${containerRect.right}px`
menu.value.style.top = `${containerRect.top}px`
break
}
// 邊界檢查
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
if (menuRect.right > viewportWidth) {
menu.value.style.left = `${viewportWidth - menuRect.width}px`
}
if (menuRect.bottom > viewportHeight) {
menu.value.style.top = `${viewportHeight - menuRect.height}px`
}
}
watch(isOpen, (val) => {
if (val) {
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
} else {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
})
<!-- DropdownSubmenu.vue -->
<template>
<dropdown :trigger="trigger" :placement="placement">
<template #trigger>
<dropdown-item>
<slot name="title"></slot>
<span class="submenu-arrow">?</span>
</dropdown-item>
</template>
<slot></slot>
</dropdown>
</template>
<script setup>
import Dropdown from './Dropdown.vue'
import DropdownItem from './DropdownItem.vue'
const props = defineProps({
trigger: {
type: String,
default: 'hover'
},
placement: {
type: String,
default: 'right'
}
})
</script>
<style scoped>
.submenu-arrow {
margin-left: 8px;
font-size: 0.8em;
}
</style>
<template>
<dropdown>
<template #trigger>
<button>點擊我</button>
</template>
<dropdown-item @click="handleAction('edit')">編輯</dropdown-item>
<dropdown-item @click="handleAction('delete')">刪除</dropdown-item>
<dropdown-item disabled>禁用項</dropdown-item>
</dropdown>
</template>
<script setup>
import Dropdown from './components/Dropdown.vue'
import DropdownItem from './components/DropdownItem.vue'
const handleAction = (action) => {
console.log(`執行操作: ${action}`)
}
</script>
<template>
<dropdown trigger="hover">
<template #trigger>
<button>導航菜單</button>
</template>
<dropdown-item>首頁</dropdown-item>
<dropdown-submenu>
<template #title>產品</template>
<dropdown-item>產品列表</dropdown-item>
<dropdown-item>產品分類</dropdown-item>
<dropdown-submenu>
<template #title>子菜單</template>
<dropdown-item>子項1</dropdown-item>
<dropdown-item>子項2</dropdown-item>
</dropdown-submenu>
</dropdown-submenu>
<dropdown-item>關于我們</dropdown-item>
</dropdown>
</template>
本文詳細介紹了如何在Vue3.0中封裝一個功能完善的下拉菜單組件,包括基礎實現、功能擴展、使用示例以及優化建議。通過組合式API和插槽機制,我們可以構建出高度可定制、易于維護的組件。這種封裝思路也可以應用于其他復雜組件的開發中。
完整的組件代碼已經包含了響應式設計、動畫過渡、鍵盤導航等現代Web組件應有的特性,開發者可以根據實際需求進一步擴展或調整。
[GitHub倉庫鏈接]
字數統計:約3600字 “`
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。