在仿 ElementPlus 组件库的开发过程中,我们已经成功打造了多个实用组件,如 Input 组件、Switch 组件等,接下来,我们将深入探索 Select 组件的实现细节。
一、什么是 Select 组件
Select 组件,也被称为下拉选择框组件,是用户界面中广泛使用的交互元素之一。它的核心功能是为用户提供一个选项列表,允许用户从多个预设的选项中选择一个,从而实现数据的输入或功能的设置。
从基本构成来看,Select 组件通常由一个可点击的触发区域(一般显示当前选中的选项)和一个下拉菜单组成。下拉菜单中包含了所有可供选择的选项,当用户点击触发区域时,下拉菜单会展开显示,用户可以通过点击菜单项来选择相应的选项。
Select 组件的工作原理是基于事件监听和状态管理,当用户进行选择操作时,组件会捕获该事件,并更新内部的选中状态,同时可以将选中的值传递给其他相关组件或进行相应的业务逻辑处理。
二、实现 Select 组件
(一)组件目录
目录
components
├── Select
├── Select.vue
├── types.ts
├── style.css
(二)实现 Select 组件基本功能
SelectOption
接口 :定义了选择器选项的结构,包含label
(显示文本)、value
(选项值)和可选的disabled
(是否禁用)属性。SelectProps
接口 :定义了组件的属性,包括modelValue
(用于v-model
双向绑定)、options
(选项数组)、placeholder
(占位文本)和disabled
(是否禁用组件)。SelectEmits
接口 :定义了组件触发的事件,包括change
(选项改变时触发)、update:modelValue
(用于v-model
更新)和visible-change
(下拉菜单显示状态改变时触发)。- types.ts
typescript
export interface SelectOption {
label: string;
value: string;
disabled?: boolean;
}
export interface SelectProps {
// v-model
modelValue: string;
// 选项
options: SelectOption[];
// 基本表单属性
placeholder: string;
disabled: boolean;
}
export interface SelectEmits {
(e:'change', value: string) : void;
(e:'update:modelValue', value: string) : void;
(e: 'visible-change', value:boolean): void;
}
-
模板部分:
- 创建了一个带有
yl-select
类名的div
容器,根据disabled
属性添加is-disabled
类。 - 使用
Tooltip
组件包裹Input
组件,实现下拉菜单的显示。 Input
组件通过v-model
绑定innerValue
,并根据disabled
和placeholder
属性进行配置。- 在
Tooltip
的content
插槽中,使用ul
和li
元素渲染选项列表,每个选项根据disabled
属性添加is-disabled
类。
- 创建了一个带有
-
脚本部分:
- 引入所需的 Vue 功能和组件,定义组件名称为
YlSelect
。 - 使用
defineProps
和defineEmits
定义组件的属性和事件。 - 使用
ref
定义响应式变量,包括tooltipRef
、innerValue
和isDropdownShow
。 - 实现
controlDropdown
函数,用于控制下拉菜单的显示和隐藏,并触发visible-change
事件。 - 实现
toggleDropdown
函数,用于切换下拉菜单的显示状态,当组件禁用时不执行操作。
- 引入所需的 Vue 功能和组件,定义组件名称为
-
Select.vue
typescript
<template>
<div class="yl-select" :class="{ 'is-disabled': disabled }" @click="toggleDropdown">
<Tooltip placement="bottom-start" ref="tooltipRef" manual>
<Input v-model="innerValue" :disabled="disabled" :placeholder="placeholder" />
<template #content>
<ul class="yl-select__menu">
<template v-for="(item, index) in options" :key="index">
<li
class="yl-select__menu-item"
:class="{
'is-disabled': item.disabled,
}"
:id="`select-item-${item.value}`"
>
{{ item.label }}
</li>
</template>
</ul>
</template>
</Tooltip>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { SelectProps, SelectEmits } from './types'
import Tooltip from '../Tooltip/Tooltip.vue'
import type { TooltipInstance } from '../Tooltip/types'
import Input from '../Input/Input.vue'
defineOptions({
name: 'YlSelect',
})
const props = defineProps<SelectProps>()
const emits = defineEmits<SelectEmits>()
const tooltipRef = ref() as Ref<TooltipInstance>
const innerValue = ref('')
const isDropdownShow = ref(false)
const controlDropdown = (show: boolean) => {
if (show) {
tooltipRef.value.show()
} else {
tooltipRef.value.hide()
}
isDropdownShow.value = show
emits('visible-change', show)
}
const toggleDropdown = () => {
if (props.disabled) return
if (isDropdownShow.value) {
controlDropdown(false)
} else {
controlDropdown(true)
}
}
</script>
(三)实现 Select 组件选择功能
新增 SelectStates
接口,用于定义组件内部状态的结构,包含 inputValue
(输入框显示的值)和 selectedOption
(当前选中的选项,可为 null
)。
- types.ts
typescript
export interface SelectStates {
inputValue: string;
selectedOption: null | SelectOption;
}
-
模板部分
- 在
li
元素的class
绑定中,新增了is-selected
类,用于根据states.selectedOption
的值来判断当前选项是否被选中。当states.selectedOption
的value
与当前选项的value
相等时,应用该类。 - 为
li
元素添加了@click.stop
事件监听器,绑定到itemSelect
方法,当用户点击选项时会触发该方法,同时阻止事件冒泡。
- 在
-
脚本部分
- 引入了
reactive
函数,并从types.ts
中导入新定义的SelectStates
接口。 - 新增
findOption
函数,该函数接收一个value
作为参数,在props.options
数组中查找value
匹配的选项。如果找到则返回该选项,否则返回null
。 - 根据
props.modelValue
的值,使用findOption
函数查找初始选中的选项,并将其赋值给initialOption
。 - 使用
reactive
创建响应式的states
对象,根据initialOption
初始化inputValue
和selectedOption
。如果initialOption
存在,则inputValue
为其label
,selectedOption
为该选项;否则inputValue
为空字符串,selectedOption
为null
。 - 实现
itemSelect
方法,该方法接收一个SelectOption
类型的参数e
。当点击的选项未被禁用时,更新states
对象的inputValue
和selectedOption
,并触发change
和update:modelValue
事件,将选中选项的value
作为参数传递。最后调用controlDropdown
方法关闭下拉菜单。
- 引入了
-
Select.vue
typescript
<li class="yl-select__menu-item"
//...
:class="{
'is-disabled': item.disabled,
'is-selected': states.selectedOption?.value === item.value,
}"
@click.stop="itemSelect(item)"
>
import { ref, reactive } from 'vue'
import type { SelectProps, SelectEmits, SelectOption, SelectStates} from './types'
const findOption = (value: string) => {
const option = props.options.find((option) => option.value === value)
return option ? option : null
}
const initialOption = findOption(props.modelValue)
const states = reactive<SelectStates>({
inputValue: initialOption ? initialOption.label : '',
selectedOption: initialOption,
})
const itemSelect = (e: SelectOption) => {
if (e.disabled) return
states.inputValue = e.label
states.selectedOption = e
emits('change', e.value)
emits('update:modelValue', e.value)
controlDropdown(false)
}
(四)为 Select 组件添加部分样式和focus
-
Tooltip/types.ts
:新增TooltipEmits
接口,定义了visible-change
和click-outside
两个事件,用于处理下拉框显示状态变化和点击外部区域的情况。 -
Tooltip/types.ts
typescript
export interface TooltipEmits {
(e: 'visible-change', value: boolean): void
(e: 'click-outside', value: boolean): void
}
-
Tooltip/Tooltip.vue
:使用useClickOutside
函数监听点击外部区域事件。当触发方式为click
、下拉框处于打开状态且非手动控制时,调用closeFinal
方法关闭下拉框。同时,若下拉框处于打开状态,触发click-outside
事件。 -
Tooltip/Tooltip.vue
typescript
useClickOutside(popperContainerNode, () => {
if (props.trigger === 'click' && isOpen.value && !props.manual) {
closeFinal()
}
if (isOpen.value) {
emits('click-outside', true)
}
})
-
Input/types.ts
:新增InputInstance
接口,定义了ref
属性,其类型为HTMLInputElement
或HTMLTextAreaElement
,用于获取输入框的 DOM 引用。 -
Input/types.ts
typescript
export interface InputInstance {
ref: HTMLInputElement | HTMLTextAreaElement
}
-
模板部分:
- 在
Tooltip
组件上添加:popperOptions
绑定popperOptions
对象,用于配置弹出框的样式和位置。 - 监听
Tooltip
的click-outside
事件,调用controlDropdown(false)
方法关闭下拉框。 - 在
Input
组件上添加ref
绑定inputRef
,并设置readonly
属性,使输入框只读。 - 在
Input
的suffix
插槽中添加Icon
组件,根据isDropdownShow
状态添加is-active
类,实现下拉箭头的旋转效果。
- 在
-
脚本部分:
- 引入
Icon
组件和InputInstance
类型。 - 定义
inputRef
引用,类型为InputInstance
。 - 定义
popperOptions
对象,配置offset
和sameWidth
修饰符,使弹出框与输入框宽度相同且有一定偏移量。 - 在
itemSelect
方法中,选中选项后调用inputRef.value.ref.focus()
方法,将焦点聚焦到输入框上。
- 引入
-
Select.vue
typescript
<Tooltip
placement="bottom-start"
ref="tooltipRef"
:popperOptions="popperOptions"
@click-outside="controlDropdown(false)"
manual
>
<Input
v-model="states.inputValue"
:disabled="disabled"
:placeholder="placeholder"
ref="inputRef"
readonly
>
<template #suffix>
<Icon
icon="angle-down"
class="header-angle"
:class="{ 'is-active': isDropdownShow }"
></Icon>
</template>
</Input>
//...
</Tooltip>
import Icon from '../Icon/Icon.vue'
import type { InputInstance } from '../Input/types'
const inputRef = ref() as Ref<InputInstance>
const popperOptions: any = {
modifiers: [
{
name: 'offset',
options: {
offset: [0, 9],
},
},
{
name: 'sameWidth',
enabled: true,
fn: ({ state }: { state: any }) => {
state.styles.popper.width = `${state.rects.reference.width}px`
},
phase: 'beforeWrite',
requires: ['computeStyles'],
},
],
}
const itemSelect = (e: SelectOption) => {
//...
inputRef.value.ref.focus()
}
-
定义了
yl-select
组件的一系列 CSS 变量,用于统一管理组件的颜色、字体大小等样式。 -
对
yl-select
及其子元素的样式进行详细设置,包括输入框聚焦效果、下拉箭头旋转动画、选项列表样式、选项悬停和选中效果、禁用选项样式等。 -
style.css
css
.yl-select {
--yl-select-item-hover-bg-color: var(--yl-fill-color-light);
--yl-select-item-font-size: var(--yl-font-size-base);
--yl-select-item-font-color: var(--yl-text-color-regular);
--yl-select-item-selected-font-color: var(--yl-color-primary);
--yl-select-item-disabled-font-color: var(--yl-text-color-placeholder);
--yl-select-input-focus-border-color: var(--yl-color-primary);
}
.yl-select {
display: inline-block;
vertical-align: middle;
line-height: 32px;
.yl-tooltip .yl-tooltip__popper {
padding: 0;
}
.yl-input.is-focus .yl-input__wrapper {
box-shadow: 0 0 0 1px var(--yl-select-input-focus-border-color) inset !important;
}
.yl-input {
.header-angle {
transition: transform var(--yl-transition-duration);
&.is-active {
transform: rotate(180deg);
}
}
}
.yl-input__inner {
cursor: pointer;
}
.yl-select__menu {
list-style: none;
margin: 6px 0;
padding: 0;
box-sizing: border-box;
}
.yl-select__menu-item {
margin: 0;
font-size: var(--yl-select-item-font-size);
padding: 0 32px 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--yl-select-item-font-color);
height: 34px;
line-height: 34px;
box-sizing: border-box;
cursor: pointer;
&:hover {
background-color: var(--yl-select-item-hover-bg-color);
}
&.is-selected {
color: var(--yl-select-item-selected-font-color);
font-weight: 700;
}
&.is-disabled {
color: var(--yl-select-item-disabled-font-color);
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
}
- styles/index.css
typescript
@import '../components/Select/style.css';
(五)为 Select 组件添加可清空选项功能
SelectProps
接口 :新增可选属性clearable
,用于控制Select
组件是否可清空选项。SelectStates
接口 :新增mouseHover
属性,用于记录鼠标是否悬停在组件上。SelectEmits
接口 :新增clear
事件,用于在清空选项时触发。- types.ts
typescript
export interface SelectProps {
clearable?: boolean
}
export interface SelectStates {
mouseHover: boolean
}
export interface SelectEmits {
(e: 'clear'): void
}
-
模板部分:
- 在
div
元素上添加@mouseenter
和@mouseleave
事件监听器,分别在鼠标进入和离开组件时更新states.mouseHover
的值。 - 在
Input
组件的suffix
插槽中,使用v-if
指令根据showClearIcon
的值来决定显示清空图标(circle-mark
)还是下拉箭头图标(angle-down
)。 - 为清空图标添加
@mousedown.prevent
事件监听器,调用NOOP
函数阻止默认的鼠标按下行为,同时添加@click.stop
事件监听器,调用onClear
函数处理清空操作。
- 在
-
脚本部分:
- 引入
computed
函数,用于创建计算属性。 - 使用
reactive
创建响应式的states
对象,初始化mouseHover
为false
。 - 创建计算属性
showClearIcon
,根据props.clearable
、states.mouseHover
、states.selectedOption
和states.inputValue
的值来判断是否显示清空图标。 - 实现
onClear
函数,当点击清空图标时,将states.selectedOption
置为null
,states.inputValue
置为空字符串,同时触发clear
、change
和update:modelValue
事件,将空字符串作为参数传递。 - 定义
NOOP
空函数,用于阻止默认的鼠标按下行为。
- 引入
-
Select.vue
typescript
<template>
<div
class="yl-select"
//...
@mouseenter="states.mouseHover = true"
@mouseleave="states.mouseHover = false"
>
<template #suffix>
<Icon
icon="circle-mark"
v-if="showClearIcon"
class="yl-input__clear"
@mousedown.prevent="NOOP"
@click.stop="onClear"
></Icon>
<Icon
v-else
icon="angle-down"
class="header-angle"
:class="{ 'is-active': isDropdownShow }"
></Icon>
</template>
//...
</template>
import { ref, reactive, computed } from 'vue'
const states = reactive<SelectStates>({
mouseHover: false,
})
const showClearIcon = computed(() => {
// hover
// props.clearable 为 true
// 必须要选择过选项
// input值不为空
return (
props.clearable && states.mouseHover && states.selectedOption && states.inputValue.trim() != ''
)
})
const onClear = () => {
states.selectedOption = null
states.inputValue = ''
emits('clear')
emits('change', '')
emits('update:modelValue', '')
}
const NOOP = () => {}
(六)为 Select 组件添加自定义模板功能
- types.ts
typescript
import type { VNode } from "vue"
export interface SelectProps {
//...
renderLabel?: RenderLabelFunc
}
export type RenderLabelFunc = (option: SelectOption) => VNode
- Select.vue
typescript
<template #content>
<li
class="yl-select__menu-item"
//...
>
<RenderVnode :vNode="renderLabel ? renderLabel(item) : item.label"> </RenderVnode>
//...
</template>
import RenderVnode from '../Common/RenderVnode'
(七)为 Select 组件添加下拉菜单筛选功能
SelectProps
接口 :新增了可选属性renderLabel
,其类型为RenderLabelFunc
。这个属性允许使用者传入一个自定义的渲染函数,用于定制选项标签的渲染方式。RenderLabelFunc
类型 :定义为一个函数类型,接收一个SelectOption
类型的参数option
,并返回一个 Vue 的虚拟节点VNode
。该函数用于将SelectOption
渲染为特定的虚拟节点。- types.ts
typescript
export interface SelectProps {
//...
filterable?: boolean
filterMethod: CustomerFilterFunc
}
export type CustomerFilterFunc = (value: string) => SelectOption[]
-
模板部分:
- 在
li
元素内部,使用了RenderVnode
组件来渲染选项内容。 - 通过三元表达式判断
renderLabel
是否存在,如果存在则调用renderLabel
函数并传入当前选项item
得到虚拟节点,将其作为RenderVnode
组件的vNode
属性;如果不存在,则直接使用item.label
作为渲染内容。
- 在
-
脚本部分 :引入了
RenderVnode
组件,该组件用于将传入的虚拟节点进行渲染。 -
Select.vue
typescript
<Input
//...
:placeholder="filteredPlaceholder"
:readonly="!filterable || isDropdownShow"
@input="onFilter"
>
<ul class="yl-select__menu">
<template v-for="(item, index) in filteredOptions" :key="index">
//...
</ul>
import { ref, reactive, computed, watch } from 'vue'
import { isFunction } from 'lodash-es'
const filteredOptions = ref(props.options)
watch(
() => props.options,
(newOptions) => {
filteredOptions.value = newOptions
},
)
const generateFilterOptions = (searchValue: string) => {
if (!props.filterable) return
if (props.filterMethod && isFunction(props.filterMethod)) {
filteredOptions.value = props.filterMethod(searchValue)
} else {
filteredOptions.value = props.options.filter((option) => option.label.includes(searchValue))
}
}
const onFilter = () => {
generateFilterOptions(states.inputValue)
}
const filteredPlaceholder = computed(() => {
return props.filterable && states.selectedOption && isDropdownShow.value
? states.selectedOption.label
: props.placeholder
})
if (show) {
// filter 模式
// 之前选择过对应的值
if (props.filterable && states.selectedOption) {
states.inputValue = ''
}
// 进行一次默认选项的生成
if (props.filterable) {
generateFilterOptions(states.inputValue)
}
tooltipRef.value.show()
} else {
tooltipRef.value.hide()
// blur 的时候 将之前的值回灌到 input 中
if (props.filterable) {
states.inputValue = states.selectedOption ? states.selectedOption.label : ''
}
}
(八)为 Select 组件添加远程搜索功能
- types.ts
typescript
export interface SelectProps {
//...
options?: SelectOption[]
remote?: boolean
remoteMethod: CustomerFilterRemoteFunc
}
export interface SelectStates {
//...
loading: boolean
}
export type CustomerFilterRemoteFunc = (value: string) => Promise<SelectOption[]>
- Select.vue
typescript
<Input
//...
@input="debounceOnFilter"
>
<template #content>
<div class="yl-select__loading" v-if="states.loading">
<Icon icon="spinner" spin></Icon>
</div>
<div class="yl-select__nodata" v-else-if="filterable && filteredOptions.length === 0">
no matching data
</div>
<ul class="yl-select__menu" v-else>
//...
</ul>
import { isFunction, debounce } from 'lodash-es'
const props = withDefaults(defineProps<SelectProps>(), {
options: () => [],
})
const timeout = computed(() => (props.remote ? 300 : 0))
const states = reactive<SelectStates>({
//...
loading: false,
})
const debounceOnFilter = debounce(() => {
onFilter()
}, timeout.value)
const generateFilterOptions = async (searchValue: string) => {
if (!props.filterable) return
if (props.filterMethod && isFunction(props.filterMethod)) {
filteredOptions.value = props.filterMethod(searchValue)
} else if (props.remote && props.remoteMethod && isFunction(props.remoteMethod)) {
states.loading = true
try {
filteredOptions.value = await props.remoteMethod(searchValue)
} catch (e) {
console.error(e)
filteredOptions.value = []
} finally {
states.loading = false
}
} else {
filteredOptions.value = props.options.filter((option) => option.label.includes(searchValue))
}
}
- style.css
css
.yl-select__nodata, .yl-select__loading {
padding: 10px 0;
margin: 0;
text-align: center;
color: var(--yl-text-color-secondary);
font-size: var(--yl-select-font-size);
}
(九)为 Select 组件添加键盘控制功能
-
SelectProps
接口:- 将
options
属性变为可选,因为远程搜索时选项可能动态获取。 - 新增
remote
属性,类型为布尔值,用于标识是否开启远程搜索功能。 - 新增
remoteMethod
属性,类型为CustomerFilterRemoteFunc
,这是一个函数,接收搜索值作为参数,返回一个Promise
,该Promise
解析为SelectOption
数组,用于从远程获取匹配的选项。
- 将
-
SelectStates
接口 :新增loading
属性,类型为布尔值,用于表示远程搜索时的加载状态。 -
CustomerFilterRemoteFunc
类型 :定义了远程搜索方法的函数签名,接收搜索值并返回一个解析为SelectOption
数组的Promise
。 -
types.ts
typescript
export interface SelectStates {
//...
highlightIndex: number
}
-
模板部分:
- 在
Input
组件上添加@input
事件监听器,绑定到debounceOnFilter
方法,实现输入时触发搜索。 - 在
template #content
部分添加了加载提示和无匹配数据提示:- 当
states.loading
为true
时,显示加载提示,包含一个旋转的图标。 - 当
filterable
为true
且filteredOptions
为空时,显示无匹配数据提示。
- 当
- 在
-
脚本部分:
- 使用
withDefaults
为props
中的options
提供默认值为空数组。 - 创建计算属性
timeout
,根据props.remote
的值决定防抖时间,若开启远程搜索则为 300 毫秒,否则为 0。 - 在
states
对象中新增loading
属性并初始化为false
。 - 使用
lodash-es
的debounce
函数创建debounceOnFilter
方法,对onFilter
方法进行防抖处理,防抖时间由timeout
决定。 - 实现
generateFilterOptions
异步方法,根据不同情况生成过滤后的选项:- 若
props.filterable
为false
,不进行过滤。 - 若
props.filterMethod
存在且为函数,使用该方法进行过滤。 - 若开启远程搜索(
props.remote
为true
)且props.remoteMethod
存在且为函数,设置states.loading
为true
,调用props.remoteMethod
进行远程搜索,搜索完成后更新filteredOptions
,并在finally
块中将states.loading
设为false
。 - 若以上条件都不满足,使用本地选项过滤,过滤包含搜索值的选项。
- 若
- 使用
-
Select.vue
typescript
<Input
//...
@keydown="handleKeyDown"
>
<li
class="yl-select__menu-item"
:class="{
'is-disabled': item.disabled,
'is-selected': states.selectedOption?.value === item.value,
'is-highlighted': states.highlightIndex === index,
}"
//...
</li>
const states = reactive<SelectStates>({
//...
highlightIndex: -1,
})
const generateFilterOptions = async (searchValue: string) => {
//...
states.highlightIndex = -1
}
const controlDropdown = (show: boolean) => {
//...
if (props.filterable) {
states.inputValue = states.selectedOption ? states.selectedOption.label : ''
}
states.highlightIndex = -1
}
//...
}
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
toggleDropdown()
break
case 'Escape':
if (isDropdownShow.value) {
controlDropdown(false)
}
break
case 'ArrowUp':
e.preventDefault()
//states.highlightIndex = -1
if (filteredOptions.value.length > 0) {
if (states.highlightIndex === -1 || states.highlightIndex === 0) {
states.highlightIndex = filteredOptions.value.length - 1
} else {
// move up
states.highlightIndex--
}
}
break
case 'ArrowDown':
e.preventDefault()
//states.highlightIndex = -1
if (filteredOptions.value.length > 0) {
if (
states.highlightIndex === -1 ||
states.highlightIndex === filteredOptions.value.length - 1
) {
states.highlightIndex = 0
// move up
states.highlightIndex++
}
}
break
default:
break
}
}
- style.css
typescript
&.is-highlighted {
background-color: var(--yl-select-item-hover-bg-color);
}