实现select下拉搜索,无限滚动(项目)


实现select下拉搜索,无限滚动

xxxxx改造下拉列表-大数据量渲染

实现select下拉搜索 无限动-虚拟列表一封成指令–性能优化

  1. 取得可见区域的可见列表项数量
  2. 取得可见区域的起始数据索引和结束数据索引
  3. 计算出可见区域对应的数据,让 Vue.js 更新
  4. 把可见区域的 top 设置为起始元素在整个列表中的位置(使用 transform 是为了更好的性能)

二次封装el-select实现下拉滚动加载

平时我们做业务需求的时候,可能会遇到非常大量的数据,有时候成百上千条,一般后端都会写一个分页的接口,只要我们请求的时候加上页码参数即可。
但是在使用element-ui的el-select下拉菜单组件中,官方没有提供相应的方法进行多页加载。
这时候我们可以实现一个Vue的自定义指令directive,每当使用el-select滚动到列表底部的时候就请求下一页数据,来达到下拉滚动加载更多的目的。
实现自定义指令
首先实现一个el-select下拉加载的自定义指令v-loadmore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// directives.js
import Vue from 'vue'

Vue.directive("loadmore", {
bind(el, binding, vnode) {
const SELECTWRAP = el.querySelector(
".el-select-dropdown .el-select-dropdown__wrap"
);
SELECTWRAP.addEventListener("scroll", function () {
// scrollTop 这里可能因为浏览器缩放存在小数点的情况,导致了滚动到底部时
// scrollHeight 减去滚动到底部时的scrollTop ,依然大于clientHeight 导致无法请求更多数据
// 这里将scrollTop向上取整 保证滚到底部时,触发调用
const CONDITION = this.scrollHeight - Math.ceil(this.scrollTop) <= this.clientHeight;
// el.scrollTop !== 0 当输入时,如果搜索结果很少,以至于没看到滚动条,那么此时的CONDITION计算结果是true,会执行bind.value(),此时不应该执行,否则搜索结果不匹配
if (CONDITION && this.scrollTop !== 0) {
binding.value();
}
});
},
});

代码说明:

document.querySelector:querySelector() 方法仅仅返回匹配指定选择器的第一个元素。
Element.scrollHeight:在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度(只读)。
Element.scrollTop:获取或设置一个元素的内容垂直滚动的像素数。
Element.clientHeight:读取元素的可见高度(只读)。
如果元素滚动到底,下面等式返回true,没有则返回false。

1
element.scrollHeight - element.scrollTop === element.clientHeight

在项目中全局注册v-loadmore指令:

1
2
3
4
// main.js

import directives from './directive.js'
Vue.use(directives)

最后在组件el-select中使用该指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!-- vue.js -->

<template>
<el-select v-model="selected" v-loadmore="loadMore">
<el-option
v-for="option in options"
:label="option.label"
:value="option.value"
:key="option.value"
></el-option>
</el-select>
</template>

<script>
export default {
data() {
return {
selected: "",
options: [
{
label: "1",
value: 1
},
// ... 此处省略多个选项
{
label: "到达底部啦",
value: 9
}
]
};
},
methods: {
loadMore() {
console.log("more")
}
}
};

使用效果如下:
下拉加载
从效果图可以看出,每当菜单列表滚动到底部时,指令就会调用传入的loadMore函数,控制台随即打印出 “more”。

注意事项:
传入的数组个数必须大于或者等于8个选项时才能让el-select组件出现下拉滚动。
列表里不存在滚动时,无法触发传入指令的函数。

进行二次封装

滚动到底部调用函数的指令已经实现了,下面只要调用接口,把获取到下一页的数据拼接到当前的数据中即可。

接下来把el-select进行二次封装,封装成公用的组件之后,传入必要的参数就可以在项目中调用。

首先新建一个文件load-select.vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!-- load-select.vue -->

<template>
<el-select :value="value" v-loadmore="loadMore" @focus="focus" v-bind="$attrs" v-on="$listeners">
<el-option
v-for="option in data"
:label="option[dictLabel]"
:value="option[dictValue]"
:key="option.value"
></el-option>
</el-select>
</template>

<script>
export default {
props: {
value: {
type: String,
default: ""
},
// 列表数据
data: {
type: Array,
default: () => []
},
dictLabel: {
type: String,
default: "label"
},
dictValue: {
type: String,
default: "value"
},
// 调用页数的接口
request: {
type: Function,
default: () => {}
},
page: {
type: [Number, String],
default: 1
}
},
data() {
return {};
},
methods: {
// 请求下一页的数据
loadMore() {
this.request({ page: this.page + 1 })
},
// 选中下拉框没有数据时,自动请求第一页的数据
focus() {
if (!this.data.length) {
this.request({page: 1})
}
}
}
};
</script>

在页面组件中调用load-select.vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<!-- page.vue -->

<template>
<div class="xxx-page">
<load-select v-model="selected" :data="data" :page="page" :request="getData"></load-select>
</div>
</template>

<script>
// 导入该组件
import loadSelect from "@/components/load-select/index";

export default {
name: "app",
components: {
loadSelect
},
data() {
return {
selected: "",
page: 1,
more: true,
data: []
};
},
methods: {
// 传入给load-select组件的函数
getData({ page = 1 } = {}) {
// 输出页数
console.log(page)
// 访问后端接口API
this.requestAPI({ page }).then(res => {
this.data = [...this.data, ...res.result]
this.page = res.page
});
},
// 模拟后端接口的API
requestAPI({ page = 1, size = 10 } = {}) {
return new Promise(resolve => {
let responseData = []
// 假设总共的数据有50条
let total = 50;
for (let index = 1; index <= size; index++) {
// serial:处于第几个元素,就显示多少序号
let serial = index + (page - 1) * size
if (serial <= 50) {
responseData.push({
label: serial,
value: serial
});
}
}
// 模拟异步请求,500ms之后返回接口的数据
setTimeout(() => {
resolve({
total,
page,
size,
result: responseData
});
}, 500);
});
}
}
};
</script>

代码解析:

首次点击下拉框时,会触发focus事件请求第一页的数据,之后只要每次滚动列表到底部,就会自动请求下一页的数据然后拼接到当前的数组中。

我们来看看效果:
测试下拉组件
完美!但是在实际使用的过程中,可能会因为接口还来不及返回数据,然后列表又向下滚动再次触发了请求,结果就是返回了两份相同的数据。

现在把接口的延迟调到2000ms重现这个场景:

重复数据

在两次快速滚动到底部的时候,请求的参数页数都是2,如何解决这个问题?可以在加载函数中加入一个拦截操作,在接口没有响应之前,不调用加载函数,不过这样做要把getData转换成异步函数的形式。

首先在load-select.vue中的loadMore()中加入一个拦截操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- load-select.vue -->

<template>
...
</template>

<script>
// 请求下一页的数据
methods: {
loadMore() {
// 如果 intercept 属性为 true 则不请求数据
if (this.loadMore.intercept) {
return
}
this.loadMore.intercept = true
this.request({ page: this.page + 1 }).then(() => {
// 接口响应之后才把 intercept 设置为 false
this.loadMore.intercept = false
})
}
}
</script>

然后在page.vue中的getData()函数转换成异步函数的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- page.vue -->

<template>
...
</template>

<script>
methods: {
// 传入给load-select组件的函数
getData({ page = 1 } = {}) {
// 返回 Promise 对象
return new Promise( resolve => {
// 访问后端接口API
this.requestAPI({ page }).then(res => {
this.data = [...this.data, ...res.result]
this.page = res.page
resolve()
});
})
},

}
</script>

现在问题来了:

一般分页的接口都支持关键字的搜索,load-select.vue组件能不能加入关键字搜索的功能呢?

关键字搜索功能

还好el-select组件支持远程搜索功能,只要传入filterable和remote参数,具体的可以查看element-ui的官方文档。

接下来对load-select.vue进行以下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<!-- load-select.vue -->

<template>
<el-select
:value="value"
v-loadmore="loadMore"
@focus="focus"
filterable
remote
:filter-method="handleSearch"
:loading="loading"
clearable
v-bind="$attrs"
v-on="$listeners"
>
<el-option
v-for="option in data"
:label="option[dictLabel]"
:value="option[dictValue]"
:key="option.value"
></el-option>
<!-- 此处加载中的value可以随便设置,只要不与其他数据重复即可 -->
<el-option v-if="hasMore" disabled label="加载中..." value="-1"></el-option>
</el-select>
</template>

<script>
export default {
props: {
value: {
default: ""
},
// 列表数据
data: {
type: Array,
default: () => []
},
dictLabel: {
type: String,
default: "label"
},
dictValue: {
type: String,
default: "value"
},
// 调用页数的接口
request: {
type: Function,
default: () => {}
},
// 传入的页码
page: {
type: [Number, String],
default: 1
},
// 是否还有更多数据
hasMore: {
type: Boolean,
default: true
}
},
data() {
return {
// 存储关键字用
keyword: "",
loading: false
};
},
methods: {
// 请求下一页的数据
loadMore() {
// 如果没有更多数据,则不请求
if (!this.hasMore) {
return
}
// 如果intercept属性为true则不请求数据,
if (this.loadMore.intercept) {
return
}
this.loadMore.intercept = true;
this.request({
page: this.page + 1,
more: true,
keyword: this.keyword
}).then(() => {
this.loadMore.intercept = false
});
},
// 选中下拉框没有数据时,自动请求第一页的数据
focus() {
if (!this.data.length) {
this.request({ page: 1 })
}
},
// 关键字搜索
handleSearch(keyword) {
this.keyword = keyword
this.loading = true
this.request({ page: 1, keyword }).then(() => {
this.loading = false
});
},
// 删除选中时,如果请求了关键字,则清除关键字再请求第一页的数据
clear() {
if (this.keyword) {
this.keyword = ""
this.request({ page: 1 })
}
}
}
};
</script>

页面调用时,getData()请求函数需要接收keyword和more参数并进行相应的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<!-- page.vue -->

<template>
<div class="xxx-page">
<load-select v-model="selected" :data="data" :page="page" :hasMore="more" :request="getData"></load-select>
</div>
</template>

<script>
// 导入该组件
import loadSelect from "@/components/load-select/index";

export default {
name: "app",
components: {
loadSelect
},
data() {
return {
selected: "",
page: 1,
more: true,
data: []
};
},
methods: {
// 传入给load-select组件的函数
getData({ page = 1, more = false, keyword = "" } = {}) {
return new Promise(resolve => {
// 访问后端接口API
this.requestAPI({ page, keyword }).then(res => {
// 如果是加载更多,则合并之前的数据
if (more) {
this.data = [...this.data, ...res.result]
} else {
this.data = res.result
}

this.page = res.page;
let { total, page, size } = res
// 如果为最后一页,则设置more为false
this.more = page * size < total
this.page = page
resolve()
});
});
},
// 模拟后端接口的API
requestAPI({ page = 1, size = 10, keyword = "" } = {}) {
return new Promise(resolve => {
// 如果有 keyword 参数,则返回带有 keyword 的数据
if (keyword) {
setTimeout(() => {
resolve({
total: 3,
page: 1,
size: 10,
result: [
{
label: keyword,
value: 1
},
{
label: keyword + 1,
value: 2
},
{
label: keyword + 2,
value: 3
}
]
})
}, 500)
return
}

let responseData = [];
// 假设总共的数据有50条
let total = 50;
for (let index = 1; index <= size; index++) {
// serial:处于第几个元素,就显示多少序号
let serial = index + (page - 1) * size
if (serial <= 50) {
responseData.push({
label: serial,
value: serial
});
}
}
setTimeout(() => {
resolve({
total,
page,
size,
result: responseData
})
}, 500)
})
}
}
};
</script>

接下来看看搜索关键字的效果:关键字搜索 搜索功能也完成啦!

总结
为了适用于大部分的请求接口,因此在设计这个组件的时候只能把请求与组件剥离开来,易用程度不算太高,不过我们可以适当地传入一些简单必要的参数去维持基本地使用。
当然,在项目中遇到某些固定的加载请求时,我们也可以对该组件进行再次封装,具体可以根据自身的业务需求进行修改。

vue对el-autocomplete二次封装增加下拉分页 (参考文章)

项目中的联想输入框现在都是采用的el-autocomplete实现的,但是随着数据量越来越多,产品要求一次不要返回所有的联想数据,要做分页处理,所以需要添加一个分页的功能。

注:看懂下面的代码需要先对vue和element有一定的学习。

废话不多数,先上完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
<template>
<el-autocomplete
ref="autocomplete"
value-key="value"
v-scrollLoad="selectLoadMore"
v-loading="loading"
v-model="state"
:fetch-suggestions="querySearch"
:placeholder="placeholder"
:trigger-on-focus="false"
@select="handleSelect"
></el-autocomplete>
</template>

<script>
export default {
name: 'InputLoadMore',
props: {
// 封装的查数据方法
getOptionFn: {
require: true
},
// 后端定义的联想的key
searchKey: {
type: String,
require: true
},
// v-model的绑定值
value: {
type: String,
require: true
},
// placehoder
placeholder: {
type: String,
default: '请输入'
}
},
data() {
return {
state: '',
loading: false,
page: 1,
pageTotal: 0
}
},
watch: {
state(val) {
this.$emit('input', val)
},
value(val) {
this.state = val
}
},
directives: {
// 自定义指令,监听下拉框的滚动,滚动到底部就加载下一页
scrollLoad: {
bind(el, binding, vnode) {
let wrapDom = el.querySelector('.el-autocomplete-suggestion__wrap')
let listDom = el.querySelector('.el-autocomplete-suggestion__wrap .el-autocomplete-suggestion__list')
wrapDom.addEventListener(
'scroll',
e => {
// 注意load的使用,节流
let condition = wrapDom.offsetHeight + wrapDom.scrollTop + 10 - listDom.offsetHeight
if (condition > 0 && !vnode.context.loading) {
//滚动到底部则执行滚动方法load,binding.value就是v-scrollLoad绑定的值,加()表示执行绑定的方法
binding.value()
}
},
false
)
}
}
},
methods: {
async querySearch(queryString, cb) {
this.page = 1
this.loading = true
try {
let { result } = await this.getOptionFn({
page: 1,
pageSize: 50,
[this.searchKey]: queryString
})
// 根据实际情况修改下面的代码,展示数据
if (result.rows) {
let arr = []
result.rows.forEach(item => {
arr.push({ value: item })
})
cb(arr)
} else {
cb([])
}
this.pageTotal = result.total || 0
} catch(e) {
// console.log(e)
} finally {
this.loading = false
}
},
handleSelect(item) {},
// 加载更多
async selectLoadMore() {
if(Number(this.pageTotal) <= this.$refs['autocomplete'].$data.suggestions.length) {
return
}
this.page = this.page + 1
this.loading = true
try {
let { result } = await this.getOptionFn({
page: this.page,
pageSize: 50,
[this.searchKey]: this.state
})
// 根据实际情况修改下面的代码,展示数据
if (result.rows) {
const arr = result.rows.map(item => {
return { value: item }
})
// 将数据添加到下拉列表
this.$refs['autocomplete'].$data.suggestions = this.$refs['autocomplete'].$data.suggestions.concat(arr)
}
this.pageTotal = result.total || 0
} catch(e) {
// console.log(e)
} finally {
this.loading = false
}
}
}
}
</script>

下面对主要的地方进行讲解。

1.自定义指令实现下拉加载更多。

主要代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 自定义指令,监听下拉框的滚动,滚动到底部就加载下一页
scrollLoad: {
bind(el, binding, vnode) {
let wrapDom = el.querySelector('.el-autocomplete-suggestion__wrap')
let listDom = el.querySelector('.el-autocomplete-suggestion__wrap .el-autocomplete-suggestion__list')
wrapDom.addEventListener(
'scroll',
e => {
// 注意load的使用,节流
let condition = wrapDom.offsetHeight + wrapDom.scrollTop + 10 - listDom.offsetHeight
if (condition > 0 && !vnode.context.loading) {
//滚动到底部则执行滚动方法load,binding.value就是v-scrollLoad绑定的值,加()表示执行绑定的方法
binding.value()
}
},
false
)
}

上面主要是运用了vue的自定义指令的bind钩子。不太了解的可以先看这个官方文档 。bind有四个参数(el、binding、vnode、oldVnode)这里用前三个,el代表绑定的元素,用来操作dom,这里用来添加scroll事件,以及计算下拉框是否滑动到底部(注意计算中的+10高度);binding是一个对象,包含旧值、新值、指令名等,这里主要用绑定值value,用来执行加载更多的方法;Vnode指的是虚拟节点,这里取他的context即为this控制loading来节流。

2.增加props(getOptionFn、searchKey、value、placeholder)抽离业务。成为公共组件
  1. getOptionFn为接口封装的方法。fetch-suggestions和加载更多里面都要用到
  2. searchKey表示接口需要传的参数的key,不同的接口的key可能不一致。
  3. value是外面v-modle的绑定值,注意在watch里面设置值,不知道的可以看看v-model的实现原理。
  4. placeholder不解释
3.可能需要解释的

在加载到更多数据后怎么把输入加到下拉里面?

1
this.$refs['autocomplete'].$data.suggestions // 下拉的列表

怎么避免加载完了还加载更多。
这里是用的数量比较,也可以加一个标识符,加载完了设置为true,变化条件后设为false。

1
2
3
if(Number(this.pageTotal) <= this.$refs['autocomplete'].$data.suggestions.length) {
return
}

vue对el-autocomplete二次封装增加下拉分页
el-autocomplete实现滚动分页加载的方法
ElementUI el-dropdown 如何实现下拉框出现“触发元素”样式修改

使用 transform 是为了更好的性能(重绘回流)

transform
通过以下performance调试可以看出时间也有差异,使用transform会比top少一个layout延时(因为使用top引起了回流)。
transform

为什么使用transform偏移比使用定位top性能更好?重绘、回流是什么?

居中为什么用transform,而不是margin top/left

首先。我们了解下transform是干嘛的。

在MDN中的官方解释:CSS transform属性允许你旋转,缩放,倾斜或平移给定元素。这是通过修改CSS视觉格式化模型的坐标空间来实现的。简言之,transform可以操作一些动画、位移效果。

margin top/left,我们应该很熟悉,用得比较多。

那为什么说,居中显示,CSS3标准的transform更胜一筹呢?我们主要还是从浏览器渲染的性能方面考虑。

  1. 浏览器渲染过程

我们知道,浏览器中有JS引擎和渲染引擎,对于HTML页面的渲染就靠渲染引擎来完成。下面是chrome浏览器页面渲染的整体过程图:
居中为什么用transform,而不是margin top/left

从上面的流程图中不难看出,Chrome渲染主要包括Parse Html、Recalculate Style、Layout、Paint、Image Decode、Image Resize和Composite Layers等。相对应的中文表述就是:html解析、查找并计算样式、排布、绘制、图片解码、图片大小设置、合并图层并输出页面到屏幕。浏览器最终渲染出来的页面,跟Photoshop有点类似,是由多个图层合并而来。

  1. transform的原理:

transform是通过创建一个RenderLayers合成层,拥有独立的GraphicsLayers。每一个GraphicsLayers都有一个Graphics Context,其对应的RenderLayers会paint进Graphics Context中。合成器(Compositor)最终会负责将由Graphics Context输出的位图合并成最终屏幕展示的图案。
满足如下条件的RenderLayers,会被认为是一个独立的合成层:

  • 有3D或者perspective transform的CSS属性的层
  • video元素的层
  • canvas元素的层
  • flash
  • 对opacity和transform应用了CSS动画的层
  • 使用了CSS滤镜(filters)的层
  • 有合成层后代的层
  • 同合成层重叠,且在该合成层上面(z-index)渲染的层
    如果RenderLayer是一个合成层,那么它有属于它自己的单独的GraphicsLayer,否则它和它的最近的拥有GraphicsLayer的父layer共用一个GraphicsLayer。

由此可见,transform发生在Composite Layer这一步,它所引起的paint也只是发生在单独的GraphicsLayer中,并不会引起整个页面的回流重绘。

  1. GPU

我们经常会听到GPU会加速渲染,那GPU在这里又扮演什么角色呢?

前面说到,合成器会负责将层合成绘制为最终的屏幕画面。在硬件加速体系结构,合成由GPU负责。在chrome浏览器多进程模型中,有一个专门的进程来负责传递Render进程的命令,即GPU进程。Render进程和GPU进程是通过共享内存传递的。

Render进程可以快速 的将命令发给命令缓冲区,并且返回到CPU密集的render活动中,留给GPU进程去处理这些命令。我们可以充分利用多内核机器上的GPU进程和CPU进程。这也是为什么GPU会加速渲染,使transform渲染速度更快的又一原因。

  1. margin top/left

marign:外边距,定义元素周围的空间;简言之,可以改变元素的位移。在浏览器页面渲染的时候,margin可以控制元素的位置,也就是说,改变margin,就会改变render tree的结构,必定会引起页面layout回流和repaint重绘

因此,从浏览器性能考虑,transform会比margin更省时间。

但是,transform真的处处适用吗?

  1. transform的局限性

上面提到,transform实际上也是用到了GPU加速,也就是说占用了内存。由此可见创建GraphicsLayer,虽然节省了layout,paint阶段,但Layer创建的越多,占用内存就会越大,而过多的渲染开销会超过性能的改善。

因此,当且仅当需要的时候,才会为元素创建渲染层。
居中为什么用transform,而不是margin top/left