三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Element Plus el-select 内嵌 Checkbox 实现多选与全选功能

Element Plus el-select 内嵌 Checkbox 实现多选与全选功能

1. 从需求场景说起:为什么要在el-select里塞checkbox?

最近在重构一个后台管理系统的筛选模块时,遇到了一个挺典型的需求:用户需要从一个包含几十个选项的下拉框里进行多选,并且希望能有“全选”和“取消全选”的快捷操作。第一反应当然是直接用el-selectmultiple属性,它原生支持多选,看起来完美契合。

但实际用起来,问题就来了。原生的多选el-select在选项很多时,交互并不友好。用户选中几个选项后,下拉框的标签会挤成一团,尤其是当选项文本较长时,整个输入框的宽度会被撑得很难看。更重要的是,产品经理提了一个很具体的要求:需要在下拉面板的顶部固定显示“全选”和“取消全选”的复选框,并且要能清晰地反映部分选中的状态(indeterminate)。这个功能,原生的el-select是做不到的。

于是,一个混合组件的想法就诞生了:用el-select作为下拉容器,利用其展开/收起、定位、样式的基础能力,但完全自定义其下拉面板(dropdown)的内容,在里面放入el-checkbox-groupel-checkbox来实现多选逻辑,并手动添加上全选功能。这听起来像是把方形的积木塞进圆形的孔里,但经过一番折腾,发现这条路不仅走得通,而且灵活性极高。

2. 核心思路拆解:自定义下拉面板与状态同步

要实现el-select内嵌el-checkbox,核心在于理解el-select组件的结构。一个el-select主要由两部分组成:1. 触发下拉的输入框(或按钮);2. 下拉展示的列表面板。我们通常通过slot来定制下拉面板里的内容。

2.1 利用slot劫持下拉内容

el-select提供了一个名为dropdown的插槽(slot),允许我们完全替换默认的下拉选项列表。这是我们实现自定义布局的入口。

<el-select v-model="selectedValues" placeholder="请选择" :popper-append-to-body="false" <!-- 关键点1:控制定位 --> > <!-- 自定义下拉区域 --> <template #dropdown> <div class="custom-dropdown"> <!-- 这里将放置我们的 checkbox-group 和全选控件 --> </div> </template> </el-select>

这里有一个至关重要的属性:popper-append-to-body="false"。默认情况下,el-select的下拉面板(Popper)会被附加到document.body上,以实现更好的层叠和定位。但在我们完全自定义内容,并且内容高度可能动态变化时,这会导致一个经典问题:下拉面板滚动或内容变化时,面板定位会漂移或闪烁。将其设为false,下拉面板将作为el-select的子元素渲染,定位更稳定,但需要我们自己处理好下拉面板的样式(如z-index,position: absolute等)。

2.2 构建内部Checkbox逻辑

在下拉面板内部,我们构建一个独立的el-checkbox-group来管理多选状态。

<template #dropdown> <div class="custom-dropdown"> <!-- 全选 Checkbox --> <div class="select-all-row"> <el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange" > 全选 </el-checkbox> </div> <el-divider /> <!-- 选项列表 --> <el-checkbox-group v-model="selectedValues" @change="handleCheckedChange"> <div class="option-item" v-for="item in options" :key="item.value"> <el-checkbox :label="item.value">{{ item.label }}</el-checkbox> </div> </el-checkbox-group> </div> </template>

关键变量解释:

  • selectedValues: 这是一个数组,用于绑定el-checkbox-group的选中值。同时,它也作为el-selectv-model,这样组件内部的选择状态就能同步到外部。
  • checkAll: 一个布尔值,控制“全选”复选框的勾选状态。
  • isIndeterminate: 一个布尔值,控制“全选”复选框的半选(indeterminate)状态。当有选项被选中但未全选时,应为此状态。

2.3 状态同步:全选与部分选的逻辑

这是整个功能的大脑。我们需要让“全选”复选框的状态(选中、未选中、半选)与下方各个选项的选中状态实时同步。

1. 监听选项变化 (handleCheckedChange): 当用户勾选/取消勾选下方任意选项时,这个函数被触发。我们需要在这里计算是否达到了“全选”或“全不选”的状态,并更新checkAllisIndeterminate

handleCheckedChange(currentSelectedValues) { const optionValues = this.options.map(item => item.value); const allSelected = optionValues.length > 0 && currentSelectedValues.length === optionValues.length; const noneSelected = currentSelectedValues.length === 0; this.checkAll = allSelected; this.isIndeterminate = !noneSelected && !allSelected; }

2. 处理全选操作 (handleCheckAllChange): 当用户点击“全选”复选框时,我们需要根据其目标状态,更新整个selectedValues数组。

handleCheckAllChange(isChecked) { if (isChecked) { // 全选:将所有选项的value赋值给selectedValues this.selectedValues = this.options.map(item => item.value); } else { // 取消全选:清空数组 this.selectedValues = []; } // 操作完成后,半选状态应设为false this.isIndeterminate = false; }

注意:这里存在一个潜在的循环触发风险。handleCheckAllChange中修改了selectedValues,这会触发el-checkbox-group@change事件,从而又调用handleCheckedChange。但我们的逻辑是幂等的(无论执行多少次,结果都一样),所以不会造成死循环。这是一个很取巧但稳定的设计。

3. 样式与交互的深度打磨:让组件“以假乱真”

仅仅实现功能是不够的,自定义组件必须在外观和交互上接近原生el-select的多选模式,才能提供一致的用户体验。这里有几个需要精细处理的点。

3.1 模拟原生多选标签展示

原生的multiple模式会在输入框内显示选中的标签。我们自定义的组件也需要这个效果。我们可以利用el-select的另一个插槽prefix或直接在其后添加一个容器来模拟。

一种更简洁的思路是:我们不再依赖el-select显示值,而是将其作为一个“无值”的触发器。我们在el-select旁边(或内部)用一个div来渲染选中的标签。

<template> <div class="custom-select-wrapper"> <el-select v-model="selectedValues" placeholder="请选择" :popper-append-to-body="false" class="hidden-select" > <template #dropdown> ... </template> </el-select> <!-- 自定义标签展示区 --> <div class="selected-tags" v-if="selectedValues.length > 0"> <el-tag v-for="value in selectedValues" :key="value" closable @close="removeTag(value)" > {{ getLabelByValue(value) }} </el-tag> </div> </div> </template> <style scoped> .custom-select-wrapper { position: relative; display: inline-block; } .hidden-select { /* 将原生的el-select输入框视觉上隐藏,但保留其点击触发下拉的功能 */ opacity: 0; position: absolute; width: 100%; height: 100%; cursor: pointer; z-index: 1; } .selected-tags { min-height: 40px; /* 与el-select默认高度一致 */ border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; background-color: #fff; cursor: pointer; } .selected-tags:hover { border-color: #c0c4cc; } </style>

这样,用户点击整个标签区域,都能触发下拉框。el-select组件本身被透明覆盖在上方,负责处理点击和下拉逻辑。el-tagclosable属性提供了删除单个选项的能力,我们需要实现对应的removeTag方法,从selectedValues中移除对应值。

3.2 解决下拉面板定位与滚动问题

这是自定义下拉组件最容易踩坑的地方。当我们设置:popper-append-to-body="false"后,下拉面板的定位、宽度、滚动都需要自己管理。

1. 宽度与定位:我们需要让自定义的下拉面板宽度与触发它的el-select输入框宽度一致,并且在其下方对齐。

<template #dropdown> <div class="custom-dropdown" :style="{ width: dropdownWidth + 'px', top: '100%', left: '0' }" > <!-- 内容 --> </div> </template> <script> export default { mounted() { // 在组件挂载后,获取el-select输入框的宽度 const selectEl = this.$el.querySelector('.el-select'); if (selectEl) { this.dropdownWidth = selectEl.offsetWidth; } }, data() { return { dropdownWidth: 200 // 默认宽度 }; } } </script> <style scoped> .custom-dropdown { position: absolute; background: #fff; border: 1px solid #e4e7ed; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); z-index: 2001; /* 确保高于页面其他元素 */ max-height: 274px; /* 与el-select默认最大高度接近 */ overflow-y: auto; margin-top: 4px; box-sizing: border-box; } </style>

2. 滚动防抖与面板关闭:一个更棘手的问题是,如果页面可滚动,当用户滚动页面时,固定定位(或绝对定位但参考系变化)的下拉面板可能会错位。虽然我们设置了popper-append-to-body="false"缓解了此问题,但最佳实践是:在页面开始滚动时,自动关闭下拉面板。这符合大多数主流UI库的行为。

我们可以监听页面的滚动事件,在滚动发生时,手动控制el-select的展开状态。但直接操作el-select的内部状态visible比较困难。一个变通方法是,在滚动时,我们通过点击页面其他区域来“模糊”(blur)下拉框,触发其自动关闭。这需要一些额外的DOM事件监听。

mounted() { // ... 其他代码 this.closeDropdownOnScroll(); }, methods: { closeDropdownOnScroll() { const scrollHandler = () => { // 这里需要找到触发下拉的input元素,并触发blur事件 const selectInput = this.$el.querySelector('.el-select .el-input__inner'); if (selectInput && document.activeElement === selectInput) { // 失去焦点会触发el-select关闭下拉 selectInput.blur(); } }; // 使用防抖,避免频繁触发 this.debouncedScrollHandler = _.debounce(scrollHandler, 100); window.addEventListener('scroll', this.debouncedScrollHandler, true); // 使用捕获阶段 }, beforeDestroy() { if (this.debouncedScrollHandler) { window.removeEventListener('scroll', this.debouncedScrollHandler, true); } } }

提示:这里使用了 Lodash 的_.debounce进行防抖。在实际项目中,你需要引入 Lodash 或自己实现一个防抖函数。监听事件时使用第三个参数true(捕获阶段),是为了确保在页面滚动事件早期就能处理,提高响应速度。

4. 进阶功能与边界情况处理

一个健壮的组件必须考虑各种边界情况和进阶需求。以下是几个常见的增强点。

4.1 搜索过滤功能集成

如果选项非常多(比如超过50个),搜索过滤就变得必不可少。我们可以在自定义下拉面板的顶部,“全选”行的上方,加入一个el-input搜索框。

<template #dropdown> <div class="custom-dropdown" :style="{ width: dropdownWidth + 'px' }"> <!-- 搜索框 --> <div class="dropdown-search"> <el-input v-model="searchKeyword" placeholder="输入关键词过滤" size="small" clearable @input="handleSearch" /> </div> <!-- 全选行 --> <div class="select-all-row"> <el-checkbox ... >全选 ({{ filteredOptions.length }})</el-checkbox> </div> <el-divider /> <!-- 选项列表(基于过滤后的数据) --> <el-checkbox-group v-model="selectedValues" @change="handleCheckedChange"> <div class="option-item" v-for="item in filteredOptions" :key="item.value"> <el-checkbox :label="item.value">{{ item.label }}</el-checkbox> </div> </el-checkbox-group> <!-- 无结果提示 --> <div v-if="filteredOptions.length === 0" class="no-data">无匹配选项</div> </div> </template> <script> export default { data() { return { searchKeyword: '', options: [/* 所有选项 */], filteredOptions: [] // 过滤后的选项 }; }, created() { // 初始化时,过滤列表等于全部列表 this.filteredOptions = [...this.options]; }, methods: { handleSearch() { const keyword = this.searchKeyword.toLowerCase().trim(); if (!keyword) { this.filteredOptions = [...this.options]; } else { this.filteredOptions = this.options.filter(item => item.label.toLowerCase().includes(keyword) ); } // 过滤后,需要重新计算全选状态(基于当前显示的可选项) this.updateCheckAllState(); }, updateCheckAllState() { const visibleValues = this.filteredOptions.map(item => item.value); const selectedInVisible = this.selectedValues.filter(v => visibleValues.includes(v)); const allSelected = visibleValues.length > 0 && selectedInVisible.length === visibleValues.length; const noneSelected = selectedInVisible.length === 0; this.checkAll = allSelected; this.isIndeterminate = !noneSelected && !allSelected; }, handleCheckAllChange(isChecked) { const visibleValues = this.filteredOptions.map(item => item.value); if (isChecked) { // 全选当前可见项:将可见项的值加入选中列表,并去重 const newSelected = [...new Set([...this.selectedValues, ...visibleValues])]; this.selectedValues = newSelected; } else { // 取消全选当前可见项:从选中列表中移除所有可见项的值 this.selectedValues = this.selectedValues.filter(v => !visibleValues.includes(v)); } this.isIndeterminate = false; } } } </script>

注意点:引入搜索后,“全选”的逻辑变得复杂了。是全选“所有数据”还是全选“当前过滤后的数据”?上面的示例采用的是后者,这也是更符合用户直觉的做法:用户看到什么,操作就影响什么。同时,在计算全选状态时,也要基于过滤后的列表进行判断。

4.2 性能优化:虚拟滚动应对海量数据

当选项数量达到数百甚至上千时,一次性渲染所有el-checkbox会导致严重的性能问题,造成页面卡顿。此时需要引入虚拟滚动(Virtual Scrolling)。

我们可以借助现有的第三方库,如vue-virtual-scroller,或者基于el-select原生已支持的虚拟滚动(需要较高版本Element Plus)。但因为我们完全自定义了下拉内容,集成起来需要一些改造。

一个相对简单的思路是,我们不在模板中直接v-for所有filteredOptions,而是只渲染可视区域内的选项。这需要计算滚动位置和每个选项的高度。

<template #dropdown> <div class="custom-dropdown" @scroll.passive="handleScroll" :style="{ width: dropdownWidth + 'px', maxHeight: '274px' }" ref="dropdownRef"> <!-- 搜索和全选行(固定高度) --> <div class="dropdown-header" ref="headerRef"> <!-- 搜索框和全选checkbox --> </div> <el-divider /> <!-- 虚拟滚动区域 --> <div class="virtual-list" :style="{ height: totalHeight + 'px', position: 'relative' }"> <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }"> <el-checkbox-group v-model="selectedValues" @change="handleCheckedChange"> <div v-for="item in visibleOptions" :key="item.value" class="option-item" :style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }" > <el-checkbox :label="item.value">{{ item.label }}</el-checkbox> </div> </el-checkbox-group> </div> </div> </div> </template> <script> export default { data() { return { itemHeight: 40, // 每个选项的预估高度 visibleCount: 10, // 可视区域能容纳的选项数 startIndex: 0, // 起始渲染索引 offsetY: 0, // 内容偏移量 }; }, computed: { filteredOptions() { /* ... */ }, totalHeight() { return this.filteredOptions.length * this.itemHeight; }, endIndex() { return Math.min(this.startIndex + this.visibleCount, this.filteredOptions.length); }, visibleOptions() { return this.filteredOptions.slice(this.startIndex, this.endIndex); } }, methods: { handleScroll(event) { const scrollTop = event.target.scrollTop; const headerHeight = this.$refs.headerRef?.offsetHeight || 0; // 计算当前应该从第几个选项开始渲染 this.startIndex = Math.floor((scrollTop - headerHeight) / this.itemHeight); this.startIndex = Math.max(0, this.startIndex); // 计算内容区域的偏移量 this.offsetY = this.startIndex * this.itemHeight; } } } </script>

这是一个极简的虚拟滚动实现,真实项目中需要考虑更多细节,如动态高度的项、滚动节流、滚动条样式等。对于大多数后台管理系统,如果选项在200条以内,可以不使用虚拟滚动。超过这个数量级,强烈建议引入成熟的虚拟滚动库来保证体验。

4.3 与表单验证的集成

我们的自定义组件需要能够无缝接入 Element Plus 的表单验证体系。关键在于,el-selectv-model绑定的selectedValues数组,必须能够正确触发el-form-item的验证规则。

这通常没有问题,因为el-form-item通过注入的form-item上下文来收集其下所有组件的校验事件。只要我们的selectedValues变化时,能触发一个changeblur事件,验证就能正常工作。

为了更规范,我们可以在selectedValues变化的 watch 里,手动触发一个自定义事件。

watch: { selectedValues(newVal) { // 触发change事件,便于父组件或表单监听 this.$emit('change', newVal); // 如果组件在el-form-item内,可以尝试触发其内部的校验(非必须,el-form-item通常会监听) // 更推荐的做法是让父组件在表单提交或字段blur时统一校验。 } }

在父组件中使用时,就像使用一个普通的表单组件一样:

<el-form :model="form" :rules="rules"> <el-form-item label="多选项目" prop="selectedItems"> <custom-checkbox-select v-model="form.selectedItems" :options="optionList" /> </el-form-item> </el-form> <script> rules: { selectedItems: [ { required: true, message: '请至少选择一项', trigger: 'change' } ] } </script>

5. 封装与复用:构建一个高可用的独立组件

经过上述所有步骤,我们已经有了一个功能完整、体验良好的组件。最后一步是将其封装成一个独立的、可复用的 Vue 组件,方便在项目中多处调用。

5.1 完整的组件代码示例

下面是一个相对完整的单文件组件(SFC)示例,整合了核心功能、搜索和基础样式。

<!-- CustomCheckboxSelect.vue --> <template> <div class="custom-checkbox-select"> <!-- 隐藏的el-select,仅作为下拉触发器 --> <el-select ref="selectRef" v-model="internalValue" :placeholder="placeholder" :popper-append-to-body="false" :clearable="clearable" @clear="handleClear" class="hidden-select" > <template #dropdown> <div class="custom-dropdown" :style="{ width: dropdownWidth + 'px' }" ref="dropdownRef" > <!-- 搜索框 --> <div class="dropdown-search" v-if="filterable"> <el-input v-model="searchKeyword" :placeholder="filterPlaceholder" size="small" clearable @input="handleSearch" @click.stop /> </div> <!-- 全选行 --> <div class="select-all-row" v-if="showSelectAll"> <el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange" > 全选 ({{ filteredOptions.length }}) </el-checkbox> </div> <el-divider v-if="showSelectAll" /> <!-- 选项列表 --> <div class="options-container" @scroll.passive="handleScroll"> <el-checkbox-group v-model="internalValue" @change="handleCheckedChange"> <div class="option-item" v-for="item in visibleOptions" :key="item.value" :style="optionItemStyle" > <el-checkbox :label="item.value" :disabled="item.disabled"> <slot name="option" :option="item"> {{ item.label }} </slot> </el-checkbox> </div> </el-checkbox-group> <div v-if="filteredOptions.length === 0" class="no-data"> {{ noDataText }} </div> </div> </div> </template> </el-select> <!-- 自定义标签展示区 --> <div class="selected-tags-display" @click="focusSelect" :class="{ 'is-focused': isDropdownVisible }" > <span v-if="internalValue.length === 0" class="placeholder"> {{ placeholder }} </span> <template v-else> <el-tag v-for="value in selectedTags" :key="value" size="small" closable @close.stop="removeTag(value)" class="display-tag" > {{ getLabelByValue(value) }} </el-tag> <span v-if="internalValue.length > maxDisplayTags" class="tag-more"> +{{ internalValue.length - maxDisplayTags }}... </span> </template> <i :class="['el-icon-arrow-down', { 'is-reverse': isDropdownVisible }]"></i> </div> </div> </template> <script> export default { name: 'CustomCheckboxSelect', props: { modelValue: { type: Array, default: () => [] }, options: { type: Array, required: true, validator: (val) => val.every(item => item.value !== undefined && item.label !== undefined) }, placeholder: { type: String, default: '请选择' }, filterable: { type: Boolean, default: false }, filterPlaceholder: { type: String, default: '输入关键词过滤' }, showSelectAll: { type: Boolean, default: true }, clearable: { type: Boolean, default: false }, maxDisplayTags: { type: Number, default: 3 }, noDataText: { type: String, default: '无匹配数据' }, itemHeight: { type: Number, default: 40 } }, emits: ['update:modelValue', 'change', 'clear'], data() { return { internalValue: [...this.modelValue], searchKeyword: '', filteredOptions: [], checkAll: false, isIndeterminate: false, dropdownWidth: 200, isDropdownVisible: false, // 虚拟滚动相关(简化版,实际建议用库) startIndex: 0, visibleCount: 8, }; }, computed: { selectedTags() { // 控制显示标签的数量 return this.internalValue.slice(0, this.maxDisplayTags); }, visibleOptions() { if (!this.filterable) { return this.options; } const start = this.startIndex; const end = Math.min(start + this.visibleCount, this.filteredOptions.length); return this.filteredOptions.slice(start, end); }, optionItemStyle() { return { height: `${this.itemHeight}px`, lineHeight: `${this.itemHeight}px`, padding: '0 12px', boxSizing: 'border-box' }; } }, watch: { modelValue(newVal) { if (JSON.stringify(newVal) !== JSON.stringify(this.internalValue)) { this.internalValue = [...newVal]; this.updateCheckAllState(); } }, internalValue(newVal) { this.$emit('update:modelValue', newVal); this.$emit('change', newVal); this.updateCheckAllState(); }, options: { immediate: true, handler(newVal) { this.filteredOptions = [...newVal]; this.updateCheckAllState(); } } }, mounted() { this.initDropdownWidth(); this.setupDropdownListener(); }, beforeDestroy() { this.teardownDropdownListener(); }, methods: { initDropdownWidth() { const selectEl = this.$el.querySelector('.el-select'); if (selectEl) { this.dropdownWidth = selectEl.offsetWidth; } }, setupDropdownListener() { // 监听el-select的下拉状态 const select = this.$refs.selectRef; if (select && select.$refs.popper) { // 注意:这里访问了内部属性,在Element Plus版本升级时可能不稳定 // 更稳健的方式是通过自定义指令或监听visible-change事件(如果暴露) const observer = new MutationObserver(() => { const popper = select.$refs.popper; this.isDropdownVisible = popper && popper.style.display !== 'none'; }); observer.observe(document.body, { attributes: true, subtree: true }); this.observer = observer; } // 页面滚动时关闭下拉(简化版) this.scrollHandler = () => { if (this.isDropdownVisible) { this.$refs.selectRef?.blur(); } }; window.addEventListener('scroll', this.scrollHandler, true); }, teardownDropdownListener() { if (this.observer) { this.observer.disconnect(); } if (this.scrollHandler) { window.removeEventListener('scroll', this.scrollHandler, true); } }, handleSearch() { const keyword = this.searchKeyword.toLowerCase().trim(); if (!keyword) { this.filteredOptions = [...this.options]; } else { this.filteredOptions = this.options.filter(item => item.label.toLowerCase().includes(keyword) ); } this.startIndex = 0; // 搜索后重置滚动位置 this.updateCheckAllState(); }, updateCheckAllState() { const visibleValues = this.filteredOptions.map(item => item.value); const selectedInVisible = this.internalValue.filter(v => visibleValues.includes(v)); const allSelected = visibleValues.length > 0 && selectedInVisible.length === visibleValues.length; const noneSelected = selectedInVisible.length === 0; this.checkAll = allSelected; this.isIndeterminate = !noneSelected && !allSelected; }, handleCheckedChange() { // 状态更新已在watch和updateCheckAllState中处理 }, handleCheckAllChange(isChecked) { const visibleValues = this.filteredOptions.map(item => item.value); if (isChecked) { // 全选可见项 const newSelected = [...new Set([...this.internalValue, ...visibleValues])]; this.internalValue = newSelected; } else { // 取消全选可见项 this.internalValue = this.internalValue.filter(v => !visibleValues.includes(v)); } this.isIndeterminate = false; }, removeTag(value) { const index = this.internalValue.indexOf(value); if (index > -1) { this.internalValue.splice(index, 1); } }, getLabelByValue(value) { const option = this.options.find(item => item.value === value); return option ? option.label : value; }, focusSelect() { this.$refs.selectRef?.focus(); }, handleClear() { this.internalValue = []; this.$emit('clear'); }, handleScroll(event) { // 简易虚拟滚动计算(生产环境建议使用库) if (!this.filterable) return; const scrollTop = event.target.scrollTop; const headerHeight = this.$refs.dropdownRef?.querySelector('.dropdown-header')?.offsetHeight || 0; this.startIndex = Math.floor((scrollTop - headerHeight) / this.itemHeight); this.startIndex = Math.max(0, this.startIndex); } } }; </script> <style scoped> .custom-checkbox-select { position: relative; display: inline-block; width: 100%; } .hidden-select { opacity: 0; position: absolute; width: 100%; height: 100%; cursor: pointer; z-index: 2; } .hidden-select :deep(.el-input__inner) { cursor: pointer; } .selected-tags-display { min-height: 40px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 5px 30px 5px 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; background-color: #fff; cursor: pointer; box-sizing: border-box; width: 100%; position: relative; } .selected-tags-display:hover { border-color: #c0c4cc; } .selected-tags-display.is-focused { border-color: #409eff; } .selected-tags-display .placeholder { color: #c0c4cc; line-height: 30px; } .selected-tags-display .el-icon-arrow-down { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); transition: transform 0.3s; color: #c0c4cc; } .selected-tags-display .el-icon-arrow-down.is-reverse { transform: translateY(-50%) rotate(180deg); } .display-tag { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tag-more { font-size: 12px; color: #909399; } .custom-dropdown { background: #fff; border: 1px solid #e4e7ed; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); z-index: 2001; max-height: 274px; overflow: hidden; display: flex; flex-direction: column; box-sizing: border-box; } .dropdown-search { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; } .select-all-row { padding: 0 12px; height: 40px; display: flex; align-items: center; flex-shrink: 0; } .options-container { flex: 1; overflow-y: auto; } .option-item { padding: 0 12px; box-sizing: border-box; } .option-item:hover { background-color: #f5f7fa; } .no-data { text-align: center; padding: 20px; color: #909399; font-size: 14px; } </style>

5.2 使用示例与Props说明

封装好后,在父组件中使用就非常简洁了:

<template> <div> <custom-checkbox-select v-model="selectedList" :options="cityOptions" placeholder="请选择城市" filterable show-select-all :max-display-tags="2" @change="handleSelectionChange" /> <p>已选择: {{ selectedList }}</p> </div> </template> <script> import CustomCheckboxSelect from './components/CustomCheckboxSelect.vue'; export default { components: { CustomCheckboxSelect }, data() { return { selectedList: [], cityOptions: [ { value: 'shanghai', label: '上海' }, { value: 'beijing', label: '北京' }, { value: 'guangzhou', label: '广州' }, { value: 'shenzhen', label: '深圳' }, { value: 'hangzhou', label: '杭州' }, // ... 更多选项 ] }; }, methods: { handleSelectionChange(val) { console.log('选中值变化:', val); } } }; </script>

主要Props说明:

  • modelValue/v-model: 选中的值数组,双向绑定。
  • options: 选项数组,格式为[{ value, label, disabled? }]
  • filterable: 是否启用搜索过滤。
  • showSelectAll: 是否显示“全选”行。
  • maxDisplayTags: 输入框内最多显示的标签数量,超出显示“+N...”。
  • itemHeight: 每个选项行高,用于虚拟滚动计算(简化版)。

这个组件已经具备了生产环境使用的基础,你可以根据项目需求,继续扩展如远程搜索、自定义选项模板、分组显示等功能。它的优势在于,在保留了el-select基础交互(键盘导航、焦点管理)的同时,提供了远超原生多选模式的定制能力。

← 返回列表