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 组件在处理大量数据时的性能,经测试百万级选择数据也不会卡顿(前提是电脑能内存能抗得住),改善用户体验。

← 返回帖子 更多前端内容 →