虚拟列表实战封装el-select

·  阅读 3960

本文核心内容:

  1. 核心解决中后台系统,当同时存在多个大数据量的下拉列表导致页面卡顿、无法响应的问题。
  2. 解决方案采用的是虚拟列表,基于vue2.5 + element2.0对el-select进行了一层封装,实现el-select下拉的虚拟列表。

相信点进这里的童鞋,可能都遇到了同一个问题,并在寻找合适的解决方案。话不多说,直接进入主题吧~

一、背景讲解

1.简单讲述背景

  • 最近接的需求,一个后端哥们捣鼓的前端项目。页面中一个有很多筛选框的组件,通过切换一定的条件拉取不同的下拉数据(就是下面贴图的组件),一个很常见的中台交互行为。
  • 下拉数据的获取方式比较特殊,是通过:爬一个ssr页面中的内容获取。具体行为大概是通过正则匹配出一个变量名获取一个js对象字符串,大概是总大小30多k的一个字符串吧,最后用evalnew Function等方式把字符串变成正常的js对象。

2.问题排查:

  • 根据某个操作路径操作,发现页面卡顿,基本是卡崩状体,任何ui交互无反应,只能等卡顿完才有响应。由以上表象,从:

    1. js线程阻塞问题。监控performance面板的js执行时间,未发现明显js执行慢占据线程问题,也使用了worker,把字符串转为js对象的那段代码用woker执行未发现改善。
    2. 内存排查。一开始将js字符串转换为js对象时采用的方法是直接用eval(str)的方案,从网上搜到相关eval多次执行内存释放的问题、还有eval的直接执行和间接执行(可参考:这篇关于eval的文章),尝试后也没有改善。
    3. dom节点问题。由于之前做项目的时候,有遇到过整个页面卡死的现象,引起的原因就是瞬间大量的dom操作,导致浏览器这个tab的进程卡顿。尝试把dom的部分注释掉,js执行部分保留后发现,页面交互丝滑得难以置信。好吧,这就破案了~

3.简单分析:

  • 使用 document.getElementsByTagName('*').length 分析当前页面的dom数量
  • 如下图,这些select会根据不同条件获取数据,默认第一项时候的dom数

  • 如下图,切换了组件类型的条件后,dom数量剧增

  • 由此可见,dom数量变得巨大,主要集中在地区、运营商这种下拉数据中,浏览器一次渲染了大量的dom。这也是导致后续再切换条件,整个浏览器tab卡到失去响应的罪魁祸首(浏览器进行大量的dom的增删操作)。

二、解决方案

1.使用element提供的远程搜索 remote-method

  • 远程搜索绝对是解决这类问题的成本相对低的方案了。但是讲述背景里也介绍到了,这个项目的数据是爬ssr页面拿下来了,不能直接改造成远程加载的方式获取数据。
  • 前端实现远程搜索。把拉回来的数据存在内存,通过 filter-method 自定义搜索,模拟远程搜索。但是这样可能存在一个问题,搜索到一个重复度高的keyword,也会导致一次性加载过多的数据,所以也被弃用了。

2.对el-select封装一层,实现虚拟列表

  • 虚拟列表绝对是前端优化的一个利器,能解决很多页面的性能问题,本文实现虚拟列表的方案是使用 交叉监视器 IntersectionObserver + padding

  • 具体虚拟列表实现可参考:虚拟列表无限滚动

  • 说说组件实现思路吧:

    1. 在el-select中添加子元素 <li class="start" /> 和 <li class="end" />,作为显示区的开始结束标志,用 IntersectionObserver 对其进行监听

    2. 通过 v-if 控制,根据当前的 startIndex 和 endIndex 控制元素是否插入dom。即 nowIndex >= startIndex && nowIndex < endIndex

    3. 通过计算一个li的高度,乘上 startIndex 充当滚动列表的 padding-top,让上方淡出可视区被销毁的dom元素有一个占位空间,保证滚动列表的正常,一次实现具体的虚拟滚动。

    4. 最后贴张效果图

三、开箱即用

  • 直接贴出整个组件的代码~ 给到正真需要的开发小伙伴哈

  • 使用方法完全跟正常使用element的select组件一样,只是不需要自己完成el-optionv-for步骤。接入后可直接参考element2.0的select使用文档。传入数组:[ { label: '', value: '' } ]即可。如需定制化,可以传入一个arrange的function自己实现

  • <template>
      <el-select
        ref="elSelectRef"
        v-model="proxyValue"
        :filter-method="selectFilter"
        v-bind="$attrs"
        v-on="$listeners"
        @visible-change="handleVisible"
      >
        <li class="start" />
        <template v-for="(item, i) in optionsDuplicate">
          <el-option
            v-if="isRender(i)"
            ref="elOptionItem"
            :key="item.value + i"
            :label="item.label"
            :value="item.value"
          />
        </template>
        <li class="end" />
      </el-select>
    </template>
    
    <script>
      import cloneDeep from 'lodash.clonedeep'
       // 列表最大渲染总数
      const maxRender = 60
      // 超出后更新的列数。比如第一屏到底之后,会删掉前30条并最后补30条
      const refreshRender = 30 
      let listItemHeight = 34, // 默认每项list高度
          fatherUlDomNormalPaddingTop = 6 // 默认下拉ul的paddingTop
    
      export default {
        name: "BaseSelect",
        props: {
          options: {
            type: Array,
            default () {
              return []
            }
          },
          value: {
            type: [String, Array, Number],
            default () {
              return []
            }
          }
        },
        data () {
          return {
            observer: Object.create(null),
            startIndex: 0,
            optionsDuplicate: [],
            fatherUlDom: Object.create(null)
          }
        },
        computed: {
          proxyValue: {
            get () {
              return this.value
            },
            set (val) {
              this.$emit('update:value', val)
            }
          },
          // 判断当前项是否符合插入DOM的条件
          isRender () {
            return i => i >= this.startIndex && i < this.endIndex
          },
          // 通过startIndex + 一屏渲染数计算当前最大显示的index
          endIndex () {
            return this.startIndex + maxRender
          }
        },
        watch: {
          options (val) {
            this.optionsDuplicate = cloneDeep(val)
            this.$nextTick(_ => this.handleVisible(true))
          }
        },
        methods: {
          selectFilter (enterStr) {
            this.initData()
            if (!enterStr) {
              this.optionsDuplicate = this.options
              return
            }
            this.optionsDuplicate = this.options.filter(item => item.label.includes(enterStr))
          },
          handleVisible (isVisible) {
            if (!isVisible || !this.$refs.elOptionItem) {
              this.observer.disconnect && this.observer.disconnect()
              return
            }
    
            this
              .initData()
              .initObserve()
          },
          initData () {
            this.startIndex = 0
            this.fatherUlDom.style && (this.fatherUlDom.style.paddingTop = fatherUlDomNormalPaddingTop + 'px')
    
            return this
          },
          initObserve () {
            this.$nextTick(() => {
              const listDomVm = this.$refs.elOptionItem[0]
              if (!listDomVm) return
    
              const listDom = this.$refs.elOptionItem[0].$el
              listItemHeight = listDom.offsetHeight || listItemHeight
              this.fatherUlDom = listDom.parentElement
              fatherUlDomNormalPaddingTop = parseFloat(window.getComputedStyle(this.fatherUlDom).paddingTop) || fatherUlDomNormalPaddingTop
              // 在elSelect实例中找到下拉的DOM
              const dropDownDomVm = this.$refs.elSelectRef.$children.find(_ => _.$el.className.includes('el-select-dropdown'))
              if (!dropDownDomVm) return
              const dropDownDom = dropDownDomVm.$el
              // 获取开始和结尾的<li>的DOM
              const [startDom, endDom] = [dropDownDom.querySelector('.start'), dropDownDom.querySelector('.end')]
              // IntersectionObserver实现观测首、尾元素是否进入可视区
              this.observer = new IntersectionObserver((entries) => {         
                if (entries.length >= 2) return // 避免在元素交替删除的瞬间,start、end同时进入可视区导致出现逻辑问题
                const dom = entries[0] // 取出第一个做判断
                if (!dom.isIntersecting) return
                console.log(dom.intersectionRatio, dom)
                if (dom.target === endDom) {
                  // 向下滚动
                  console.log('first', this.startIndex)
                  const resultIndex = this.startIndex + refreshRender
                  // 通过变更starIndex来触发computed中的endIndex和isRender来更改当前可渲染的DOM
                  this.startIndex = resultIndex > this.optionsDuplicate.length ? this.startIndex : resultIndex
                  console.log('second', this.startIndex)
                } else {
                  // 向上滚动
                  const resultIndex = this.startIndex - refreshRender
                  this.startIndex = resultIndex < 0 ? 0 : resultIndex
                }
                // 计算顶部的padding以撑开内容。用当前startIndex * 每个list的高度 + 初始Ul的padding
                this.fatherUlDom.style.paddingTop = this.startIndex * listItemHeight + fatherUlDomNormalPaddingTop + 'px' // 填充高度
              })
              this.observer.observe(startDom)
              this.observer.observe(endDom)
            })
          }
        }
      }
    </script> 
    复制代码
  • 对于封装element组件的小心得,如果想获取到对应组件的对应dom元素,直接在element的实例中取,比如上面代码中,要找当前el-select的对应下拉框,都不是直接用dom的api去获取的,而是在这个组件实例中的$children获取

  • 关于这种padding的无法覆盖的业务需求:

    1. list中item内容不等高。如果这种场景该组件不能很好使用,若不是硬性需求,可从布局上进行item的高度限制,不折行且溢出隐藏
  • 完工啦~试用之后完美解决了页面卡顿的问,且业务功能正常不受影响。如果用起来有发现什么bug可以反馈哈,我会持续优化跟进~

  • 最后,如果你有什么更好建议,方案,赶紧告诉我,让小弟学习学习~😄

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改