el-select海量数据渲染-分页解决方案

场景描述

最近在使用 el-select 时,发现在选择项很多的情况下会带来明显的性能问题。

我们可以通过接下来这个例子复现下当时的场景,点击一个按钮打开modal,这个modal含有多个el-select,并且每个select都有海量数据选择项(此处是1w条数据)。

可以很明显的看到,当打开modal、操作select和关闭 modal 的时候由于同时渲染了海量数据,导致了操作时明显的卡顿感,当数据量越多,这种卡顿感会更明显。

通过性能面板中的数据可以看到,初次打开modal的时候竟然耗时2s以上,足以见得,在渲染海量数据的时候是非常影响用户体验的。

目前对于这种渲染海量数据的方案比较主流的解决方法就是分页查询、虚拟列表、时间分片,这些方法的核心原理就是把这个海量数据渲染的任务拆分成小的任务,降低浏览器的性能压力。

本篇主要介绍我最终选择的分页查询解决方案。

解决方案-分页查询

如果有后端来配合,分页查询将会是一个很好的选择,因为前端侧的处理将会很简单。

实现原理

选择框分页查询的主要实现原理如下:

  1. 后端实现并提供分页接口
  2. 通过自定义的 v-loadmore 指令,监听下拉加载事件
  3. 设置 popper-append-to-body 属性为 false,以便准确监听当前下拉框滚动事件来完成每次的分页查询

注意:一定要把 popper-append-to-body 属性设为 false,因为如果按照默认设置为 true 在滚动监听的时候会带来一系列问题,比如多个选择框时切换选择时监听失效。

v-loadmore 指令

通过编写一个 v-loadmore 指令来监听每次的下拉加载事件

js 复制代码
directives: {
  // 定义指令
  loadmore: {
    inserted(el, binding, vnode) {
      // 获取滚动容器dom
      let scrollWrap = el.querySelector('.el-select-dropdown .el-scrollbar .el-select-dropdown__wrap')
      // 把监听的句柄防抖一下
      const handle = utils.debounce((e) => {
        let scrollDistance = scrollWrap.scrollHeight - scrollWrap.scrollTop
        // 比如此处预留6个像素的位置用于触底
        if (scrollWrap.clientHeight + 6 > scrollDistance) {
          binding.value() // 触底通知一下,外界
        }
      }, 170)
      // 绑定监听滚动事件
      scrollWrap?.addEventListener('scroll', handle)
      // 把监听的句柄挂载到元素身上便于解绑时使用
      el._hanlde = handle
    },
    unbind() {
      // 获取滚动容器dom
      let scrollWrap = el.querySelector('.el-select-dropdown .el-scrollbar .el-select-dropdown__wrap')
      // 解绑
      scrollWrap?.removeEventListener('scroll', el._hanlde)
      // 清空
      delete el._hanlde;
    }
  }
}
html 复制代码
<el-select v-model="value" :popper-append-to-body="false" v-loadmore="loadmore">
  <el-option v-for="item in options" :label="item.label" :value="item.value">
  </el-option>
</el-select>

分页加载指令参考自:juejin.cn/post/731265...; 分页加载效果预览:ashuai.work:8888/#/selectDow...

请求分页接口的时机

加载选择框组件的时候并不需要加载分页接口,只需在下拉加载框显示 时以及下拉框滚动触底这两个时机去加载分页接口,也就是如下这两个时机。

  1. 下拉框显示
  2. 下拉框滚动触底
html 复制代码
<el-select @visible-change="handleVisibleChange" v-loadmore="loadmore">
  <el-option v-for="item in options" :label="item.label" :value="item.value">
  </el-option>
</el-select>
js 复制代码
// 下拉框显示
handleVisibleChange(visible) {
  this.selectVisible = visible
  if (visible) {
    this.getOptions()
  }
},
// 触底了,继续发请求
loadmore() {
  if (!this.selectVisible) return
  this.pageNo++
  this.getOptions()
},

回显问题

在编辑选择框信息时,由于需要选项当中存在当前选择的数据才会正常回显的,通过每次渲染部分数据去优化选择框可能会导致在当前数据源不包含当前选择的数据,这就导致非首页数据回显可能会出现问题。

这个问题的核心在于当前的数据源不包含当前选择的数据,那就需要自己拼凑数据到数据选择框中。所以你需要一个可以查询到所有数据项的接口或者能够查询指定项的接口来获取你当前选择项的数据。

你可以通过以下笔者我冥思苦想流程图来解决回显的问题?那你就想多了哈哈哈哈(请忽略以下流程图,这个只是最初笔者想到的复杂的实现方式)。

解决问题的方法很简单!!!:在下拉框展示的时候使用分页数据,下拉框隐藏的时候使用回显数据

你只需要通过一个可见数据源的计算变量来巧妙地控制选择项:在下拉框可见时,直接返回分页数据,在下拉框隐藏时,仅加载当前选中数据。

js 复制代码
computed: {
  // 可见数据源(兼容处理分页加载选项框,非首页数据回显问题)
  visibleOptions() {
    // 下拉框可见 直接加载分页数据
    if (this.selectVisible || !this.value) return this.options
    if (!this.data.length) return []
    // 下拉框不可见 仅加载当前选中数据即可 this.data为所有的选择项数据
    return this.data.filter(item => item[`${this.valueKey}`] === this.value)
  }
},
html 复制代码
<el-select v-model="value">
  <el-option v-for="item in visibleOptions" :label="item.label" :value="item.value">
  </el-option>
</el-select>

完整代码展示

以下代码还包含远程搜索逻辑等,功能较为全面。

本文案例使用 vue2.x 实现,但核心原理都一样,其他框架如需使用请参考此代码自行转换逻辑

js 复制代码
Vue.extend({
  props: {
    // 已选择value
    value: {
      type: String,
      default: ''
    },
    // 数据源(全部)
    data: {
      type: Array,
      default: () => []
    },
    // 列表 label 键名
    labelKey: {
      type: String,
      default: 'label'
    },
    // 关键词键名
    keywordKey: {
      type: String,
      default: 'name'
    },
    // 列表 value 键名
    valueKey: {
      type: String,
      default: 'value'
    },
    // 获取分页数据方法(promise)
    getDataFn: {
      type: Function,
      default: () => { }
    },
    // 是否支持搜索
    filterable: {
      type: Boolean,
      default: false
    }
  },
  data: function () {
    return {
      options: [],
      value: '',
      pageNo: 1,
      pageSize: 20,
      loading: false,
      keyword: '',
      selectVisible: false
    }
  },
  computed: {
    // 可见数据源(兼容处理分页加载选项框,非首页数据回显问题)
    visibleOptions() {
      // 下拉框可见 直接加载分页数据
      if (this.selectVisible || !this.value) return this.options
      if (!this.data.length) return []
      // 下拉框不可见 仅加载当前选中数据即可
      return this.data.filter(item => item[`${this.valueKey}`] === this.value)
    }
  },
  methods: {
    handleVisibleChange(visible) {
      this.selectVisible = visible
      if (visible && !this.options.length) {
        this.getOptions()
      }
      if (!visible && this.keyword) {
        this.initData()
      }
    },
    initData() {
      this.keyword = ''
      this.pageNo = 1
      this.pageSize = 20
      this.options = []
    },
    async getOptions() {
      try {
        if (!this.getDataFn) return
        let content = await this.getDataFn({
          pageNo: this.pageNo,
          pageSize: this.pageSize,
          [`${this.keywordKey}`]: this.keyword
        })
        if (content.length == 0) {
          this.options.length >= this.pageSize && this.$message('已经到底了~')
          return
        }
        // 合并一下下拉框数据
        this.options = [...this.options, ...content]
      } catch (error) {
        console.log(error, 'err')
      }
    },
    // 触底了,继续发请求
    loadmore() {
      if (!this.selectVisible) return
      this.pageNo++
      this.getOptions()
    },
    // 远程搜索
    async remoteMethod(keyword) {
      try {
        this.loading = true
        this.keyword = keyword
        this.pageNo = 1
        this.options = []
        await this.getOptions()
      } finally {
        this.loading = false
      }
    }
  },
  directives: {
    // 定义指令-加载更多
    loadmore: {
      inserted(el, binding, vnode) {
        // 获取滚动容器dom
        let scrollWrap = el.querySelector('.el-select-dropdown .el-scrollbar .el-select-dropdown__wrap')
        // 把监听的句柄防抖一下
        const handle = utils.debounce((e) => {
          let scrollDistance = scrollWrap.scrollHeight - scrollWrap.scrollTop
          // 比如此处预留6个像素的位置用于触底
          if (scrollWrap.clientHeight + 6 > scrollDistance) {
            binding.value() // 触底通知一下,外界
          }
        }, 170)
        // 绑定监听滚动事件
        scrollWrap?.addEventListener('scroll', handle)
        // 把监听的句柄挂载到元素身上便于解绑时使用
        el._hanlde = handle
      },
      unbind(el) {
        // 获取滚动容器dom
        let scrollWrap = el.querySelector('.el-select-dropdown .el-scrollbar .el-select-dropdown__wrap')
        // 解绑
        scrollWrap?.removeEventListener('scroll', el._hanlde)
        // 清空
        delete el._hanlde;
      }
    }
  }
})
html 复制代码
<el-select v-model="value" ref="selectRef" popper-class="page-select-list" :popper-append-to-body="false"
  v-loadmore="loadmore" :filterable="filterable" :remote="filterable" :remote-method="remoteMethod"
  @visible-change="handleVisibleChange" :loading="loading" v-bind="$attrs" v-on="$listeners">
  <el-option v-for="item in visibleOptions" :label="item[`${labelKey}`]" :value="item[`${valueKey}`]">
  </el-option>
</el-select>

后记

本文仅作为一次性能优化实际解决的记录,如有问题欢迎留言讨论。

相关推荐
Java学长-kirito25 分钟前
springboot/ssm网购平台管理系统Java在线购物商城管理平台web电商源码
java·前端·spring boot
夫琅禾费米线30 分钟前
JavaScript 中的 Generator 函数及其方法
开发语言·前端·javascript
Traced back32 分钟前
pinia的使用
前端
世界和平�����1 小时前
vue3 命名式(函数式)弹窗
前端·javascript·vue.js
所遇所思1 小时前
vue项目中中怎么获取环境变量
前端·javascript·vue.js
ljklxlj1 小时前
webview4/edgewebbrower学习记录——执行js
前端·javascript·学习
潜龙在渊灬1 小时前
纯CSS实现无限轮播banner,这道题你解出来了吗?
前端·css·程序员
出逃日志1 小时前
前端框架Vue3的响应式数据,v-on,v-if,v-for,v-bind
前端·vue.js·前端框架
爱分享的码瑞哥2 小时前
利用正则表达式高效处理复杂HTML结构
前端·正则表达式·html
阿语!2 小时前
Vue生命周期详解
前端·vue.js