拒绝卡顿,element select 组件虚拟滚动优化

不知道大家在开发过程中有没有遇到这样一个场景,后端接口一次性返回上千条数据(比如国家地区),接口不支持分页,不能筛选,只能前端自己通过 select 组件全量渲染出来。这种渲染大量 DOM 的场景下会造成页面非常卡顿,我在网上搜索了一下一般有两种解决方案

  1. 前端自己实现数据分页效果
  2. 虚拟滚动,比如 element plus 就有专门的 select 虚拟滚动组件

最好的方案当然时直接使用成熟的轮子,奈何我们的项目是 vue 2.7,所以只能借助支持 vue2 的虚拟滚动组件 vue-virtual-scroll-list,自己封装一个 select 虚拟滚动组件

组件实现

首先在项目中引入 vue-virtual-scroll-list

1npm i vue-virtual-scroll-list

接下来开发封装虚拟滚动组件,因为使用的是 vue2.7 版本,为了以后项目升级 vue3,所以直接使用 composition api 的方式开发。在 el-select 组件内部引入安装好的 vue-virtual-scroll-list,定义好组件的基础结构,和需要传入的 props 属性

1<template> 2 <el-select 3 v-model="value" 4 v-bind="$atts" 5 v-on="$listeners" 6 > 7 <virtual-scroll-list 8 ref="virtualListRef" 9 ></virtual-scroll-list> 10 </el-select> 11</template> 12 13<script setup> 14 import VirtualScrollList from 'vue-virtual-scroll-list' 15 16 const props = defineProps({ 17 // 当前 18 value: { 19 type: [String, Number], 20 default: '', 21 }, 22 // 下拉展示的 options 23 options: { 24 type: Array, 25 default: () => [], 26 }, 27 // label 键值 28 labelKey: { 29 type: String, 30 default: 'label', 31 }, 32 // value 键值 33 valueKey: { 34 type: String, 35 default: 'value', 36 }, 37 }) 38 39 const { value, options, labelKey, valueKey } = toRefs(props) 40 41 const virtualListRef = ref(null) 42</script> 43

根据官网文档描述,有三个必填属性,data-key, data-sources, data-component,我们可以直接选取 value 作为唯一的 data-keydata-sources 就是我们传入的 options,data-component 需要我们将 el-option 封装为一个独立的组件

| 属性 | 是否必填 | 默认值 | 类型 | 描述 | | ---------------- | -------- | ------ | ------------------ | :------------------------------------------------------------------------------------------- | | data-key | 必填 | | String | Function | 虚拟滚动列表每一项的唯一 id,如果是函数的话需要返回 string | | data-sources | 必填 | | Array[Object] | 虚拟滚动的数据列表,每个数组项必须是对象,并且每个对象必须有一个唯一的属性与 data-key 匹配 | | data-component | 必填 | | Component | 虚拟滚动每一项的渲染组件 | | keeps | 非必填 | 30 | Number | 虚拟列表展示的真实 DOM 的数量 | | extra-props | 非必填 | | Object | 传递给子组件的额外参数 |

我们先将 el-option 封装为一个独立组件,需要注意的是 vue-virtual-scroll-list 默认传入是数组是 source,所以需要从 source 属性中根据 labelKey 和 valueKey 找到需要加载的 label 和 value

1<template> 2 <el-option 3 :key="value" 4 :label="label" 5 :value="value" 6 v-bind="$atts" 7 v-on="$listeners" 8 /> 9</template> 10 11<script setup> 12 import { computed, toRefs } from 'vue' 13 14 const props = defineProps({ 15 source: { 16 type: Object, 17 default: () => {}, 18 }, 19 valueKey: { 20 type: [String, Number], 21 default: '', 22 }, 23 labelKey: { 24 type: String, 25 default: '', 26 }, 27 }) 28 29 const { source, valueKey, labelKey } = toRefs(props) 30 31 const value = computed(() => source.value[valueKey.value]) 32 const label = computed(() => source.value[labelKey.value]) 33</script>

接着在父组件中引入封装的 el-option 组件,这里我们取名为 OptionNode,然后传入 data-key, data-sources, data-component 三个必填属性,同时将 labelKey 和 valueKey 通过 extra-props 属性传递给子组件。vue-virtual-scroll-list 组件需要显式设置列表的高度和滚动条,不然在元素过多时会出现列表过长的情况,同时为了配合项目,这里我将 keeps 属性设置为 20,也就是只渲染 20 个真实 DOM 节点

1<template> 2 <fe-select 3 v-model="value" 4 v-bind="$atts" 5 v-on="$listeners" 6 > 7 <virtual-scroll-list 8 ref="virtualListRef" 9+ class="virtual-scroll-list" 10+ :data-key="dataKey" 11+ :data-sources="allOptions" 12+ :data-component="OptionNode" 13+ :keeps="20" 14+ :extra-props="{ 15+ labelKey, 16+ valueKey, 17+ }" 18 ></virtual-scroll-list> 19 </fe-select> 20</template> 21 22<script setup> 23 import { toRefs, ref, nextTick, computed, watch, onMounted } from 'vue' 24 import VirtualScrollList from 'vue-virtual-scroll-list' 25+ import OptionNode from './option-node.vue' 26 27 const props = defineProps({ 28 value: { 29 type: [String, Number], 30 default: '', 31 }, 32 options: { 33 type: Array, 34 default: () => [], 35 }, 36 labelKey: { 37 type: String, 38 default: 'label', 39 }, 40 valueKey: { 41 type: String, 42 default: 'value', 43 }, 44 }) 45 46 const { value, options, labelKey, valueKey } = toRefs(props) 47 48 const virtualListRef = ref(null) 49 50+ const dataKey = ref(valueKey) 51+ const allOptions = options.value 52</script> 53 54+ <style lang="scss" scoped> 55+ .virtual-scroll-list { 56+ height: 200px; 57+ overflow: auto; 58+ } 59+ </style> 60

这个时候就可以初步实现虚拟列表滚动的效果了,但由于虚拟滚动仅渲染部分 DOM,所有还有两个问题需要考虑

  • 保存了选择项后,再二次加载时,需要显示保存项,这时需要从完整 options 中找到保存项并放在列表最上面
  • 筛选列表选项时,需要从完整 options 找到符合要求的选项并加载,同时在关闭列表时,需要重置列表

针对以上两个问题,我们引入一个 currentOptions 记录当前的 options,通过 remote-method 实现搜索效果,通过 visible-change 事件实现重置列表。经过优化后的代码如下

1<template> 2 <fe-select 3 v-model="value" 4+ filterable 5+ remote 6+ :remote-method="handleRemoteMethod" 7 v-bind="$atts" 8+ @visible-change="handleVisiableChange" 9 v-on="$listeners" 10 > 11 <virtual-scroll-list 12 ref="virtualListRef" 13 class="virtual-scroll-list" 14 :data-key="dataKey" 15 :data-sources="currentOptions" 16 :data-component="OptionNode" 17 :keeps="20" 18 :extra-props="{ 19 labelKey, 20 valueKey, 21 }" 22 ></virtual-scroll-list> 23 </fe-select> 24</template> 25 26<script setup> 27 import { toRefs, ref, nextTick, computed, watch, onMounted } from 'vue' 28 import VirtualScrollList from 'vue-virtual-scroll-list' 29 import OptionNode from './option-node.vue' 30+ import { cloneDeep, isNil } from 'lodash-es' 31 32 const props = defineProps({ 33 value: { 34 type: [String, Number], 35 default: '', 36 }, 37 options: { 38 type: Array, 39 default: () => [], 40 }, 41 labelKey: { 42 type: String, 43 default: 'label', 44 }, 45 valueKey: { 46 type: String, 47 default: 'value', 48 }, 49 }) 50 51 const { value, options, labelKey, valueKey } = toRefs(props) 52 53 const virtualListRef = ref(null) 54 55 const dataKey = ref(valueKey) 56 57+ // 当前筛选的 options 58+ const currentOptions = ref([]) 59+ // 全量 options 60+ // 注意这里需要深拷贝 61+ const allOptions = computed(() => cloneDeep(options.value)) 62 63+ onMounted(() => { 64+ handleInitOptions(allOptions.value, value.value) 65+ }) 66 67+ watch([value, options], ([newVal, newOptions], [_oldVal, oldOptions]) => { 68+ // 异步加载 options 时,如果 value 有值,需要将 value 对应的 option 放在第一位 69+ if ((!isNil(newVal) || newVal !== '') && newOptions.length > 0 && oldOptions.length === 0) { 70+ handleInitOptions(newOptions, newVal) 71+ } 72+ }) 73 74+ /** 75+ * @description 因为 DOM 不是全量加载,所以需要手动处理 76+ */ 77+ function handleRemoteMethod(query) { 78+ if (query !== '') { 79+ currentOptions.value = allOptions.value.filter((item) => { 80+ return item[labelKey.value].includes(query) 81+ }) 82+ } else { 83+ currentOptions.value = allOptions.value 84+ } 85+ } 86 87+ function handleVisiableChange(val) { 88+ // 隐藏下拉框时,重置数据 89+ if (!val) { 90+ virtualListRef.value && virtualListRef.value.reset() 91+ nextTick(() => { 92+ currentOptions.value = allOptions.value 93+ }) 94+ } 95+ } 96 97+ /** 98+ * @description 异步加载 options 时,如果 value 有值,需要将 value 对应的 option 放在第一位 99+ */ 100+ function handleInitOptions(allOptions, value) { 101+ const existOption = allOptions.find((item) => { 102+ return item[valueKey.value] === value 103+ }) 104+ if (existOption) { 105+ currentOptions.value.push(existOption) 106+ } 107 108+ currentOptions.value.push( 109+ ...allOptions.filter((item) => { 110+ return item[valueKey.value] !== value 111+ }) 112+ ) 113+ } 114</script> 115 116<style lang="scss" scoped> 117 .virtual-scroll-list { 118 height: 200px; 119 overflow: auto; 120 } 121</style> 122

总结

最后我们来看一下最终实现效果,可以看到在实际选项有 10000 个情况下,每次渲染出来的 DOM 只有 20 个,而且不论是滚动还是查询都丝滑流畅,完成的组件封装代码和示例我也放在 github 上(链接),欢迎大家点个 star