目录
1.目的
为了解决 element-ui 中 el-select 组件在大数据量的情况下出现的性能问题(数据量太大,导致渲染过慢,或造成页面卡顿甚至于卡死) 。
2.原理
模拟虚拟滚动,对 el-select组件结合vue-virtual-scroll-list(vue虚拟列表)插件进行二次封装
3.优点
适用于只查询一次接口,后端一次性把数据返回。 对比其他的优化方案,有以下优点
方案一:后台进行分页;这种。。嗯,那后端人员可能不乐意了,心里想我都把数据返回给你了,加载慢,页面卡顿不应该你前端的问题吗(关我什么事)。ok,为了避免这种情况,我们就不麻烦后端同学在每个下拉框数据返回后进行分页了,那可是个大工程。对比这种方案优点就是
- 无需后端进行配合,只需要初始化的时候一次性把数据返回给前端即可
方案二:前端懒加载,这种确实可以,我不用此方案就是考虑到数据量过大,那即使页面初始加载的时候较快,但是在页面销毁,简单来说就是切页面的时候,你要销毁所有的dom节点那肯定会出现你的路由已经变化了,但是你的页面还没跳转,造成用户体验不好。对比这种方案我的方案优点
- 由于不管下拉框如何下拉,至始至终都只渲染自己设置的规定的数量,比如:keeps="20"那这个下拉框始终只会渲染20条下拉数据,vue种dom节点那就渲染20条,对比成千上万,不论是初始化或者销毁速度都会明显的提升,用户体验好。
4.源码
1)安装插件
由于需要使用插件vue-virtual-scroll-list,所以我们先在项目中把插件安装一下;
插件官网地址:vue-virtual-scroll-list - npmvue-virtual-scroll-list - npmvue-virtual-scroll-list - npm
npm install vue-virtual-scroll-list --save
2) 创建全局组件VirtualListSelect。
我使用的环境相对来说比较复杂 ,因为我封装的这个组件主要用在低代码平台中,即下拉框是通过页面配置然后渲染出来的,所以需要接收许多参数,但是在正常开发的页面中不需要接收这么多参数,为了方便读者借鉴,我就两种都记录一下。正常开发的页面已经满足大部分读者的需求。
a.简单页面也即正常开发的页面中
- 在src/components下创建VirtualListSelect文件夹,文件夹下大概格式如下:
- VirtualListSelect.vue文件
<template>
<div>
<el-select
:value="defaultValue"
popper-class="virtualselect"
filterable
:filter-method="filterMethod"
@visible-change="visibleChange"
v-bind="$attrs"
v-on="$listeners"
>
<virtual-list
ref="virtualList"
class="virtualselect-list"
:data-key="selectData.value"
:data-sources="selectArr"
:data-component="itemComponent"
:keeps="20"
:extra-props="{
label: selectData.label,
value: selectData.value,
isRight: selectData.isRight
}"
></virtual-list>
</el-select>
</div>
</template>
<script>
import VirtualList from 'vue-virtual-scroll-list';
import itemComponent from './itemComponent';
export default {
name: 'Select',
components: {
'virtual-list': VirtualList
},
model: {
prop: 'defaultValue',
event: 'change'
},
props: {
selectData: {
type: Object,
default() {
return {};
}
}, //父组件传的值
defaultValue: {
type: String,
default: []
} // 绑定的默认值
},
mounted() {
this.init();
},
watch: {
'selectData.data'() {
this.init();
}
},
data() {
return {
itemComponent: itemComponent,
selectArr: []
};
},
methods: {
init() {
if (!this.defaultValue || this.defaultValue.length === 0) {
this.selectArr = this.selectData.data;
} else {
// 回显问题
// 由于只渲染20条数据,当默认数据处于20条之外,在回显的时候会显示异常
// 解决方法:遍历所有数据,将对应回显的那一条数据放在第一条即可
// 注意:此例子只有单选情况,多选类似,想看实现代码请在低代码情况下会有完善
this.selectArr = JSON.parse(JSON.stringify(this.selectData.data));
let obj = {};
for (let i = 0; i < this.selectArr.length; i++) {
const element = this.selectArr[i];
if (
element[this.selectData.value].toLowerCase() ===
this.defaultValue.toLowerCase()
) {
obj = element;
this.selectArr.splice(i, 1);
break;
}
}
this.selectArr.unshift(obj);
}
},
// 搜索
filterMethod(query) {
if (query !== '') {
this.$refs.virtualList.scrollToIndex(0); //滚动到顶部
setTimeout(() => {
this.selectArr = this.selectData.data.filter((item) => {
return this.selectData.isRight
? item[this.selectData.label]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1 ||
item[this.selectData.value]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1
: item[this.selectData.label]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1;
});
}, 100);
} else {
this.init();
}
},
visibleChange(bool) {
if (!bool) {
this.$refs.virtualList.reset();
this.init();
}
}
}
};
</script>
<style lang="scss">
.virtualselect {
// 设置最大高度
&-list {
max-height: 245px;
overflow-y: auto;
}
.el-scrollbar .el-scrollbar__bar.is-vertical {
width: 0 !important;
}
}
</style>
-
itemComponent.vue 文件
<template>
<div>
<el-option
:key="label + value"
:label="source[label]"
:value="source[value]"
>
<span>{{ source[label] }}</span>
<span v-if="isRight" style="float:right;color:#939393">{{
source[value]
}}</span>
</el-option>
</div>
</template>
<script>
export default {
name: 'item-component',
props: {
// index of current item
// 每一行的索引
index: {
type: Number
},
// 每一行的内容
source: {
type: Object,
default() {
return {};
}
},
// 需要显示的名称
label: {
type: String
},
// 绑定的值
value: {
type: String
},
// 右侧是否显示绑定的值
isRight: {
type: Boolean,
default() {
return false;
}
}
},
mounted() {}
};
</script>
组件封装完成后,我们最好将其注册成全局组件,以便在系统中使用
import VirtualListSelect from './VirtualListSelect';
Vue.component('virtual-list-select', VirtualListSelect);
- 下面写一个简单的demo
demo.vue
<template>
<div class="cw-select">
<virtual-list-select
:selectData="selectData"
v-model="defaultValue"
multiple
placeholder="请选择下拉数据"
clearable
@change="selectChange"
></virtual-list-select>
</div>
</template>
<script>
export default {
name: 'virtual-list-select',
data() {
return {
selectData: {
data: [], // 下拉框数据
label: 'name', // 下拉框需要显示的名称
value: 'code', // 下拉框绑定的值
isRight: false //右侧是否显示
},
defaultValue: [] //下拉框选择的默认值
};
},
mounted() {
this.selectData.data = [];
for (let i = 0; i < 10000; i++) {
this.selectData.data.push({ code: 'Test' + i, name: '测试' + i + '' });
}
},
methods: {
selectChange(val) {
console.log('下拉框选择的值', val);
}
}
};
</script>
b.通过表单配置(低代码平台)在页面中渲染出来的下拉框
- VirtualListSelect.vue
<template>
<div class="virtual-list-select">
<el-select
:value="value"
popper-class="virtualselect"
:multiple="multiple"
:collapse-tags="collapseTags"
:placeholder="placeholder"
:clearable="clearable"
:disabled="disabled"
:filterable="filterable"
:filter-method="filterMethod"
:remote="remote"
:remote-method="remoteMethod"
@visible-change="visibleChange"
v-bind="$attrs"
v-on="$listeners"
>
<virtual-list
ref="virtualList"
class="virtualselect-list"
:data-key="selectData.value"
:data-sources="selectArr"
:data-component="itemComponent"
:keeps="20"
:extra-props="{
label: selectData.label,
value: selectData.value,
isRight: selectData.isRight
}"
></virtual-list>
</el-select>
</div>
</template>
<script>
import VirtualList from 'vue-virtual-scroll-list';
import itemComponent from './itemComponent';
export default {
name: 'virtual-list-select',
components: {
'virtual-list': VirtualList
},
model: {
prop: 'value',
event: 'input'
},
props: {
selectData: {
type: Object,
default() {
return {};
}
}, //父组件传的值
value: {
type: String,
default: ''
}, // 绑定的默认值
multiple: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
},
filterable: {
type: Boolean,
default: true
},
remote: {
type: Boolean,
default: false
},
remoteMethod: {
type: Function,
default: () => ''
},
clearable: {
type: Boolean,
default: false
},
collapseTags: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
watch: {
'selectData.data'() {
this.init();
}
},
data() {
return {
itemComponent: itemComponent,
selectArr: []
};
},
methods: {
init() {
if (!this.value || this.value.length === 0) {
this.selectArr = this.selectData.data;
} else {
/** 回显问题
由于只渲染20条数据,当默认数据处于20条之外,在回显的时候会显示异常
解决方法:遍历所有数据,将对应回显的那一条数据放在第一条即可 */
this.selectArr = JSON.parse(JSON.stringify(this.selectData.data));
if (!this.multiple) {
// 1.单选
let obj = {};
for (let i = 0; i < this.selectArr.length; i++) {
const element = this.selectArr[i];
if (
element[this.selectData.value]?.toLowerCase() ===
this.value?.toLowerCase()
) {
obj = element;
this.selectArr.splice(i, 1);
break;
}
}
this.selectArr.unshift(obj);
} else {
// 2.多选
const selectedArr = [];
for (let i = 0; i < this.selectArr.length; i++) {
const element = this.selectArr[i];
for (let j = 0; j < this.value.length; j++) {
const item = this.value[j];
if (
element[this.selectData.value]?.toLowerCase() ===
item?.toLowerCase()
) {
selectedArr.push(element);
this.selectArr.splice(i, 1);
break;
}
}
}
this.selectArr.unshift(...selectedArr);
}
}
},
// 搜索
filterMethod(query) {
if (query !== '' && !this.remote) {
this.$refs.virtualList.scrollToIndex(0); //滚动到顶部
setTimeout(() => {
this.selectArr = this.selectData.data.filter((item) => {
return this.selectData.isRight
? item[this.selectData.label]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1 ||
item[this.selectData.value]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1
: item[this.selectData.label]
.toLowerCase()
.indexOf(query.toLowerCase()) > -1;
});
}, 100);
} else {
this.init();
}
},
visibleChange(bool) {
if (!bool) {
this.$refs.virtualList.reset();
this.init();
}
}
},
created() {
this.init();
}
};
</script>
<style lang="scss" scoped>
.virtualselect {
// 设置最大高度
&-list {
max-height: 245px;
overflow-y: auto;
}
.el-scrollbar .el-scrollbar__bar.is-vertical {
width: 0;
}
}
</style>
- Readme.md文件
## VirtualListSelect
### 1. 组件说明
* 本组件是对 el-select组件结合vue-virtual-scroll-list(vue虚拟列表)插件的二次封装。原理:模拟虚拟滚动,目的是为了解决 element-ui 中 el-select 组件在大数据量的情况下出现的性能问题(数据量太大,导致渲染过慢,造成页面卡顿甚至于卡死)。
* 插件地址:https://www.npmjs.com/package/vue-virtual-scroll-list
### 2. 实现原理
* 用vue-virtual-scroll-list这个插件去包裹需要循坏展示的标签。这里就是el-option标签。
* 由于插件的 data-component 属性,需要抽离出el-option标签封装成一个组件
### 3. 属性说明
* data-key=“‘id’” 就是绑定的唯一key值
* data-sources=“selectArr” 下拉框的数组
* data-component=“itemComponent” 就是抽离中的el-option组件
* keeps=“20” 渲染的个数(默认30个)
* extra-props 值为对象,可以传入自定义属性进去
### 4. 方法
* 实现模糊搜索功能,使用el-select自带的filterMethod方法
* visible-change事件实现下拉框出现/隐藏时触发虚拟列表重置和把列表重置成全量数据
### 5. 注意点
1. <virtual-list style="max-height: 245px; overflow-y: auto;"
* 这里的样式一定要设置成最大高度,防止数据量少了时候下拉框显示多余空白地方
* 高度要设置成245px,不然会出现两个滚动条,会发生滚动bug
* 一定要设置y轴超出滚动
* select标签使用popper-class自定义一个类名,解决会出现两个滚动条的问题
缺点:
* 如果每个选项的长度差距过大,横向宽度会随着滚动变化,这是因为默认只加载20个选项,el-select又是根据所有的optiion中最长的进行填充,如果加载另外20条数据的长度过长时就会出现这种情况。
* 由于只渲染20条数据,当默认数据处于20条之外,在回显的时候会显示异常,目前的解决方法是:遍历所有数据,将对应回显的那一条数据放在第一条即可。
- 低代码中render函数
render.js
export default {
name: 'render',
props: {
type: String,
config: Object
},
render: function(h) {
switch (this.type) {
case 'select':
// return selectItem(h, this.config);
return virtualListSelect(h, this.config);
default:
return '';
}
}
};
// 虚拟下拉列表
function virtualListSelect(h, config) {
const {
model,
key,
props,
listeners,
options,
optionConfig,
placeholder,
syncConfig,
goods,
value,
multiple
} = config;
let opl = 'label';
let opv = 'value';
if (optionConfig) {
if (optionConfig.label) {
opl = optionConfig.label;
}
if (optionConfig.value) {
opv = optionConfig.value;
}
}
const opts = options;
const on = listeners || {};
let pps = props || {};
if (syncConfig) {
pps = {
...pps,
...syncConfig()
};
}
return h('virtual-list-select', {
props: {
placeholder: placeholder || '请选择',
filterable: true,
...pps,
value: value,
selectData: {
data: opts, // 下拉框数据
label: opl || 'text', // 下拉框需要显示的名称
value: opv, // 下拉框绑定的值
isRight: false //右侧是否显示
},
multiple,
allowCreate: goods.allowCreate,
disabled: goods.readable === 1 || goods.pageDisabled,
'multiple-limit': goods.itemMultipleLimit,
'collapse-tags': true,
clearable: goods.clearAble,
'popper-append-to-body': false
},
on: {
...on,
change: (val) => {
goods.value = val;
if (model) {
model[key] = val;
}
if (on.change) {
on.change(val);
}
}
}
});
}