由于业务对页面性能要求很高,如果下拉框数据很大,一个页面有多个下拉框,那么就导致页面很卡顿。由于elementPlus
已经支持了下拉组件虚拟列表,但所在项目仍然使用elementUI2.0
,所以需要自己扩展支持下拉组件虚拟列表,以下是笔者总结的一篇关于elementUI2.0支持下拉框虚拟列表的实践方案,希望看完在项目中有所帮助。
正文开始...
在开始本文之前,笔者主要会从以下方向上去实现该业务需求
1、尝试在原有elementUI
组件上,写一个自定义指令,支持下拉虚拟列表
2、尝试使用社区成熟的虚拟列表
插件方案实现虚拟列表
前置
我们知道虚拟列表
本质上就是在可视区域内显示对应的数据,由于数据是按需加载,所以我们首先就要明白如何实现虚拟列表,具体可以参考以前写的一篇文章了解虚拟列表背后原理,轻松实现虚拟列表
快速实现页面
我们是使用vue-cli2
快速搭建了一个基本项目
我们可以非常清晰的看到右侧下拉测试100
条数据直接渲染出来的
我们看下实际代码
<el-form-item label="非虚拟列表-活动名称2"> <el-select v-model="form.value" placeholder="请选择"> <el-option v-for="item in sourceData" :key="item.value" :label="item.label" :value="item.value" > </el-option> </el-select> </el-form-item>
对应数据就是在created
中直接生成了一组100
条数据
export default { name: 'hello-word', data() { return { sourceData: [] } }, created () { var arr = new Array(100).fill(1); arr.forEach((v, index) => { this.sourceData.push({ value: index, label: `test_${index}` }); }); } }
我们先看下左侧虚拟列表
下拉框并不是一次性渲染所有数据,而是按需获取可视区域的数据,这是如何实现的?
虚拟列表指令
主要思路就是控制下拉数据显示条数,本质就是要控制sourceData
<el-form-item label="虚拟列表-活动名称"> <el-select v-model="form.value1" placeholder="请选择" @visible-change="handleVisibleChange" v-select="{ ...selectAttrs, data: sourceData }" > <el-option v-for="item in optionsData" :key="item.value" :label="item.label" :value="item.value" > </el-option> </el-select> </el-form-item>
我们看到v-select
指令上主要有data
,selectAttrs
,data
是原数据,selectAttrs
主要是虚拟列表需要的参数
selectAttrs
export default { name: 'hello-world', data() { return { sourceData: [], // 原始数据 selectAttrs: { viewHeight: 220, // 可视区域的高度 rowHeight: 30, // 当前行的默认高度 startIndex: 0, endIndex: 0, callback: this.updateOptions, scrollView: null // 滚动容器 } } } }
从指令配置所需要的参数来看,主要是以下几个关键字段:
viewHeight
可视区域的高度
rowHeight
当前行的默认高度
startIndex
数据起始位置
endIndex
数据默认位置
callback
执行回调,主要是控制下拉数据
scrollView
监听滚动容器
然后我们看下指令是如何编写的
const selectDirectives = { wrap: null, fn: null, select: { inserted (el, binding, vnode) { let { data, rowHeight, startIndex, callback, filterable } = binding.value; const { componentInstance: { $children: children } } = vnode; const selectDown = children[children.length - 1]; const [elScrollBar] = selectDown.$children; const [wrap] = elScrollBar.$el.childNodes; const scrollView = wrap.getElementsByClassName('el-scrollbar__view')[0]; const total = data.length; // 所有数据的总条数 // 设置el-scrollbar__view的高度 if (filterable) { scrollView.style.height = 'auto'; } else { scrollView.style.height = `${total * rowHeight}px`; } let timer = false; const fn = () => { if (timer) { return; } timer = true; const requestId = setTimeout(() => { timer = false; const scrollTop = wrap.scrollTop; // 计算当前滚动位置,获取当前开始的起始位置 const currentIndex = Math.floor(scrollTop / rowHeight); // console.log(startIndex, 'startIndex222', currentIndex); // 根据滚动条获取当前索引与起始索引不相等时,将滚动的当前位置设置为起始位置 if (currentIndex !== startIndex) { startIndex = Math.max(currentIndex, 0); } const paddingTop = `${startIndex * rowHeight}px`; scrollView.style.paddingTop = paddingTop; // eslint-disable-next-line standard/no-callback-literal callback({ startIndex, scrollView }); }, 100); if (!requestId) { clearTimeout(requestId); } }; selectDirectives.fn = fn; selectDirectives.wrap = wrap; wrap.addEventListener('scroll', fn, false); }, unbind () { selectDirectives.wrap.removeEventListener('scroll', selectDirectives.fn); } } };
关键的几点
1、找到内容滚动容器wrap
,主要是通过componentInstance
找到下拉滚动父容器
2、设置滚动容器内部高度scrollView
【必须要设置】,不设置的话,内容数据将无法滚动显示
let { data, rowHeight, startIndex, callback } = binding.value; const { componentInstance: { $children: children } } = vnode; const selectDown = children[children.length - 1]; const [elScrollBar] = selectDown.$children; const [wrap] = elScrollBar.$el.childNodes; const scrollView = wrap.getElementsByClassName('el-scrollbar__view')[0]; const total = data.length; // 所有数据的总条数 // 设置el-scrollbar__view的高度 scrollView.style.height = `${total * rowHeight}px`;
用一张图还原一下,为什么需要设置scrollView
的高度,以及当内部容器滚动时,我们需要给内部设置一个paddingTop
,不然显示就会有空白块
3、确定当前滚动的起始位
主要是当我们滚动容器时,根据滚动的位置确定起始位,核心代码如下
const scrollTop = wrap.scrollTop; // 计算当前滚动位置,获取当前开始的起始位置 const currentIndex = Math.floor(scrollTop / rowHeight); // console.log(startIndex, 'startIndex222', currentIndex); // 根据滚动条获取当前索引与起始索引不相等时,将滚动的当前位置设置为起始位置 if (currentIndex !== startIndex) { startIndex = Math.max(currentIndex, 0); } const paddingTop = `${startIndex * rowHeight}px`; scrollView.style.paddingTop = paddingTop; // eslint-disable-next-line standard/no-callback-literal callback({ startIndex, scrollView });
4、我们看到有callback
执行回调返回出去了startIndex
,scrollView
所以从最初设计指令时,我们看到了指令的selectAttrs
上有一个callback
... selectAttrs: { viewHeight: 250, // 可视区域的高度 rowHeight: 30, // 当前行的默认高度 startIndex: 0, endIndex: 0, callback: this.updateOptions, scrollView: null // 滚动容器 }
指令执行回调
主要看updateOptions
methods: { updateOptions ({ startIndex, scrollView }) { this.selectAttrs.startIndex = startIndex; this.selectAttrs.scrollView = scrollView; this.renderOptions(); }, }
我们看下renderOptions
这个方法,主要是更新下拉框数据
... renderOptions () { let { selectAttrs: { viewHeight, rowHeight, startIndex, endIndex }, sourceData } = this; const total = sourceData.length; // 可视区域的条数 const limit = Math.ceil(viewHeight / rowHeight); // 设置末位索引 endIndex = Math.min(startIndex + limit, total); this.selectAttrs.endIndex = endIndex; this.optionsData = sourceData.slice(startIndex, endIndex); },
以上比较关键的一行代码就是根据回调函数中的startIndex
以及limit
确认最后的endIndex
, 以下是核心关键代码
const limit = Math.ceil(viewHeight / rowHeight); // 设置末位索引 endIndex = Math.min(startIndex + limit, total);
最后我们就是根据起始位对愿数数据进行slice
操作,确认真正需要显示的数据
this.optionsData = sourceData.slice(startIndex, endIndex);
对应的页面显示
<el-select v-model="form.value1" placeholder="请选择" @visible-change="handleVisibleChange" v-select="{ ...selectAttrs, data: sourceData }" > <el-option v-for="item in optionsData" :key="item.value" :label="item.label" :value="item.value" > </el-option> </el-select>
然后我们注意到,我们在下拉框下绑定了@visible-change="handleVisibleChange"
方法,实际上只有我们在打开下拉框时才会需要触发更新下拉,所以我们需要调用renderOptions
... handleVisibleChange () { const { selectAttrs: { scrollView } } = this; // 当打开下拉框时,重置scrollView的paadingTop,避免白屏 if (scrollView) { scrollView.style.paddingTop = '0px'; } this.renderOptions(); }
但是我们注意到,这里我们重置了scrollView
的paddingTop
,因为我们在滚动时设置了paddingTop
,所以此时我们需要重置paddingTop
就是为了回显我们上次选择的内容区域
由于我们设置了内容器的高度,所以如果有设置过滤搜索,就会显示有问题,于是我们在过滤搜索时,就将高度置auto
let { data, rowHeight, startIndex, callback, filterable } = binding.value; const { componentInstance: { $children: children } } = vnode; const selectDown = children[children.length - 1]; const [elScrollBar] = selectDown.$children; const [wrap] = elScrollBar.$el.childNodes; const scrollView = wrap.getElementsByClassName('el-scrollbar__view')[0]; const total = data.length; // 所有数据的总条数 // 设置el-scrollbar__view的高度 if (filterable) { scrollView.style.height = 'auto'; } else { scrollView.style.height = `${total * rowHeight}px`; } ...
挂载指令
主要是局部注册就行
// 指令 const selectDirectives = { wrap: null, fn: null, select: { inserted (el, binding, vnode) { ... } };
然后我们需要挂在当前单文件中
export default { name: 'HelloWorld', data () { return { msg: 'Welcome to Your Vue.js App', form: { value1: '', value2: '' }, sourceData: [], optionsData: [], selectAttrs: { viewHeight: 220, // 可视区域的高度 rowHeight: 30, // 当前行的默认高度 startIndex: 0, endIndex: 0, callback: this.updateOptions, scrollView: null, // 滚动容器 filterable: true } }; }, directives: selectDirectives, ... }
最终结果就是下面这样了
vue-virtual-scroll-list插件实现虚拟列表
在以上例子中我们尝试用自己写的指令已经满足虚拟列表,那如果不用自己写的指令,使用社区的方案,会不会更快,更简单呢?我们考虑主要是用这个社区插件[1],实现起来就更简单
<template> <div class="hello"> <el-form ref="form" :model="form" inline> <el-form-item label="活动名称"> <el-select v-model="form.value" placeholder="请选择" @visible-change="handleVisibleChange" ref="select" > <virtual-list :data-key="'id'" :data-sources="sourceData" :data-component="optionComponent" :keeps="10" :extra-props="extraProps" style="max-height: 245px; overflow-y: auto;" > </virtual-list> </el-select> </el-form-item> </el-form> </div> </template>
引入vue-virtual-scroll-list
import virtualList from 'vue-virtual-scroll-list'; const optionComponent = { props: { source: { type: Object, default () { return {}; } }, label: String, value: String }, template: '<el-option :label="source[label]" :value="source[value]"></el-option>' }; export default { name: 'HelloWorld', components: { virtualList }, data () { return { msg: 'Welcome to Your Vue.js App', form: { value: '' }, optionComponent, sourceData: [], extraProps: { label: 'label', value: 'value' } }; }, methods: { handleVisibleChange () { const select = this.$refs.select; const child = select.$children; const [, selectDrop] = child; const [cchild] = selectDrop.$children; const [a] = cchild.$children; const [group] = a.$el.children; group.style.paddingTop = '0px'; console.log(group); } }, created () { var arr = new Array(100).fill(1); arr.forEach((v, index) => { this.sourceData.push({ value: index, label: `test_${index}`, id: Math.random() }); }); } };
我们注意到handleVisibleChange
同样是将滚动容器的paddingTop
置零了,这样保证,打开下拉框时不会白屏。
并且如果是用插件,就必须要有id
,virtual-list
上指定data-key
总结
- 主要是写了一个指令,在
elementUI
的select
组件上支持虚拟列表展示,我们在项目使用自定义指令支持下拉框的虚拟列表 - 使用第三方插件
vue-virtual-scroll-list
实现虚拟列表 - 本文实例源码code example[2]
- 个人比较推荐社区优秀成熟的第三方库去满足我们的业务,自己虽然手写了一个指令支持虚拟列表,但是在业务时间紧凑的情况下,肯定优先使用插件,除非插件不满足我们自己的业务需求,那么只能自己造轮子了。