下拉菜单组件:综合运用泛型与defineModel
从业务组件中提炼通用组件
上一节我们完成了 ChangeLocale 语言切换组件的基本功能,但其中的 Dropdown 逻辑是硬编码的。本节的目标是将 Dropdown 提炼为一个通用组件,具备以下能力:
- 泛型支持:让 Dropdown 可以接受任意类型的数据项
- 选中状态管理:通过
v-model或事件的形式控制哪个选项被激活 - 高度可定制:通过 Slot 让用户自定义每一项的渲染内容
泛型组件的定义
Vue 3.3 开始支持在 <script setup> 中使用 generic 属性来定义泛型组件。这是实现类型安全通用组件的核心能力。
<!-- components/Dropdown.vue -->
<script setup lang="ts" generic="T extends { icon?: string }">
import type { IconifyIcon } from '@iconify/vue'
interface DropdownMenuProps<T> {
items: T[]
iconProps?: Partial<InstanceType<typeof IconifyIcon>['$props']>
iconClass?: string
}
const props = withDefaults(defineProps<DropdownMenuProps<T>>(), {
iconProps: undefined,
iconClass: undefined,
})
const emit = defineEmits<{
change: [item: T, index: number]
}>()
</script>
vue
这里的关键设计点是:泛型 T 约束了数据项必须可能包含 icon 属性,但又不强制要求。items 数组的元素类型就是 T,这使得组件可以适配各种不同的数据结构。
defineModel 实现双向绑定
在 Vue 3.4+ 中,defineModel 是一个编译器宏,用于简化 v-model 的实现。它替代了传统 props + emit('update:xxx') 的繁琐模式。在本组件中,currentIndex 表示当前选中项的索引,通过 defineModel 实现双向绑定:
<script setup lang="ts" generic="T extends { icon?: string }">
// defineModel 创建一个双向绑定的响应式值
const currentIndex = defineModel<number>('value', { default: 0 })
</script>
vue
与传统写法的对比如下:
| 方案 | 实现方式 | 代码量 | 使用复杂度 |
|---|---|---|---|
| props + watch | 定义 current prop,监听变化同步本地 ref | 较多 | 需手动管理同步 |
| props + emit | 父组件监听 change 事件更新数据 | 中等 | 每次需处理事件 |
| defineModel | 一行代码完成双向绑定 | 最少 | 直接 v-model 绑定 |
完整的 Dropdown 通用组件
<!-- components/Dropdown.vue -->
<script setup lang="ts" generic="T extends { icon?: string }">
import type { IconifyIcon } from '@iconify/vue/dist/IconifyIcon.vue'
import { Icon } from '@iconify/vue'
interface DropdownMenuProps<T> {
items: T[]
iconProps?: Partial<InstanceType<typeof IconifyIcon>['$props']>
iconClass?: string
}
const props = withDefaults(defineProps<DropdownMenuProps<T>>(), {
iconProps: undefined,
iconClass: undefined,
})
const emit = defineEmits<{
change: [item: T, index: number]
}>()
// 使用 defineModel 管理当前选中索引
const currentIndex = defineModel<number>('value', { default: 0 })
const handleCommand = (command: { item: T; index: number }) => {
currentIndex.value = command.index
emit('change', command.item, command.index)
}
</script>
<template>
<el-dropdown @command="handleCommand">
<!-- 由用户通过 slot 自定义触发元素 -->
<slot name="header" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, index) in items"
:key="index"
:command="{ item, index }"
:class="{ active: index === currentIndex }"
>
<Icon
v-if="item.icon"
:icon="item.icon"
v-bind="iconProps"
:class="iconClass"
/>
<!-- 使用 slot 让用户自定义每一项的渲染内容 -->
<slot name="item" :item="item">
{{ item }}
</slot>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style scoped>
:deep(.el-dropdown-menu__item.active) {
color: var(--el-color-primary);
background-color: var(--el-dropdown-menuItem-hover-fill);
}
</style>
vue
在业务组件中使用
提炼完成后的 ChangeLocale 组件变得非常简洁。核心变化在于:不再需要手动管理 current 状态、不再需要手写 handleCommand 中的索引查找逻辑,所有这些都由通用 Dropdown 组件内部处理。
<!-- ChangeLocale.vue -->
<script setup lang="ts">
import Dropdown from './Dropdown.vue'
interface LocaleItem {
name: string
label: string
icon?: string
}
const locales = ref<LocaleItem[]>([
{ name: 'zh-CN', label: '中文', icon: 'ri:translate-2' },
{ name: 'en', label: 'English', icon: 'ri:english-input' }
])
const current = ref(0)
const handleChange = (item: LocaleItem, index: number) => {
// 执行语言切换逻辑
loadLanguage(item.name)
}
</script>
<template>
<Dropdown
v-model:value="current"
:items="locales"
:icon-props="{ width: '16' }"
icon-class="mr-1"
@change="handleChange"
>
<template #header>
<span class="dropdown-link">
<Icon :icon="locales[current]?.icon" />
</span>
</template>
<template #item="{ item }">
{{ (item as LocaleItem).label }}
</template>
</Dropdown>
</template>
vue
Slot 机制实现高度可定制
通用组件的一个核心设计原则是:把最大的控制权交给使用者。本组件通过两层 Slot 实现了这一点:
#headerSlot:让用户自定义下拉菜单的触发元素(如图标、按钮等)#itemSlot:让用户自定义每个下拉选项的渲染方式,通过 scoped slot 暴露当前项数据
这种设计模式下,Dropdown 组件只负责最核心的下拉交互逻辑和选中状态管理,而展示层完全由外部控制。
关键要点总结
- Vue 3.3 泛型组件 通过
<script setup generic="T">实现,让组件可以适配任意数据类型,同时保持完整的 TypeScript 类型推断 - defineModel 是 Vue 3.4+ 推荐的双向绑定方案,相比
props + emit('update:xxx')写法更加简洁,组件内部直接通过defineModel()获取响应式的绑定值 - Slot + 泛型 的组合是构建通用组件的最佳实践:泛型保证类型安全,Slot 保证灵活性
defineModel和props + watch两种方案在实际效果上差异不大,选择哪种取决于团队约定和个人习惯
↑