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>

后记

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

相关推荐
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte9 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc