ElementUI-select-大数据量列表

1,284 阅读3分钟

select组件大数据量列表渲染

请看官先了解vue-virtual-scroll-list这个第三方插件(3000+star),了解地址

可以通过链接直接体验select大数据量效果

效果图:

录制_2022_09_02_17_15_49_879.gif github项目地址:地址

如果想要了解select的底层原理的话,可以跳到文章底部。

改造开始

element-ui版本是2.15.9。

我们是要对select组件二次改造然后拿给业务组件使用的,对eui组件二次改造的方式我在另一篇文章里写了。

select组件的文件结构如下:

image.png

select组件使用方式如下图:option组件是select组件的插槽,所以select组件内部可以获取到这些options

image.png

在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文件

image.png
<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组件的使用方式如下图: image.png

添加虚拟列的select得使用下图的使用方式

image.png

select组件底层的实现原理

我觉得图+文字的讲解才能让人更加连贯的去理解一个东西,而ppt式:点击鼠标左键,不断出现新的图和文字能明显增加这种连贯性,能让看官更好的去理解select组件底层的实现原理。

点击链接,等界面元素消失后,不断点击鼠标左键会出现图形和文字。

效果图:

ezgif.com-gif-maker.gif

看到这里的看官,麻烦点个赞赞吧

如果遇到问题,我远程帮你,微信号:17688172759