POST · 前端 · 片段
el-select 组件优化方案(虚拟列表形式)
2026/11/13 · by ZHANG XING YUN · 阅读 1 次 · 1432 字 · 预计阅读 4 分钟
1. 背景介绍
在使用 el-select 组件处理大量数据时,可能会遇到性能问题,如页面响应慢、滚动卡顿等。为了解决这些问题,可以通过引入虚拟列表技术对 el-select 进行优化。
2. 虚拟列表原理
虚拟列表是一种前端优化技术,主要通过只渲染当前视口内的元素来减少 DOM 操作,从而提高页面性能。当用户滚动时,动态加载和卸载元素,确保只有可见部分的数据被渲染到 DOM 中。
3. 优化方案
封装yxVirtualListSelect组件,基于el-select组件重写el-select组件筛选事件,子组件不在是el-option而是基于el-option进行二次封装的组件yx-option,示例:
<template>
<el-select
ref="select"
:value="value"
filterable
:filter-method="handleFilter"
v-on="$listeners"
v-bind="$attrs"
@visible-change="changeVisible"
:allow-create="allowCreate"
>
<yx-option
ref="virtualRef"
:allowCreate="allowCreate"
:virtualData="virtualData"
:select-value="value"
:label="propsOptions.label"
:value="propsOptions.value"
/>
</el-select>
</template>
<script>
import YxOption from './YxOption.vue'
export default {
name: 'yxVirtualListSelect',
components: { YxOption },
props: {
options: {
type: Array,
default: () => [],
},
value: {
type: [String, Number, Array],
default: '',
},
filterMethod: {
type: Function,
default: null,
},
propsOptions: {
type: Object,
default: () => {
return {
label: 'label',
value: 'value',
}
},
},
allowCreate: {
type: Boolean,
default: false,
},
},
model: {
prop: 'value',
event: 'change',
},
watch: {
options: {
handler(newVal) {
this.virtualData = newVal
},
immediate: true,
deep: true,
},
},
data() {
return {
virtualData: [],
}
},
methods: {
handleFilter(val) {
if (this.filterMethod) return (this.virtualData = this.filterMethod(val))
this.virtualData = this.options.filter((item) => {
return (
item[this.propsOptions.label] &&
item[this.propsOptions.label].includes(val)
)
})
},
changeVisible(cb) {
this.$nextTick(() => {
if (cb) {
this.$refs['virtualRef'].resetVirtual() // 解决打开白屏问题,必须使用 $nextTick 延时处理
} else {
this.options.length !== this.virtualData.length &&
setTimeout(() => this.handleFilter(''), 100) // 解决筛选后关闭 数据丢失问题 setTimeout解决重置数据弹窗闪一下问题
}
'multiple' in this.$attrs &&
this.$attrs.multiple !== false &&
(this.$refs['virtualRef'].isShow = cb) // 解决多选回显问题
})
},
},
}
</script>
<style></style>
将 el-select 的下拉选项列表替换为虚拟列表组件yx-option。示例:
<template>
<div :ref="randomId" class="option-wrap" :id="randomId">
<div class="virtual-dom">
<!-- 使用虚拟列表渲染el-option组件 -->
<el-option
v-for="(item, index) in virtualOptions"
:label="item[label]"
:value="item[value]"
:key="`virtual_options_${item[value]}_${index}`"
></el-option>
</div>
</div>
</template>
<script>
export default {
name: 'YxOption',
props: {
virtualData: {
type: Array,
default: () => [],
},
selectValue: {
type: [String, Number, Array],
default: '',
},
label: {
type: String,
},
value: {
type: String,
},
allowCreate: {
type: Boolean,
default: false,
},
},
watch: {
selectValue: {
handler(val) {
if (Array.isArray(val) && !this.isShow) {
this.showMultipleLabel() // 用于回显 多选
} else if (!Array.isArray(val)) {
this.resetVirtual() // 用于回显 单选
}
},
deep: true,
immediate: true,
},
// 监听virtualData的变化,更新虚拟列表总高度 和 数据
virtualData() {
this.resetVirtual()
this.initVirtual('init')
},
},
data() {
return {
randomId: '', // 随机生成的ID值
virtualOptions: [], // 虚拟列表的选项数据
leafNumber: 1, // 选项的子节点数量
optionHeight: 34, // 选项的高度
scrollFunc: null,
isShow: false,
root: null,
}
},
mounted() {
this.initId()
this.$nextTick(() => this.initVirtual())
},
beforeDestroy() {
const $wrap = this.$refs[this.randomId]
if (!$wrap) return
const $scroll = this.getParents($wrap, 'el-select-dropdown__wrap')
if ($scroll) $scroll.removeEventListener('scroll', this.scrollFunc) // 清除监听
},
methods: {
// 生成一个随机的id值
initId() {
this.randomId =
'virtual_' + parseInt(Math.random() * 10 * 1024 * 1024 + '')
},
/**
* 工具 - 根据class名称递归查询父节点
* @param {HTMLElement} element 当前节点element
* @param {string} className 需要查询的class值
*/
getParents(element, className) {
var returnParentElement = null
function getpNode(element, className) {
if (!element.parentNode) return null
// 创建父级节点的类数组
let pClassList = element.parentNode.getAttribute('class').split(' ')
let pNode = element.parentNode
if (!pClassList || !pClassList.length) {
// 如果未找到类名数组,表示父类无类名,则再次递归
getpNode(pNode, className)
} else if (pClassList && !pClassList.includes(className)) {
// 如果父类的类名中没有预期类名,则再次递归
getpNode(pNode, className)
} else if (pClassList && pClassList.includes(className)) {
returnParentElement = pNode
}
}
getpNode(element, className)
return returnParentElement
},
// 处理多选回显
showMultipleLabel() {
const vIndex = this.virtualData.reduce((arr, item, index) => {
if (this.selectValue.includes(item[this.value])) {
arr.push(index)
}
return arr
}, [])
this.virtualOptions = this.virtualData.filter((_, index) =>
vIndex.includes(index),
)
},
/**
* 重置虚拟列表,使用场景有两个
* 1- select选中时,保证选中数据正常为label值
* 2- 下拉框展开保证下拉菜单回显正常
*/
resetVirtual() {
const $wrap = document.getElementById(this.randomId)
if (!$wrap) return
const $virtualDom = $wrap.querySelector('.virtual-dom')
const $scroll = this.getParents($wrap, 'el-select-dropdown__wrap')
const _scrollHeight = $scroll.offsetHeight
if (this.allowCreate) this.root = $wrap.parentNode
$scroll.style.maxHeight = '290px'
let vIndex = 0
const isArray = Array.isArray(this.selectValue)
const isSelectValue = isArray
? !!this.selectValue.length
: String(this.selectValue) !== ''
if (isSelectValue) {
// 查找选中值在虚拟数据中的索引
vIndex = this.virtualData.findIndex(
(item) =>
item[this.value] ===
(isArray ? this.selectValue[0] : this.selectValue),
)
}
// 计算可视区域内需要显示的选项数量
const showNumber =
parseInt(_scrollHeight / this.optionHeight) + this.leafNumber
vIndex = vIndex >= 8 ? vIndex : 0 // 解决有值时初始化数据不足时渲染不完整问题
this.handleRender($virtualDom, $scroll, vIndex, showNumber)
},
/**
* 渲染虚拟列表
* @param {HTMLElement} $virtualDom 虚拟列表的盒子dom元素
* @param {HTMLElement} $scroll 滚动容器
* @param {number} vIndex 当前显示节点开始的索引
* @param {number} showNumber 显示的数量
* @param {number} _scrollTop 滚动条高度(用于矫正开始和结束位置偏移)
**/
handleRender($virtualDom, $scroll, vIndex, showNumber, _scrollTop) {
// 使用 requestAnimationFrame 充分利用了显示器的刷新机制,比较节省系统资源
requestAnimationFrame(() => {
// 直接计算当前所滚动到位置的数据
this.virtualOptions = this.virtualData.slice(
vIndex,
vIndex + showNumber,
)
// 计算偏移量
const y = $scroll ? vIndex * this.optionHeight : _scrollTop
// 解决可自定义新增节点时 列表顶部出现占位空白情况
if (this.allowCreate) {
const firstChild = this.root && this.root.childNodes[0]
if (firstChild instanceof HTMLLIElement) {
firstChild.style.display = y > 0 ? 'none' : 'block'
}
}
$virtualDom.style.transform = `translate(0, ${y}px)`
// 设置滚动高度
$scroll && ($scroll.scrollTop = y)
})
},
// 初始化虚拟列表
initVirtual(type) {
const $wrap = document.getElementById(this.randomId)
if (!$wrap) return
const $virtualDom = $wrap.querySelector('.virtual-dom')
$wrap.style.height = this.virtualData.length * this.optionHeight + 'px'
// 获取滚动容器
const $scroll = this.getParents($wrap, 'el-select-dropdown__wrap')
$scroll.removeEventListener('scroll', this.scrollFunc) // 清除滚动监听
this.scrollFunc = (type) => {
const _scrollHeight = $scroll.offsetHeight || 290 // 解决筛选无数据时,高度为0的情况
const _scrollTop = $scroll.scrollTop
const showNumber =
parseInt(_scrollHeight / this.optionHeight) + this.leafNumber
// 当前应该展示的数据索引
let vIndex = parseInt(_scrollTop / this.optionHeight)
if (type === 'init') vIndex = vIndex >= 8 ? vIndex : 0 // 解决筛选空结果时,列表空白问题
this.handleRender($virtualDom, null, vIndex, showNumber, _scrollTop)
}
this.$nextTick(() => {
this.scrollFunc(type)
$scroll.addEventListener('scroll', this.scrollFunc) // 添加滚动监听
})
},
},
}
</script>
4. 结论
通过引入虚拟列表技术,可以显著提升 el-select 组件在处理大量数据时的性能,经测试百万级选择数据也不会卡顿(前提是电脑能内存能抗得住),改善用户体验。