{T}

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。这样组件既能支持默认渲染,也能通过插槽彻底把显示层交给调用方。

Vue SFC
<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 简洁。

ts
const emit = defineEmits<{ change: [item: T, index?: number] }>()

通用组件的输出应优先保留上下文信息、避免过早裁剪;真正只需要 string 的是外层业务组件。

四、currentIndex 受控状态:props + watch 或 defineModel

课程先实现了一版传统方案:父传 current、子维护 currentIndex、watch 监听 props.current、点击同步本地索引。后面又演示了更简洁的 defineModel<number>({ default: 0 }) 通过 v-model 管理激活项。两者都成立,差异主要在项目版本与团队习惯。

ts
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 交给调用方定义每个下拉项,内部只保留一份合理的默认渲染兜底。

Vue SFC
<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 兜底
要不要把下拉泛型化只有一个调用方且很简单先内联写业务组件,复用出现再抽

延伸阅读