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

·  阅读 2546
【面试】后端一次给你30万option待选项,页面无响应了,遇见这种沙雕你怎么办?

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

不想看实现过程的,可以直接c,v代码,就能用。

第一个构想就是虚拟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滚动

实现原理:在限定区域内只展示部分dome元素,随着滚动,动态计算截取数据,复用dom元素。

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

虽然作者 已经封装过virtual-selector插件,但是根本还是在virtual-scroller上做的二次封装,只完成了最基本的select功能,定制化不高,对c,v又特殊需求,想定制化的工程师并不友好。

索性自己重新封装,满足自由度,写满了注释。方便样式嫌丑或者功能不满足的工程师,自己改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>
      <div
        v-if="flist.length===0"
        class="virtual-selector__dropdown"
        style="text-align: center;"
      >
        <slot name="nodata"></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() {
      //防抖监控ipnut输入,并组装需要显示的内容
      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() {
      //通知上层封装,input输入了什么
      this.$emit('input', {
        [this.option.itemNameKey]: this.selected[this.option.itemNameKey]
      })
    },
    handleFocus(e) {
      //通知上层封装,当前点击的组件ID,它是那个
      e.target.offsetParent.classList.toggle(dropdownActiveClassName)
      this.$emit('focus', {
        id: this.vsId,
        focus: { event: e }
      })
    },
    handleInputSelect(e) {
      //框选input中的内容
      e.target.offsetParent.classList.add(dropdownActiveClassName)
    },
    handleItemSelect(e, item) {
      //返回用户选的option
      this.selected = {
        ...item,
        [this.option.itemNameKey]: e.target.offsetParent.innerText
      }
      this.$emit('select', {
        id: this.vsId,
        select: { ...this.selected }
      })
    },
    handleGlobalClick(e) {
      //监听页面点击事件,如果点击的不是组件,则清空组件的所有input 样式、关闭虚拟滚动弹窗
      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() {
    //挂载页面click监听
    this.mount()
  },
  beforeDestroy: function() {
    //卸载页面ckick监听
    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 #DCDFE6;
  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: #346fdf;
}

.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: #409eff;
  /*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: 5px;
  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__scroller::-webkit-scrollbar {
   width: 12px;
   height: 6px;
   background: transparent;
 }

.virtual-selector__scroller::-webkit-scrollbar-thumb {
   background: transparent;
   border-radius: 5px;
 }

.virtual-selector__scroller::-webkit-scrollbar-thumb {
   background: hsla(0, 0%, 53%, 0.4);
 }

.virtual-selector__scroller::-webkit-scrollbar-track {
   background: hsla(0, 0%, 53%, 0.1);
 }


/**/



.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;
  color: rgba(64, 158, 255, 1);
  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"
    @input="handleInput"
    @focus="handleFocus"
    @search="handleSearch"
    @select="handleSelect">
    <div slot="loading">loading...</div>
    <div slot="nodata">未找到匹配项</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 {
      this.selectedvalue = this.list.find(item => {
        return item[this.listOption.itemNameKey] === this.value || item[this.listOption.itemValueKey] === this.value
      })
    }
  },
  mounted() {
  },
  destroyed() {
  },
  methods: {
    handleInput(input){
      console.log('Input : ', input)
    },
    //点击
    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

修缮了一下没那么丑了

image.png

分类:
前端
标签:
分类:
前端
标签: