【面试】后端一次给你30万option待选项,页面无响应了你怎么办?

4 篇文章 0 订阅
4 篇文章 0 订阅

最近遇到一个问题,在开发编辑页面时候,遇到select大数据量,一个下拉选择器有两万个备选,页面上会不定出现3-18个select,预计可能会渲染30万多个option备选,这么多dom加载,直接把浏览器干无响应了。

第一个构想就是虚拟select

搜索一番没有合适的轮子,vxe-table 比较合适,但是为了一个问题单独引入,不合适,
只能先基于elementUIselect选择器再度封装解决问题,节流版select

第一版,节流select

此版本的select规避页面一打开,就加载所有option导致页面卡死,无法使用为目标

<throttling-select v-model="value" :list="options" :placeholder="placeholder" selkey="key" sellabel="label"></throttling-select>

在vue组件挂载的时候,判断当前selectoption数量是否超过1000个,小于则直接渲染,大于则不渲染只启用搜索渲染,借助于el-select的远程搜索功能实现


<template>
  <el-select
    @visible-change="popChange"
    v-model="selectValue"
    filterable
    remote
    :placeholder="placeholder"
    :remote-method="remoteMethod"
    :loading="loading"
    style="width: calc(98% - 20px)"
  >
    <el-option
      v-for="item in options"
      :key="item[selkey]"
      :label="item[sellabel]"
      :value="item[selkey]"
    >
    </el-option>
  </el-select>
</template>

<script>
export default {
  mixins: [],
  filters: {},
  components: {},
  model: {
    prop: 'value', //绑定的值,通过父组件传递
    event: 'update' //自定义名
  },
  props: {
    value: {
      type: [String, Number],
      default: ''
    },
    list: {//选项值
      type: Array,
      default: () => []
    },
    placeholder: {
      type: [String, Number],
      default: '选项多,加载慢,建议搜索'
    },
    selkey: {
      type: [String, Number],
      default: 'key'
    },
    sellabel: {
      type: [String, Number],
      default: 'label'
    }
  },
  data() {
    return {
      options: [],
      selectValue: '',
      loading: false
    }
  },
  computed: {},
  watch: {
    selectValue(val) {
      console.log(val)
      this.$emit('update', val)
    }
  },
  created() {
    this.selectValue = this.value
  },
  mounted() {
  },
  destroyed() {
  },
  methods: {
    popChange(e) {
      console.log(e)
      if (e) {
        this.loading = true
        this.options = this.list.length > 1000 ? [] : [...this.list]   
        this.loading = false
      }
    },
    remoteMethod(query) {
      console.log(query)
      if (query !== '') {
        this.loading = true
        this.options = []
        const reg = new RegExp(query.toLowerCase())
        setTimeout(() => {
          this.options = this.list.filter(item => {
            return reg.test(item[this.selkey].toLowerCase())
          })
          this.loading = false
        }, 50)
      } else {
        this.options = []
      }
    }
  }
}
</script>

<style rel="stylesheet/scss" lang="scss" scoped></style>

第二版:虚拟select滚动

基于vue-virtual-scroller插件二次开发,好处是支持大数据,复用dom元素,拖动流畅

虽然作者 已经封装过virtual-selector插件,但是根本还是在virtual-scroller上做的二次封装,只完成了最基本的select功能,定制化不高.

索性自己重新封装,满足自由度。样式嫌丑或者功能不满足请自己改virtualSelector.vue文件

安装npm install -save vue-virtual-scroller

创建virtualSelector.vue文件

//virtualSelector.vue
//开发插件
<template>
  <div
    class="virtual-selector"
    :id="vsId"
  >
    <span
      class="virtual-selector__label"
      :class="{none: !label}"
    >{{ label }}</span>
    <div class="virtual-selector__input-wrapper">
      <input
        class="virtual-selector__input"
        :placeholder="placeholder"
        v-model="selected[option.itemNameKey]"
        @keyup="handleKeyup"
        @input="handleInput"
        @focus="handleFocus($event)"
        @select="handleInputSelect($event)"
      />
      <i class="virtual-selector__arrow">
        <svg
          viewBox="64 64 896 896"
          data-icon="down"
          width="1em"
          height="1em"
          fill="currentColor"
          aria-hidden="true"
          focusable="false"
          class=""
        >
          <path d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"></path>
        </svg>
      </i>
      <div
        v-if="loading"
        class="virtual-selector__loading"
      >
        <slot name="loading"></slot>
      </div>
      <recycle-scroller
        v-if="flist.length > 0"
        class="virtual-selector__scroller virtual-selector__dropdown"
        :items="flist"
        :item-size="itemSize"
        :key-field="option.itemNameKey"
        v-slot="{ item }"
      >
        <div
          class="virtual-selector__dropdown-item"
          :class="{
            'virtual-selector__dropdown-item--selected':
              item[option.itemValueKey] === selected[option.itemValueKey],
          }"
          @click="handleItemSelect($event, item)"
        >
          <slot
            v-if="$scopedSlots.item"
            name="item"
            :item="item"
          ></slot>
          <slot v-else>{{ item[option.itemNameKey] }}</slot>
        </div>
      </recycle-scroller>
    </div>
  </div>
</template>

<script>
function debounce(fn, delay) {
  let timer;

  return function () {
    const context = this;
    const args = arguments;

    clearTimeout(timer);

    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}


import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
const defaultItemPageSize = 8;
const defaultItemGap = 0;
const dropdownActiveClassName = "virtual-selector__input-wrapper--active";
export default {
  name: "VirtualSelector",
  components: { RecycleScroller },
  props: {
    loading: {
      type: Boolean,
      default: false,
    },
    label: {
      type: String,
      default: "",
    },
    placeholder: {
      type: String,
      default: "",
    },
    value: {
      type: Object,
      default: () => {},
    },
    list: {
      type: Array,
      required: true,
      default: () => [],
    },
    /**
     * option: {
     *   itemNameKey: string,
     *   itemValueKey: string,
     *   itemPageSize: number
     *   itemGap: number
     * }
     */
    option: {
      type: Object,
      required: true,
      default: () => {},
    },
  },
  data() {
    return {
      id: new Date().getTime(),
      flist: [],
      selected: {},
      itemSize: 32 + ((this.option && this.option.itemGap) || defaultItemGap),
    };
  },
  computed: {
    vsId() {
      return `virtual-selector-${this.id}`;
    },
  },
  watch: {
    list: {
      immediate: true,
      handler() {
        this.init();
      },
    },
  },
  methods: {
    init() {
      if (!this.list || this.list.length == 0) return;
      if (
        !this.option ||
        !this.option.itemNameKey ||
        !this.option.itemValueKey
      ) {
        throw new Error(
          '请指定列表配置选项"itemNameKey"或"itemValueKey"'
        );
      }
      this.flist = [...this.list];
      this.value instanceof Object &&
      (this.selected = {
        [this.option.itemNameKey]: this.value[this.option.itemNameKey],
        [this.option.itemValueKey]: this.value[this.option.itemValueKey],
      });
      this.$nextTick(() => {
        document
        .getElementById(this.vsId)
         .querySelector(".virtual-selector__scroller").style.maxHeight =
          (this.option.itemPageSize || defaultItemPageSize) * this.itemSize +
          4 +
          "px";
      });
    },
    mount() {
      document.addEventListener("click", this.handleGlobalClick, false);
    },
    unmount() {
      document.removeEventListener("click", this.handleGlobalClick, false);
    },
    handleKeyup: debounce(function () {
      const input = this.selected[this.option.itemNameKey];
      this.option.itemNameKey !== this.option.itemValueKey &&
      (this.selected[this.option.itemValueKey] = "");
      if (!input) {
        this.flist = [...this.list];
      } else {
        this.flist = this.list.filter((item) => {
          if (
            item[this.option.itemNameKey].toLowerCase() === input.toLowerCase()
          ) {
            this.selected[this.option.itemValueKey] =
              item[this.option.itemValueKey];
            this.$nextTick(() => {
              this.$emit("select", {
                id: this.vsId,
                select: { ...this.selected },
              });
            });
          }
          return item[this.option.itemNameKey]
          .toString()
           .toLowerCase()
           .includes(input.toLowerCase());
        });
      }
      this.$emit("search", {
        id: this.vsId,
        search: { [this.option.itemNameKey]: input },
      });
    }, 300),
    handleInput() {
      this.$emit("input", {
        [this.option.itemNameKey]: this.selected[this.option.itemNameKey],
      });
    },
    handleFocus(e) {
      e.target.offsetParent.classList.toggle(dropdownActiveClassName);
      this.$emit("focus", {
        id: this.vsId,
        focus: { event: e },
      });
    },
    handleInputSelect(e) {
      e.target.offsetParent.classList.add(dropdownActiveClassName);
    },
    handleItemSelect(e, item) {
      this.selected = {
        ...item,
        [this.option.itemNameKey]: e.target.offsetParent.innerText,
      };
      this.$emit("select", {
        id: this.vsId,
        select: { ...this.selected },
      });
    },
    handleGlobalClick(e) {
      if (e.target.className === "virtual-selector__input") return;
      Array.from(document.querySelectorAll(".virtual-selector")).forEach(
        (el) => {
          el.querySelector(".virtual-selector__input-wrapper").classList.remove(
            dropdownActiveClassName
          );
        }
      );
    },
  },
  mounted: function () {
    this.mount();
  },
  beforeDestroy: function () {
    this.unmount();
  },
};
</script>

<style scoped>
.virtual-selector {
  display: inline-flex;
  align-items: center;
  box-sizing: border-box;
}
.virtual-selector__label {
  line-height: 1.5;
  font-size: 14px;
  white-space: nowrap;
  color: #333;
}
.virtual-selector__label::after {
  position: relative;
  top: -0.5px;
  content: ":";
  margin: 0 8px 0 2px;
}
.virtual-selector__label.none::after {
  display: none;
}
.virtual-selector__input-wrapper {
  position: relative;
  flex: 1;
}
.virtual-selector__input {
  display: block;
  width: 100%;
  height: 32px;
  padding: 0 34px 0 11px;
  border: 1px solid #409eff;
  border-radius: 5px;
  background-color: #fff;
  color: rgba(0, 0, 0, 0.65);
  font-size: 14px;
  box-sizing: border-box;
  outline: none;
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  cursor: text;
}
.virtual-selector__input::placeholder,
.virtual-selector__input::-webkit-input-placeholder {
  color: rgba(0, 0, 0, 0.65);
}
.virtual-selector__input:hover {
  border-color: #0f48b3;
}
.virtual-selector__arrow {
  position: absolute;
  top: 10px;
  right: 11px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 12px;
  height: 12px;
  transition: transform 0.3s, -webkit-transform 0.3s;
  pointer-events: none;
}
.virtual-selector__arrow svg {
  color: rgba(0, 0, 0, 0.25);
}
.virtual-selector__loading {
  position: absolute;
  top: 1px;
  left: 1px;
  width: calc(100% - 2px);
  height: 30px;
  line-height: 30px;
  font-size: 12px;
  text-align: center;
  color: rgba(0, 0, 0, 0.65);
  background-color: #fff;
}
.virtual-selector__input-wrapper--active input {
  border-color: #0f48b3;
  /*box-shadow: 0 0 0 2px rgba(15, 72, 179, 0.2);*/
}
.virtual-selector__input-wrapper--active .virtual-selector__arrow {
  transform: rotate(180deg);
}
.virtual-selector__input-wrapper--active .virtual-selector__dropdown {
  display: block;
}
.virtual-selector__dropdown {
  display: none;
  position: absolute;
  min-width: 100%;
  padding: 4px 0;
  margin: 5px 0 0;
  border-radius: 2px;
  box-sizing: border-box;
  line-height: 1.5;
  list-style: none;
  font-size: 14px;
  background-color: #fff;
  color: rgba(0, 0, 0, 0.65);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  outline: none;
  z-index: 1050;
  overflow-y: auto;
  transform: translateZ(0px);
}
.virtual-selector__scroller {
  max-height: 252px;
}
.virtual-selector__dropdown-item {
  display: block;
  padding: 5px 12px;
  line-height: 22px;
  font-weight: 400;
  font-size: 14px;
  color: rgba(0, 0, 0, 0.65);
  text-align: left;
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
  cursor: pointer;
  transition: background 0.3s ease;
}
.virtual-selector__dropdown-item:hover {
  background-color: #dae7f2;
}
.virtual-selector__dropdown-item--selected {
  font-weight: 600;
  background-color: #fafafa;
}
</style>

抛出插件VirtualSelector.vue插件

//创建index.js
//写入
import VirtualSelector from '@/components/virtualSelector/Selector/virtualSelector'

const VirSelector = {
  install(Vue) {
    Vue.component('virtual-selector', VirtualSelector)
    Vue.component('VirtualSelector', VirtualSelector)
  }
}

export default VirSelector

挂载全局插件VirSelector

//main.js
import VirSelector from '@/components/virtualSelector/index'

Vue.use(VirSelector)

封装使用vue-virtual-selector

//vue-virtual-selector.vue
//可以拿去用了
<template>
  <virtual-selector
    :loading="loading"
    label=""
    :placeholder="placeholder"
    v-model="selectedvalue"
    :list="list"
    :option="listOption"
    @focus="handleFocus"
    @search="handleSearch"
    @select="handleSelect">
    <div slot="loading">loading...</div>
    <div slot="item" slot-scope="{ item }">
      <span>{{ item[listOption.itemNameKey] }}</span>
    </div>
  </virtual-selector>
</template>

<script>
export default {
  name: 'VirSelector',
  mixins: [],
  filters: {},
  model: {
    prop: 'value', //绑定的值,通过父组件传递
    event: 'update' //自定义名
  },
  components: {},
  props: {
    value: {
      required: true,
      type: [String, Number],
      default: ''
    },
    list: {
      required: true,
      type: Array,
      default: () => []
    },
    listOption: {
      required: false,
      type: Object,
      default: () => {
        return {
          itemNameKey: 'label',
          itemValueKey: 'key',
          itemPageSize: 8,
          itemGap: 5
        }
      }
    },
    placeholder: {
      required: false,
      type: [String, Number],
      default: ''
    }
  },
  data() {
    return {
      loading: false,
      selectedvalue: {}
    }
  },
  computed: {},
  watch: {},
  created() {
    //如果value没值,有带选项,提示不存在
    if (!this.value && this.list.length && !this.placeholder) {
      //给value一个默认值
      this.selectedvalue = this.list[0]
      this.$emit('update', this.selectedvalue[this.listOption.itemValueKey])
    } else {
    //如果v-model有值,则匹配
      this.selectedvalue = this.list.find(item => {
        return item[this.listOption.itemNameKey] === this.value || item[this.listOption.itemValueKey] === this.value
      })
    }
  },
  mounted() {
  },
  destroyed() {
  },
  methods: {
    //点击
    handleFocus({ id, focus }) {
      console.log('focus : ', { id, focus })
    },
    //搜索
    handleSearch({ id, search }) {
      console.log('search : ', { id, search })
      this.$emit('update', search[this.listOption.itemValueKey])
      this.selectedvalue = search
    },
    //选择
    handleSelect({ id, select }) {
      console.log('select : ', { id, select })
      this.$emit('update', select[this.listOption.itemValueKey])
      this.selectedvalue = select
    }
  }
}
</script>

<style rel="stylesheet/scss" lang="scss" scoped></style>

完成了一个独立插件

image.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值