select组件大数据量列表渲染
请看官先了解vue-virtual-scroll-list这个第三方插件(3000+star),了解地址
可以通过链接直接体验select大数据量效果
效果图:
github项目地址:地址
如果想要了解select的底层原理的话,可以跳到文章底部。
改造开始
element-ui版本是2.15.9。
我们是要对select组件二次改造然后拿给业务组件使用的,对eui组件二次改造的方式我在另一篇文章里写了。
select组件的文件结构如下:
select组件使用方式如下图:option组件是select组件的插槽,所以select组件内部可以获取到这些options
在select组件内使用vue-virtual-scroll-list
先安装组件,npm i vue-virtual-scroll-list
打开select.vue文件
<template>
...
<!-- -->
<el-select-menu
ref="popper"
:append-to-body="popperAppendToBody"
v-show="visible && emptyText !== false">
<!-- 滚动条 -->
<el-scrollbar
tag="ul"
wrap-class="el-select-dropdown__wrap"
view-class="el-select-dropdown__list"
ref="scrollbar"
:class="{ 'is-empty': !allowCreate && query && filteredOptionsCount === 0 }"
v-show="options.length > 0 && !loading">
<!-- 特殊的option,比如允许用户创造出来的option -->
<el-option
:value="query"
created
v-if="showNewOption">
</el-option>
<!-- 外部的插槽,我们在用select组件时,会写option组件作为select的插槽 -->
<slot></slot>
</el-scrollbar>
<template v-if="emptyText && (!allowCreate || loading || (allowCreate && options.length === 0 ))">
<slot name="empty" v-if="$slots.empty"></slot>
<p class="el-select-dropdown__empty" v-else>
{{ emptyText }}
</p>
</template>
</el-select-menu>
...
</template>
我们在这里使用vue-virtual-scroll-list
打开select.vue文件
引入vue-virtual-scroll-list组件
...
import VirtualList from 'vue-virtual-scroll-list';
import virtualListItem from './virtual-scroll-list-item.vue';
...
export default {
...
components: {
...
'virtual-list': VirtualList
},
...
}
找到template中渲染list的地方,使用虚拟列来渲染,建议找到对应代码,然后直接替换。
<template>
...
<!-- 建议直接替换以下代码 -->
<el-select-menu
ref="popper"
:append-to-body="popperAppendToBody"
v-show="visible && (virtualScroll ? virtualScrollEmptyText !== false : emptyText !== false)">
<!-- 如果没开启虚拟滚动,则使用开发者传入的插槽作为option组件 -->
<el-scrollbar
v-if="!virtualScroll"
tag="ul"
wrap-class="el-select-dropdown__wrap"
view-class="el-select-dropdown__list"
ref="scrollbar"
:class="{ 'is-empty': !allowCreate && query && filteredOptionsCount === 0 }"
v-show="options.length > 0 && !loading">
<el-option
:value="query"
created
v-if="showNewOption">
</el-option>
<slot></slot>
</el-scrollbar>
<!-- 使用虚拟列 -->
<virtual-list
v-else
ref="virtualList"
class="el-select-dropdown-virtual-scroll__list"
:data-key="'value'"
:data-sources="filterItems"
:data-component="virtualListItem"
:estimate-size="34"
v-show="items.length > 0 && !loading && !isEmpty">
</virtual-list>
<!-- 在使用虚拟列时,空内容的展示 -->
<template v-if="isEmpty">
<slot name="empty" v-if="$slots.empty"></slot>
<p class="el-select-dropdown__empty" v-else>
{{ virtualScroll ? virtualScrollEmptyText : emptyText }}
</p>
</template>
</el-select-menu>
...
</template>
给prop添加几个属性
props: {
...
// 是否开启虚拟列表
virtualScroll: {
type: Boolean,
default: false
},
// 将需要渲染的list数据传入
items: {
type: Array,
default() {
return [];
}
},
// option组件的label使用list的item的哪个属性,默认是label属性
labelKey: {
type: String,
default: 'label'
},
...
}
给data添加三个属性
data(){
return {
...
virtualListItem: virtualListItem,
noUsedItemCounter: 0,
filterItems: this.filterItem('')
...
}
}
添加三个computed:isEmptyOption、virtualScrollEmptyText、isEmpty
改造一个computed:emptyText
computed:{
...
isEmptyOption() {
return this.noUsedItemCounter === this.filterItems.length;
},
virtualScrollEmptyText() {
if (this.loading) {
return this.loadingText || this.t('el.select.loading');
} else {
if (this.initEmpty && this.query === '' && this.options.length === 0) return this.noDataText || this.t('el.select.noDate');
if (this.remote && this.query === '' && this.items.length === 0) return false;
if (this.filterable && this.query && this.items.length > 0 && this.isEmptyOption) {
return this.noMatchText || this.t('el.select.noMatch');
}
if (this.isEmptyOption) {
return this.noDataText || this.t('el.select.noData');
}
}
return null;
},
isEmpty() {
if (this.virtualScroll) {
return this.virtualScrollEmptyText && (this.loading || this.isEmptyOption);
} else {
return this.emptyText && (!this.allowCreate || this.loading || (this.allowCreate && this.options.length === 0));
}
},
// 改造该方法
emptyText() {
/* 新增的代码行 */
if (this.virtualScroll) {
return null;
}
/* 和上面的注释对应 */
if (this.loading) {
return this.loadingText || this.t('el.select.loading');
} else {
if (this.remote && this.query === '' && this.options.length === 0) return false;
if (this.filterable && this.query && this.options.length > 0 && this.filteredOptionsCount === 0) {
return this.noMatchText || this.t('el.select.noMatch');
}
if (this.options.length === 0) {
return this.noDataText || this.t('el.select.noData');
}
}
return null;
},
...
}
添加一个方法:filterItem
改造三个方法:scrollToOption、handleFocus、handleQueryChange,各位看官,直接替换方法即可
methods:{
...
// 添加的
filterItem(query) {
let lowerCaseQuery = query.toString().toLowerCase();
let tempItems = [...this.items];
if (query === undefined || query === '') {
return tempItems;
} else {
if (this.allowCreate) {
let rsl = tempItems.find(item => {
return item[this.labelKey].toString() === query;
}) ? [] : [{label: query, value: query}];
return rsl.concat(tempItems.filter(item => {
return item[this.labelKey].toString().toLowerCase().includes(lowerCaseQuery) || item.isPlaceHolder || item.isGroupTitle;
}) || []);
} else {
return tempItems.filter(item => {
return item[this.labelKey].toString().toLowerCase().includes(lowerCaseQuery) || item.isPlaceHolder || item.isGroupTitle;
}) || [];
}
}
},
// 改造
handleQueryChange(val) {
if (this.previousQuery === val || this.isOnComposition) return;
if (
this.previousQuery === null &&
(typeof this.filterMethod === 'function' || typeof this.remoteMethod === 'function')
) {
this.previousQuery = val;
return;
}
this.previousQuery = val;
this.$nextTick(() => {
if (this.visible) this.broadcast('ElSelectDropdown', 'updatePopper');
});
this.hoverIndex = -1;
if (this.multiple && this.filterable) {
this.$nextTick(() => {
const length = this.$refs.input.value.length * 15 + 20;
this.inputLength = this.collapseTags ? Math.min(50, length) : length;
this.managePlaceholder();
this.resetInputHeight();
});
}
if (this.remote && typeof this.remoteMethod === 'function') {
this.hoverIndex = -1;
this.remoteMethod(val);
} else if (typeof this.filterMethod === 'function') {
this.filterMethod(val);
this.broadcast('ElOptionGroup', 'queryChange');
} else {
this.filteredOptionsCount = this.optionsCount;
/* 新增的代码行 */
this.filterItems = this.filterItem(val);
/* 和上面那个注释对应 */
this.broadcast('ElOption', 'queryChange', val);
this.broadcast('ElOptionGroup', 'queryChange');
}
if (this.defaultFirstOption && (this.filterable || this.remote) && this.filteredOptionsCount) {
this.checkDefaultFirstOption();
}
},
scrollToOption(option) {
const target = Array.isArray(option) && option[0] ? option[0].$el : option.$el;
if (this.$refs.popper) {
if (this.virtualScroll) {
if (this.filterItems.length > 0) {
let currentNodeIndex = -1;
const firstSelectNode = Array.isArray(option) && option[0] ? option[option.length - 1] : option;
for (let i = 0; i < this.filterItems.length; i++) {
const item = this.filterItems[i];
if (item.value === firstSelectNode.value) {
currentNodeIndex = i;
break;
}
}
if ((currentNodeIndex === -1 || currentNodeIndex === 0)) {
this.$refs.virtualList && this.$refs.virtualList.reset();
} else {
this.$refs.virtualList.scrollToIndex(currentNodeIndex);
}
} else {
this.$refs.virtualList && this.$refs.virtualList.reset();
}
} else if (target) {
const menu = this.$refs.popper.$el.querySelector('.el-select-dropdown__wrap');
scrollIntoView(menu, target);
}
}
this.$refs.scrollbar && this.$refs.scrollbar.handleScroll();
},
handleFocus(event) {
if (!this.softFocus) {
if (this.automaticDropdown || this.filterable) {
this.visible = true;
if (this.filterable) {
this.menuVisibleOnFocus = true;
}
}
this.$emit('focus', event);
} else {
this.softFocus = false;
}
},
...
}
改造一下mounted钩子函数,各位直接替换即可
mounted() {
if (this.multiple && Array.isArray(this.value) && this.value.length > 0) {
this.currentPlaceholder = '';
}
addResizeListener(this.$el, this.handleResize);
const reference = this.$refs.reference;
if (reference && reference.$el) {
const sizeMap = {
medium: 36,
small: 32,
mini: 28
};
const input = reference.$el.querySelector('input');
this.initialInputHeight = input.getBoundingClientRect().height || sizeMap[this.selectSize];
}
if (this.remote && this.multiple) {
this.resetInputHeight();
}
this.$nextTick(() => {
if (reference && reference.$el) {
this.inputWidth = reference.$el.getBoundingClientRect().width;
}
});
this.setSelected();
/* 增加的代码行 */
this.filterItems = this.filterItem('');
let counter = 0;
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item.isGroupTitle) {
counter++;
}
}
this.noUsedItemCounter = counter;
/* 和上一个注释对应 */
},
创建虚拟滚动需要用到的item
在select.vue文件的同级目录创建一个virtual-scroll-list-item.vue文件
<script>
export default {
name: 'item',
inject: ['select'],
props: {
index: {
type: Number
},
source: {
type: Object,
default() {
return {};
}
}
},
render(h) {
const {source} = this;
// 此处的用法是vue的作用域插槽
let render = this.select.$scopedSlots.default ? this.select.$scopedSlots.default(source)[0] : null;
return (render);
}
};
</script>
改变select的使用方式
原来的select组件的使用方式如下图:
添加虚拟列的select得使用下图的使用方式
select组件底层的实现原理
我觉得图+文字的讲解才能让人更加连贯的去理解一个东西,而ppt式:点击鼠标左键,不断出现新的图和文字能明显增加这种连贯性,能让看官更好的去理解select组件底层的实现原理。
点击链接,等界面元素消失后,不断点击鼠标左键会出现图形和文字。
效果图: