ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/13 2:41:13 拓冰建站 浏览量
Element Plus el-select 内嵌 Checkbox 实现多选与全选功能 1. 从需求场景说起为什么要在el-select里塞checkbox最近在重构一个后台管理系统的筛选模块时遇到了一个挺典型的需求用户需要从一个包含几十个选项的下拉框里进行多选并且希望能有“全选”和“取消全选”的快捷操作。第一反应当然是直接用el-select的multiple属性它原生支持多选看起来完美契合。但实际用起来问题就来了。原生的多选el-select在选项很多时交互并不友好。用户选中几个选项后下拉框的标签会挤成一团尤其是当选项文本较长时整个输入框的宽度会被撑得很难看。更重要的是产品经理提了一个很具体的要求需要在下拉面板的顶部固定显示“全选”和“取消全选”的复选框并且要能清晰地反映部分选中的状态indeterminate。这个功能原生的el-select是做不到的。于是一个混合组件的想法就诞生了用el-select作为下拉容器利用其展开/收起、定位、样式的基础能力但完全自定义其下拉面板dropdown的内容在里面放入el-checkbox-group和el-checkbox来实现多选逻辑并手动添加上全选功能。这听起来像是把方形的积木塞进圆形的孔里但经过一番折腾发现这条路不仅走得通而且灵活性极高。2. 核心思路拆解自定义下拉面板与状态同步要实现el-select内嵌el-checkbox核心在于理解el-select组件的结构。一个el-select主要由两部分组成1. 触发下拉的输入框或按钮2. 下拉展示的列表面板。我们通常通过slot来定制下拉面板里的内容。2.1 利用slot劫持下拉内容el-select提供了一个名为dropdown的插槽slot允许我们完全替换默认的下拉选项列表。这是我们实现自定义布局的入口。el-select v-modelselectedValues placeholder请选择 :popper-append-to-bodyfalse !-- 关键点1控制定位 -- !-- 自定义下拉区域 -- template #dropdown div classcustom-dropdown !-- 这里将放置我们的 checkbox-group 和全选控件 -- /div /template /el-select这里有一个至关重要的属性:popper-append-to-bodyfalse。默认情况下el-select的下拉面板Popper会被附加到document.body上以实现更好的层叠和定位。但在我们完全自定义内容并且内容高度可能动态变化时这会导致一个经典问题下拉面板滚动或内容变化时面板定位会漂移或闪烁。将其设为false下拉面板将作为el-select的子元素渲染定位更稳定但需要我们自己处理好下拉面板的样式如z-index,position: absolute等。2.2 构建内部Checkbox逻辑在下拉面板内部我们构建一个独立的el-checkbox-group来管理多选状态。template #dropdown div classcustom-dropdown !-- 全选 Checkbox -- div classselect-all-row el-checkbox :indeterminateisIndeterminate v-modelcheckAll changehandleCheckAllChange 全选 /el-checkbox /div el-divider / !-- 选项列表 -- el-checkbox-group v-modelselectedValues changehandleCheckedChange div classoption-item v-foritem in options :keyitem.value el-checkbox :labelitem.value{{ item.label }}/el-checkbox /div /el-checkbox-group /div /template关键变量解释selectedValues 这是一个数组用于绑定el-checkbox-group的选中值。同时它也作为el-select的v-model这样组件内部的选择状态就能同步到外部。checkAll 一个布尔值控制“全选”复选框的勾选状态。isIndeterminate 一个布尔值控制“全选”复选框的半选indeterminate状态。当有选项被选中但未全选时应为此状态。2.3 状态同步全选与部分选的逻辑这是整个功能的大脑。我们需要让“全选”复选框的状态选中、未选中、半选与下方各个选项的选中状态实时同步。1. 监听选项变化 (handleCheckedChange) 当用户勾选/取消勾选下方任意选项时这个函数被触发。我们需要在这里计算是否达到了“全选”或“全不选”的状态并更新checkAll和isIndeterminate。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 classcustom-select-wrapper el-select v-modelselectedValues placeholder请选择 :popper-append-to-bodyfalse classhidden-select template #dropdown ... /template /el-select !-- 自定义标签展示区 -- div classselected-tags v-ifselectedValues.length 0 el-tag v-forvalue in selectedValues :keyvalue closable closeremoveTag(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-tag的closable属性提供了删除单个选项的能力我们需要实现对应的removeTag方法从selectedValues中移除对应值。3.2 解决下拉面板定位与滚动问题这是自定义下拉组件最容易踩坑的地方。当我们设置:popper-append-to-bodyfalse后下拉面板的定位、宽度、滚动都需要自己管理。1. 宽度与定位我们需要让自定义的下拉面板宽度与触发它的el-select输入框宽度一致并且在其下方对齐。template #dropdown div classcustom-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; } /style2. 滚动防抖与面板关闭一个更棘手的问题是如果页面可滚动当用户滚动页面时固定定位或绝对定位但参考系变化的下拉面板可能会错位。虽然我们设置了popper-append-to-bodyfalse缓解了此问题但最佳实践是在页面开始滚动时自动关闭下拉面板。这符合大多数主流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 classcustom-dropdown :style{ width: dropdownWidth px } !-- 搜索框 -- div classdropdown-search el-input v-modelsearchKeyword placeholder输入关键词过滤 sizesmall clearable inputhandleSearch / /div !-- 全选行 -- div classselect-all-row el-checkbox ... 全选 ({{ filteredOptions.length }})/el-checkbox /div el-divider / !-- 选项列表基于过滤后的数据 -- el-checkbox-group v-modelselectedValues changehandleCheckedChange div classoption-item v-foritem in filteredOptions :keyitem.value el-checkbox :labelitem.value{{ item.label }}/el-checkbox /div /el-checkbox-group !-- 无结果提示 -- div v-iffilteredOptions.length 0 classno-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 classcustom-dropdown scroll.passivehandleScroll :style{ width: dropdownWidth px, maxHeight: 274px } refdropdownRef !-- 搜索和全选行固定高度 -- div classdropdown-header refheaderRef !-- 搜索框和全选checkbox -- /div el-divider / !-- 虚拟滚动区域 -- div classvirtual-list :style{ height: totalHeight px, position: relative } div classvirtual-list-content :style{ transform: translateY(${offsetY}px) } el-checkbox-group v-modelselectedValues changehandleCheckedChange div v-foritem in visibleOptions :keyitem.value classoption-item :style{ height: itemHeight px, lineHeight: itemHeight px } el-checkbox :labelitem.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-select的v-model绑定的selectedValues数组必须能够正确触发el-form-item的验证规则。这通常没有问题因为el-form-item通过注入的form-item上下文来收集其下所有组件的校验事件。只要我们的selectedValues变化时能触发一个change或blur事件验证就能正常工作。为了更规范我们可以在selectedValues变化的 watch 里手动触发一个自定义事件。watch: { selectedValues(newVal) { // 触发change事件便于父组件或表单监听 this.$emit(change, newVal); // 如果组件在el-form-item内可以尝试触发其内部的校验非必须el-form-item通常会监听 // 更推荐的做法是让父组件在表单提交或字段blur时统一校验。 } }在父组件中使用时就像使用一个普通的表单组件一样el-form :modelform :rulesrules el-form-item label多选项目 propselectedItems custom-checkbox-select v-modelform.selectedItems :optionsoptionList / /el-form-item /el-form script rules: { selectedItems: [ { required: true, message: 请至少选择一项, trigger: change } ] } /script5. 封装与复用构建一个高可用的独立组件经过上述所有步骤我们已经有了一个功能完整、体验良好的组件。最后一步是将其封装成一个独立的、可复用的 Vue 组件方便在项目中多处调用。5.1 完整的组件代码示例下面是一个相对完整的单文件组件SFC示例整合了核心功能、搜索和基础样式。!-- CustomCheckboxSelect.vue -- template div classcustom-checkbox-select !-- 隐藏的el-select仅作为下拉触发器 -- el-select refselectRef v-modelinternalValue :placeholderplaceholder :popper-append-to-bodyfalse :clearableclearable clearhandleClear classhidden-select template #dropdown div classcustom-dropdown :style{ width: dropdownWidth px } refdropdownRef !-- 搜索框 -- div classdropdown-search v-iffilterable el-input v-modelsearchKeyword :placeholderfilterPlaceholder sizesmall clearable inputhandleSearch click.stop / /div !-- 全选行 -- div classselect-all-row v-ifshowSelectAll el-checkbox :indeterminateisIndeterminate v-modelcheckAll changehandleCheckAllChange 全选 ({{ filteredOptions.length }}) /el-checkbox /div el-divider v-ifshowSelectAll / !-- 选项列表 -- div classoptions-container scroll.passivehandleScroll el-checkbox-group v-modelinternalValue changehandleCheckedChange div classoption-item v-foritem in visibleOptions :keyitem.value :styleoptionItemStyle el-checkbox :labelitem.value :disableditem.disabled slot nameoption :optionitem {{ item.label }} /slot /el-checkbox /div /el-checkbox-group div v-iffilteredOptions.length 0 classno-data {{ noDataText }} /div /div /div /template /el-select !-- 自定义标签展示区 -- div classselected-tags-display clickfocusSelect :class{ is-focused: isDropdownVisible } span v-ifinternalValue.length 0 classplaceholder {{ placeholder }} /span template v-else el-tag v-forvalue in selectedTags :keyvalue sizesmall closable close.stopremoveTag(value) classdisplay-tag {{ getLabelByValue(value) }} /el-tag span v-ifinternalValue.length maxDisplayTags classtag-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; } /style5.2 使用示例与Props说明封装好后在父组件中使用就非常简洁了template div custom-checkbox-select v-modelselectedList :optionscityOptions placeholder请选择城市 filterable show-select-all :max-display-tags2 changehandleSelectionChange / 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基础交互键盘导航、焦点管理的同时提供了远超原生多选模式的定制能力。