拒绝卡顿,element select 组件虚拟滚动优化
不知道大家在开发过程中有没有遇到这样一个场景,后端接口一次性返回上千条数据(比如国家地区),接口不支持分页,不能筛选,只能前端自己通过 select 组件全量渲染出来。这种渲染大量 DOM 的场景下会造成页面非常卡顿,我在网上搜索了一下一般有两种解决方案
- 前端自己实现数据分页效果
- 虚拟滚动,比如 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-key
,data-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