Dropdown 通用封装与 LocaleSelect 泛型改造
概述
这一节把服务于 LocaleSelect 的下拉结构进一步抽象成通用 Dropdown 容器:接收任意类型列表、渲染菜单项、支持激活态高亮、对外回调 item + index、并通过 trigger / item 插槽把显示层交给调用方。核心是从「写死 LocaleItem」升级到泛型建模,从单纯事件同步升级到受控状态设计,并明确 Vue 3.3 泛型 SFC 与 Vue 3.4 defineModel 的版本边界。
注意本节不涉及路由跳转、i18n 实例创建或语言包加载,这些都属于 LocaleSelect 业务层的职责;Dropdown 只负责把「点击某个菜单项」这一交互原语稳定地交付出去。抽象得越早,后续接入主题切换、通知中心、用户菜单等同类结构时的复用成本就越低,否则每个业务都要重复写一遍展开收起、激活高亮与点击回调。
学习目标
- 从业务组件里抽离「通用 Dropdown 容器」,保留结构与交互、下沉业务包装
- 用泛型 T 描述菜单项的「可选能力」,而不是退化成 any[]
- 让通用 Dropdown 回调 item + index,业务组件再决定是否转成 string
- 用 props + watch 或 defineModel 管理受控激活索引 currentIndex
- 开放 trigger 与 item 两个插槽,并区分 Vue 3.3 泛型与 3.4 defineModel
- 明确 Dropdown 内部只交付交互原语,不预判业务值的真正语义
- 在泛型约束里为「未知字段」保留扩展空间,避免业务被迫套模板
- 判断哪些场景值得做通用封装、哪些场景直接写业务组件更省事
一、抽离的是通用 Dropdown 容器
这一节不是继续强化语言切换本身,而是把 LocaleSelect 中可复用的 Dropdown 结构单独提炼。被抽离的公共能力包括:接收任意类型列表、渲染下拉菜单项、支持当前激活项高亮、对外回调当前点击的 item 与 index、允许调用方自定义 trigger 和 item 展示内容。拆完后 Dropdown 负责结构和交互,LocaleSelect 只负责语言业务——如果一个组件未来会服务多种场景,就不要继续沿用强业务命名。
二、泛型 Dropdown 用 T 描述可选能力
「支持任意类型列表」不等于把类型写成 any[]。更合理的做法是:用 Vue 3.3 的 SFC Generics 为列表项建立类型参数 T,再为 T 约束一些可选能力,例如 icon / iconClass / iconProps / text。这样组件既能支持默认渲染,也能通过插槽彻底把显示层交给调用方。
<script
setup
lang="ts"
generic="T extends {
icon?: string
iconClass?: string
iconProps?: Record<string, unknown>
text?: string
}"
>T 不是固定的 LocaleItem,而是一种「当前菜单项至少可能包含哪些能力」的描述。如果展示字段不确定,不要强推 item.text,应提供 item slot 作为兜底扩展方式。
三、通用组件回调 item + index,业务层再裁剪
这里有两个事件层级不能混在一起:通用 Dropdown 内部最有信息量的回调是当前点击的 item 和 index;LocaleSelect 这一层真正对外需要的业务值是语言标识(如 locale.name)。分工应是 Dropdown emit("change", item, index),LocaleSelect 拿到 item 后再 emit("change", item.name)。通用组件不需要预设所有业务都只关心 string,业务组件又能保持对外 API 简洁。
const emit = defineEmits<{ change: [item: T, index?: number] }>()通用组件的输出应优先保留上下文信息、避免过早裁剪;真正只需要 string 的是外层业务组件。
四、currentIndex 受控状态:props + watch 或 defineModel
课程先实现了一版传统方案:父传 current、子维护 currentIndex、watch 监听 props.current、点击同步本地索引。后面又演示了更简洁的 defineModel<number>({ default: 0 }) 通过 v-model 管理激活项。两者都成立,差异主要在项目版本与团队习惯。
const currentIndex = ref(props.current ?? 0)
watch(() => props.current, (value) => {
if (typeof value === "number") currentIndex.value = value
})
// 或
const currentIndex = defineModel<number>({ default: 0 })注意 currentIndex 是视图激活态,不等同于业务值本身;defineModel 是 Vue 3.4+ 官方能力,版本较低就继续用 props + watch。
五、v-model 管激活,change 管回调,二者不互斥
当 currentIndex 交给 v-model 管理后,看起来似乎不再需要回调 index,但 index 仍有价值:帮助调用方快速同步本地高亮、做日志或埋点、处理需要索引的业务逻辑。平衡的设计是 v-model 负责「当前激活谁」、change(item, index) 负责「用户刚刚点了谁」。组件设计时优先保证 API 语义清楚,而不是一味追求回调最少。
六、至少开放 trigger 与 item 两个插槽
抽象重点不只是 trigger 区不写死,item 区也不要写死:有的业务项想显示 text,有的想显示 value,有的还想显示 icon、徽标或更复杂结构。通用组件最好策略是 trigger slot 交给调用方定义顶部触发区、item slot 交给调用方定义每个下拉项,内部只保留一份合理的默认渲染兜底。
<slot name="trigger" />
<slot name="item" :item="item" :index="index">{{ item.text }}</slot>trigger 是入口显示层、item 是列表显示层,两者都应能自定义;只开放 trigger 不开放 item,通用性仍然有限;插槽上移到外层后,边距和排版也要一起迁移到外层 trigger 节点上。
七、职责边界:结构留内部,值转换放外层
课程最后效果之所以更清爽,是因为职责切分更干净:Dropdown 只关心列表渲染、激活态控制、change 回调、插槽扩展;LocaleSelect 只关心 locales 数据、当前语言切换、把 LocaleItem 转成外部真正需要的 string。这就是典型的「基础容器 + 业务包装」拆分模式——基础组件不要替业务做过多假设,业务包装层不要重新实现 Dropdown 的结构和样式。「组件更清爽」的本质是职责更单一,不是代码更少。
八、区分 Vue 3.3 泛型与 3.4 defineModel
课程里提到「Vue 3.3 新加入的 generic 泛型组件特性」对应的是 <script setup generic="T">,但后面使用的 defineModel 并不是同一个版本点。正确区分:Vue 3.3+ 支持 SFC Generics,Vue 3.4+ 官方支持 defineModel。正式笔记里要区分版本边界,避免把不同版本能力混成一个知识点;如果团队版本不统一,优先用兼容方案。
九、默认渲染兜底不要喧宾夺主
开放 item slot 后,组件内部仍要保留一份默认渲染 {{ item.text }},但这份兜底必须克制:它只用于「没有任何自定义显示需求」的简单场景,不应在内部强加图标、徽标或固定排版。如果业务项只有 value 没有 text,默认渲染会显示空白,这时应当让调用方通过 item slot 接管,而不是在通用层去猜测「空白时显示什么」。兜底存在的意义是降低零配置接入成本,而不是替所有业务拍板显示策略。
十、泛型约束为未知字段留扩展空间
约束 T extends { icon?; text? } 并没有禁止业务携带额外字段,但 TS 在 item slot 里不会自动把未知字段暴露给调用方,直接访问 item.anyField 会报类型错误。两种出路:一是把可能用到的业务字段显式写进约束,二是放宽成 T extends { text?: string } & Record<string, unknown> 让任意字段可访问但失去精确提示。前者类型更稳、后者更灵活,取舍取决于这个 Dropdown 到底服务多少种差异很大的调用方;服务面越广,越倾向显式声明而非兜底 any。
十一、什么时候值得做通用封装
并非每个下拉都要抽成泛型容器。如果某个结构只被一个业务用到、且交互简单,直接内联写业务组件反而更省心;通用抽取只在三种信号同时出现时划算:两个及以上调用方共享同一套交互、交互本身不简单(键盘导航、虚拟滚动、异步加载)、团队愿意长期维护这份抽象。提前抽象会增加一层间接成本——调用的地方看不出真实渲染,调试时要跳两层;所以判断标准不是「这段代码能不能抽」,而是「抽完之后半年内会不会真的被复用」。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 外层语言切换报类型错误 | 通用 Dropdown 把整个对象当最终业务值 | 让 Dropdown 回调 item+index,LocaleSelect 再转 string |
| 默认激活项不更新 | 只改 command 没同步 currentIndex | 回调里同步索引或直接改 v-model |
| 泛型化了仍只能显示 text | 显示层写死在组件内部 | 开放 item slot 让调用方自定义 |
| 迁移插槽后 trigger 边距不对 | 边距类还停在旧节点 | 把边距类迁到新 trigger 外层节点 |
| 想用 defineModel 却报错 | 项目仍在 Vue 3.3 | 改回 props + watch 或升级 3.4+ |
| 抽象后职责混乱 | 通用与业务组件都在做值转换 | 通用管结构交互,业务管值映射与输出 |
| 插槽项显示空白 | 业务项只有 value 没有 text | 由 item slot 提供显示或补上 text 字段 |
| 想取 item 的额外字段报错 | 泛型约束未声明该字段 | 显式扩展 T 或用 Record 兜底 |
| 要不要把下拉泛型化 | 只有一个调用方且很简单 | 先内联写业务组件,复用出现再抽 |