懒加载-虚拟列表-下拉菜单选择器

最近,遇到 select 表单下拉选项过多的问题。由于业务场景需要,选项是由接口请求回来,数量取决于线上数据的多少。

借此,顺便记录下目前遇到 select 组件有哪些优化选择。附上一些例子,使用 vue 写的。当然用 react 也是类似的做法,这个和用什么框架关系不大。

为了文章内容不要太长,例子里的共同样式放在了最后。

一、懒加载

懒加载这个词各位应该也很熟悉。在一个古老的例子,瀑布流显示图片就有这个词语了。就是第一屏只显示几条,用户有兴趣往下拉,再继续加载(或请求)新的数据。
以前的话应该是监听 scroll 事件,判断是否要见底了,来补充新数据。后面出了一个厉害的 IntersectionObserver 方法。具体怎么用就不赘述了,总之就是能监听一个 dom 的显隐,来触发回调。

例子可看代码,支持前端自己分批数据或者每次下拉由后端处理分批

<template>
  <div ref="lazy-load-select" class="lazy-load-select" :class="{ active: active }">
    <a class="display-label" href="javascript:;" @click="active = !active">{{
      displayLabel
    }}</a>
    <div class="list" v-show="active">
      <input
        type="text"
        class="select-input"
        @input="search"
        v-model="searchText"
      />
      <ul class="select-ul" ref="select-ul">
        <li
          v-for="item in displayData"
          ref="select-li"
          :class="{ 'select-li': true, selected: item.value === currentValue }"
          :key="item.value"
          @click="select(item.value)"
        >
          {{ item.label }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
function genData({ pageno, count, keyword }) {
  return new Array(count).fill('1').map((_, index) => {
    const value = (pageno - 1) * count + index + 1 + '';
    return {
      value,
      label: 'LazyLoad-' + value + (keyword || ''),
    };
  });
}

const originData = genData({ pageno: 1, count: 100 });

// 调试用的声明,true:使用全部数据返回,由前端分批,false:分批从后端获取数据
const defaultProps = false ? {
  originData: () => originData,
  getData: undefined,
} : {
  originData: () => [],
  getData: ({ pageno, count, keyword }) => {
    return Promise.resolve(genData({ pageno, count, keyword }));
  },
};

// 上面是调试示例用的代码

const ONE_LOAD = 10; // 每次加载10条

const LAZY_LOAD_COMP = 2; // 倒数第几个开始新的加载

export default {
  name: 'LazyLoadSelect', // 懒加载下拉菜单,支持前端分批处理或后端分批处理
  props: {
    originData: {
      type: Array,
      default: defaultProps.originData,
    },
    getData: {
      type: Function,
      default: defaultProps.getData,
    },
  },
  data() {
    return {
      currentValue: 0,
      active: false,
      searchText: '',
      displayData: [],
      filterData: [],
      intersectionObserver: null,
    };
  },
  computed: {
    displayLabel() {
      const selectd = this.originData.find(
        item => item.value === this.currentValue
      );
      return selectd ? selectd.label : '请选择';
    },
    useRemoteData() {
      return typeof this.getData === 'function';
    },
    count() {
      return ONE_LOAD;
    },
    pageno() {
      return this.displayData.length / this.count || 1;
    },
  },
  methods: {
    async initFilterData(keyword) {
      this.displayData = [];
      if (this.useRemoteData) {
        // 如果有外部方法,初始化则不需要过滤,直接更新接口来的数据即可
        const data = await this.loadRemoteData();
        this.displayData = data;
      } else {
        // 如果没有外部方法,初始化则先根据搜索词过滤得出过滤数据,然后再对过滤数据分批
        this.filterData = this.originData.filter(item => {
          return keyword ? item.label.indexOf(keyword) !== -1 : true;
        });
        this.displayData = this.filterData.slice(0, 0 + ONE_LOAD);
      }
      this.$selectUl && (this.$selectUl.scrollTop = 0);
      this.updateObserveLayzload();
    },
    search(event) {
      // 应防抖
      this.initFilterData(this.searchText);
    },
    select(value) {
      this.currentValue = value;
      this.active = false;
      this.$emit('change', value);
    },
    updateObserveLayzload() {
      this.$nextTick(() => {
        if (this.displayData.length >= this.filterData.length && !this.useRemoteData) {
          this.$lazyLoadLi &&
            this.intersectionObserver.unobserve(this.$lazyLoadLi);
          return;
        }
        const $liArr = this.$refs['select-li'];

        this.$lazyLoadLi &&
          this.intersectionObserver.unobserve(this.$lazyLoadLi);
        this.$lazyLoadLi = $liArr[$liArr.length - 1 - LAZY_LOAD_COMP];
        this.intersectionObserver.observe(this.$lazyLoadLi);
      });
    },
    async loadRemoteData() { // 从外部方法获取数据
      try {
        const data = await this.getData(
            { pageno: this.pageno, count: this.count, keyword: this.searchText }
        );
        return data;
      } catch(error) {
        console.error('get data error:', error);
      }
      return [];
    },
    async getNewBatchData() {
      if (this.useRemoteData) {
        // 数据(包括筛选)完全由后端处理
        const addData = await this.loadRemoteData();
        return addData;
      }
      // 由前端对数据分批
      const addData = this.filterData.slice(
        this.displayData.length,
        this.displayData.length + ONE_LOAD,
      );
      return addData;
    },
    async updateData() {
      const addData = await this.getNewBatchData();
      this.displayData.push(...addData);
      this.updateObserveLayzload();
    },
  },
  created() {
    this.initFilterData('');
  },
  mounted() {
    this.$selectUl = this.$refs['select-ul'];
    this.intersectionObserver = new IntersectionObserver(entries => {
      if (entries[0].intersectionRatio) {
        this.updateData();
      }
    });

    const $main = this.$refs['lazy-load-select'];
    window.addEventListener(
      'click',
      (ev) => {
        // 判断是否点击在 select 组件上
        const isSelectComp = ev.composedPath().find(dom => $main === dom);
        if (!isSelectComp) {
          this.active = false;
        }
      }
    );
  }
};
</script>

二、虚拟滚动条

懒加载是为了性能优化出现,确实也有一定作用。但是没有解决根本问题,那就是选项过多导致的下拉菜单的 dom 过多。如果持之以恒地往下拉,还是会有很多 dom。

这时候虚拟滚动条方案就出现了:每次都是固定展示 n 条。然后借由不断的替换这一批下拉选项数据,达到各个选项可选择的功能。

首先算出实际的下拉菜单的高度,用一个一样高的容器包裹着真实渲染的下拉菜单选项,然后监听 scroll 事件,每次得出滚动距离 scrollTop,用容器 padding 或者 绝对定位什么都行。就是让下拉菜单选项距离顶部有 scrollTop 的距离。这一步是达到滚动体验和真的有那么多个的效果一样。
然后根据 scrollTop 来算出滚动了多少条的选项,用以更新这一批真实渲染的下拉菜单选项。比如滚动的距离有 10 条的高度,那么新的一批就从第 11 条开始。

例子可看代码,理论上还是可以用 IntersectionObserver,但简单实现还是用了监听 scroll 事件。

<template>
  <div ref="virtual-scroll-relect" class="virtual-scroll-relect" :class="{ active: active }">
    <a class="display-label" href="javascript:;" @click="active = !active">{{
      displayLabel
    }}</a>
    <div class="list" v-show="active">
      <input
        type="text"
        class="select-input"
        @input="search"
        v-model="searchText"
      />
      <div class="view-container" ref="view-container" @scroll="handleScroll">
        <div class="item-container" ref="item-container"
            :style="{ height: containerHeight+'px', paddingTop: containerPaddingTop+'px' }">
          <div
            v-for="item in displayData"
            class="item"
            :class="{ selected: item.value === currentValue }"
            :key="item.value"
            @click="select(item.value)"
          >
            {{ item.label }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
function genData(length) {
  return new Array(length).fill('1').map((_, index) => {
    const idx = index + 1;
    return {
      value: idx,
      label: 'VirtualScroll-' + idx
    };
  });
}

const originData = genData(1000);

// 上面是调试示例用的代码

const BATCH_NUM = 10; // 每一批替换的条数

const ITEM_HEIGHT = 24; // 单个选项的高度

export default {
  name: 'VirtualScrollSelect', // 虚拟滚动列表下拉菜单
  props: {
    originData: {
      type: Array,
      default: () => originData, // 原始的所有数据
    },
  },
  data() {
    return {
      filterData: [], // 原始数据经过过滤后的数据
      displayData: [], // 真正用于渲染的数据
      currentValue: 0,
      active: false,
      searchText: '',
      containerHeight: 0,
      containerPaddingTop: 0,
      itemHeight: ITEM_HEIGHT,
    };
  },
  computed: {
    displayLabel() {
      const selectd = this.originData.find(
        item => item.value === this.currentValue
      );
      return selectd ? selectd.label : '请选择';
    }
  },
  watch: {
    active(val) {
      if (val) {
        this.init();
      }
    },
  },
  methods: {
    init() {
      if (this.containerHeight) return;
      this.updateFilterData();
    },
    updateFilterData(keyword) {
      this.filterData = this.originData.filter(item => {
        return keyword ? item.label.indexOf(keyword) !== -1 : true;
      });
      this.displayData = this.filterData.slice(0, BATCH_NUM);
      this.$nextTick(() => {
        this.containerHeight = this.itemHeight * this.filterData.length;
      });
    },
    search(event) {
      // 应防抖
      this.containerPaddingTop = 0;
      this.$viewContainer.scrollTop = 0;
      this.updateFilterData(this.searchText);
    },
    select(value) {
      this.currentValue = value;
      this.active = false;
      this.$emit('change', value);
    },
    handleScroll() {
      this.containerPaddingTop = this.$viewContainer.scrollTop;
      const position = Math.ceil(this.containerPaddingTop / this.itemHeight);
      this.displayData = this.filterData.slice(position, position + BATCH_NUM);
    },
  },
  created() {
  },
  mounted() {
    this.$viewContainer = this.$refs['view-container'];
  }
};
</script>

三、两者混合

现实情况还是有很多是 懒加载 + 虚拟滚动列表 的组合。拿上面的虚拟列表例子加上了懒加载功能。偷懒实现就把所有数据都改成接口(外部方法)返回。由调用方(或是前端自己分批或是后端分页处理)自己对数据分批处理然后返回回来。

这个要注意的就是,每次加上新的一批的数据,要更新虚拟列表的高度,已达到视觉上真的好像多了很多选项的感觉

<template>
  <div ref="multi-relect" class="multi-relect" :class="{ active: active }">
    <a class="display-label" href="javascript:;" @click="active = !active">{{
      displayLabel
    }}</a>
    <div class="list" v-show="active">
      <input
        type="text"
        class="select-input"
        @input="search"
        v-model="searchText"
      />
      <div class="view-container" ref="view-container" @scroll="handleScroll">
        <div class="item-container" ref="item-container"
            :style="{ height: containerHeight+'px', paddingTop: containerPaddingTop+'px' }">
          <div
            v-for="item in displayData"
            ref="item"
            class="item"
            :class="{ selected: item.value === currentValue }"
            :key="item.value"
            @click="select(item.value)"
          >
            {{ item.label }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
function genData({ pageno, count, keyword }) {
  return new Array(count).fill('1').map((_, index) => {
    const value = (pageno - 1) * count + index + 1 + '';
    return {
      value,
      label: 'MultiSelect-' + value + (keyword || ''),
    };
  });
}

const ONE_LOAD = 10; // 每次加载10条,作用于懒加载

const BATCH_NUM = 10; // 每一批替换的条数

const ITEM_HEIGHT = 24;

export default {
  name: 'AllSelect', // 虚拟滚动列表 + 懒加载下拉菜单
  props: {
    getData: {
      type: Function,
      default: ({ pageno, count, keyword }) => {
        return Promise.resolve(genData({ pageno, count, keyword }));
      },
      require: true,
    },
  },
  data() {
    return {
      originData: [],
      displayData: [], // 真正用于渲染的数据
      currentValue: 0,
      active: false,
      searchText: '',
      viewContainerHeight: 200,
      containerHeight: 0,
      containerPaddingTop: 0,
      itemHeight: ITEM_HEIGHT,
    };
  },
  computed: {
    displayLabel() {
      const selectd = this.originData.find(
        item => item.value === this.currentValue
      );
      return selectd ? selectd.label : '请选择';
    },
    count() {
      return ONE_LOAD;
    },
    pageno() {
      return (this.originData.length / this.count) + 1;
    },
  },
  watch: {
    active(val) {
      if (val) {
        this.init();
      }
    },
  },
  methods: {
    init() {
      if (this.containerHeight) return;
      this.initData();
    },
    search(event) {
      // 应防抖
      this.containerPaddingTop = 0;
      this.$viewContainer.scrollTop = 0;
      this.initData();
    },
    select(value) {
      this.currentValue = value;
      this.active = false;
      this.$emit('change', value);
    },
    async initData() {
      this.originData = [];
      this.originData = await this.loadRemoteData();;
      this.displayData = this.originData.slice(0, BATCH_NUM);
      this.containerHeight = this.itemHeight * this.originData.length;
    },
    async loadRemoteData() { // 从外部方法获取数据
      if (typeof this.getData !== 'function') return [];
      try {
        const data = await this.getData(
            { pageno: this.pageno, count: this.count, keyword: this.searchText }
        );
        return data;
      } catch(error) {
        console.error('get data error:', error);
      }
      return [];
    },
    async updateData() {
      const addData = await this.loadRemoteData();
      this.displayData.push(...addData);
      this.handleScroll();
    },
    async handleScroll() {
      this.containerPaddingTop = this.$viewContainer.scrollTop;

      // 要见底,则加载新数据
      if ((this.containerHeight - this.containerPaddingTop) <= this.viewContainerHeight) {
        const data = await this.loadRemoteData();
        this.originData.push(...data);
      }

      const position = Math.ceil(this.containerPaddingTop / this.itemHeight);
      this.displayData = this.originData.slice(position, position + BATCH_NUM);
      this.containerHeight = this.itemHeight * this.originData.length;
    },
  },
  mounted() {
    this.$viewContainer = this.$refs['view-container'];
  }
};
</script>

样式部分:

<style>
* {
  box-sizing: border-box;
}
ul,
li {
  padding: 0;
  margin: 0;
  list-style: none;
}

.lazy-load-select {
  width: 200px;
  height: 30px;
  border: 1px solid #d7dde4;
  border-radius: 5px;
  position: relative;
}
.active {
  border: 1px solid #3399ff;
  box-shadow: 0px 0px 2px #abcdef, 0px 0px 2px #abcdef, 0px 0px 2px #abcdef,
    0px 0px 2px #abcdef;
}

.display-label {
  display: inline-block;
  width: 200px;
  height: 100%;
  text-decoration: none;
  line-height: 30px;
  color: #657180;
  position: relative;
  padding-right: 20px;
}
.display-label::after {
  content: "";
  height: 0;
  width: 0;
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-20%);
  border: 6px solid;
  border-color: #657180 transparent transparent;
}
.active .display-label::after {
  transform: translateY(-80%);
  border-color: transparent transparent #657180;
}

.list {
  position: absolute;
  width: 200px;
  top: 35px;
  left: 0;
  padding: 5px;
  box-shadow: 2px 2px 4px #ccc, -2px 2px 4px #ccc, 2px -2px 4px #ccc,
    -2px -2px 4px #ccc;
}
.select-input {
  border: 1px solid #d7dde4;
  width: 100%;
  color: #657180;
}
.select-input:focus {
  outline: 1px solid #abcdef;
}

.select-ul {
  height: 200px;
  overflow: auto;
  margin-top: 5px;
}
.select-li {
  cursor: pointer;
  padding: 3px;
  color: #657180;
}
.select-li:hover {
  background-color: #ccc;
}
.selected {
  background-color: #abcdef;
}
</style>