按钮点击回调的节流处理
一、应用场景
有这么一些可能发生连续点击的场景:
- 极快的连击:用户可能以为只点击了一次但是实际上发生了快速的连击。
- 较快的连击:用户实际上是比较慢的触发了连续的点击,但是因为比如接口返回的速度比较快导致虽然请求有带loading动画但是请求很快就结束了loading也就随之结束了然后用户又点了下按钮,或者请求本身就没带loading,用户在请求还没结束前就可能又点了下按钮。
- 很慢的连击:用户点击后去上了个厕所,界面还停留在刚才的地方,上完厕所回来后忘记了刚才是否有点击过按钮了,于是又点击了一下。
第三种情况从按钮层面去处理会涉及到一个变量状态的维护,而且这种场景很大情况下已经不能算前端程序问题了。如果要处理这种情况,建议是从接口层面去做接口地址和参数组合的重复性判断,如果相同接口和最近一次的请求触发的参数一样,并且上一次请求是成功的,可以弹框提示用户进行二次确认后再提交。这个弹框的确定和取消按钮对应一个promise的resolve和reject,就可以直接封装到ajax请求方法里了。
所以,本文不考虑很慢的连击的场景。
二、节流函数
直接抛代码。这段代码的入参和setTimeout的入参是反过来的,先传时间再传回调函数(也可以不传时间直接传回调函数,这样会用一个默认的时间)。然后如果传入的函数的返回值是Promise的实例,则await等待一下它,同时也await一个promise化的timeout,当其中一个被reject或者两个都被resolve时才允许下一轮的执行(也就是说如果回调函数是一个接口请求的promise,我们传的时间入参是2000毫秒,那么如果接口返回速度很快,用户也要在2秒后才能触发下一轮的执行,不容易触发连击)。
/**
* 对函数进行节流处理(可以只传回调函数)
* @param lazyMilliseconds [number] 延迟时间(单位:毫秒),可以不传
* @param fn [function] 回调函数
* @returns {(function(): Promise<void>)}
*/
const DEFAULT_LAZY_MILLISECONDS = 2000;
export const throttle = (lazyMilliseconds, fn) => {
// 参数预处理
if (typeof lazyMilliseconds === 'function') {
fn = lazyMilliseconds;
lazyMilliseconds = DEFAULT_LAZY_MILLISECONDS;
}
lazyMilliseconds = lazyMilliseconds || DEFAULT_LAZY_MILLISECONDS;
// 参数校验
if (typeof fn !== 'function') {
throw new Error(`slow函数的入参fn应为函数,但是实际收到的是${typeof fn}类型的入参`);
}
if (typeof lazyMilliseconds !== 'number') {
throw new Error(`slow函数的入参lazyMilliseconds应为数字,但是实际收到的是${typeof lazyMilliseconds}类型的入参`);
}
let timer = null;
const context = this;
// 返回包装后的函数
return async function() {
if (timer) return;
const returnVal = fn.apply(context, Array.from(arguments));
// 如果返回值是Promise实例,在其还处于pending状态时不应该允许二次触发fn函数
if (returnVal instanceof Promise) {
timer = 1024; // 随便给个数字
try {
/**
* 当promise结束且指定的等待时间已过,才允许下一次执行fn
* 1、当lazyMilliseconds时间已到,但是promise的pending状态还未结束时,也禁止下一次执行fn
*/
const timeoutPromise = new Promise(resolve => setTimeout(resolve, lazyMilliseconds));
// 当returnVal被reject时,或者returnVal和timeoutPromise都被resolve时,Promise.all的状态才会变成reject或者resolve
await Promise.all([returnVal, timeoutPromise]);
} finally {
timer = null;
}
return;
}
// 如果返回值不是promise就正常按设定的延迟时间来
timer = setTimeout(() => {
if (timer) {
clearTimeout(timer);
timer = null;
}
}, lazyMilliseconds);
};
};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
这其实就是一个高阶函数(返回函数的函数)。跟节流按钮组件相比,这种方式的适用性更广。比如纯文本内容的确认框,其实我们是不用常规的显示组件的方式去写的,那些写起来太繁琐——毕竟使用的地方太多了。而且对比按钮组件,在重构老项目时,使用节流函数也会更方便一些。举个例子,我们可能会这样封装确认框:
Vue.prototype.$doConfirm = params => {
let message;
let title;
let options;
if (typeof params === 'string') {
message = params;
} else {
message = params.message;
title = params.title;
options = params.options;
}
return new Promise(resolve => {
Vue.prototype
.$confirm(message, title === undefined ? '提示' : title, options || {})
.then(() => resolve(true))
.catch(() => resolve(false));
});
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这样在调用侧(Vue组件里)只需要这么写:
{
methods: {
async confirmToDoSomething() {
if (!(await this.$doConfirm('确定要这么做吗,老铁?'))) return;
// 点击确定按钮之后的逻辑
await IEatThis();
await YouEatThat();
await SpNothingForHim();
},
},
}2
3
4
5
6
7
8
9
10
11
这种要改成使用按钮组件是比较麻烦的,但是要改成使用节流函数就很容易,像下面这样:
{
methods: {
async confirmToDoSomething() {
if (!(await this.$doConfirm('确定要这么做吗,老铁?'))) return;
// 点击确定按钮之后的逻辑
this.yesIConfirmIWillDoThat();
},
yesIConfirmIWillDoThat: throttle(async function () {
await IEatThis();
await YouEatThat();
await SpNothingForHim();
}),
},
}2
3
4
5
6
7
8
9
10
11
12
13
14
三、节流按钮组件
这个组件封装得逻辑有点多,所以看起来会有点复杂。可以先跳过代码看后面的思路说明。
<template>
<el-button :type="type" :size="size" :class="{ 'is-disabled': disabledAfterTokenUsed }" @click="onClick">
<slot></slot>
<span v-show="timerCountingDown && isDelayTimeIntegralSecondsNoLessThan1Second">({{ countingDown }}s)</span>
</el-button>
</template>
<script>
import { getTypeBoolean, getTypeNumber, getTypeString } from 'blablabla1 /这个不是真实包名/';
import { apiGetToken } from 'blablabla2 /这个不是真实包名/';
export default {
name: 'ThrottleButton',
props: {
// 禁用时间,传0或不传表示不延迟
delay: getTypeNumber(),
defaultToken: getTypeString(),
type: getTypeString({ default: 'default' }),
size: getTypeString({ default: 'small' }),
getTokenOnMount: getTypeBoolean(),
},
data() {
return {
// 防连续点击触发逻辑的计时器
timer: null,
// 防网络抖动用的token
token: this.defaultToken || '',
// 消耗掉token时,一定时间内按钮呈禁用状态
timerDisabledAfterTokenUsed: null,
disabledAfterTokenUsed: false,
timerCountingDown: null,
countingDown: 0,
};
},
computed: {
// 延迟时间是否为大于等于2秒的整数秒
isDelayTimeIntegralSecondsNoLessThan1Second() {
return this.delay > 1000 && this.delay / 1000 === Math.round(this.delay / 1000);
},
},
watch: {
token: {
immediate: true,
handler(newVal, oldVal) {
if (!newVal && oldVal) {
if (this.timerDisabledAfterTokenUsed) return;
this.disabledAfterTokenUsed = true;
this.timerDisabledAfterTokenUsed = setTimeout(() => {
this.clearTimerDisabledAfterTokenUsed();
this.disabledAfterTokenUsed = false;
}, this.delay || 0);
// 如果延迟时间大于等于2秒,且为整数秒,则展示一个倒计时按钮
if (this.isDelayTimeIntegralSecondsNoLessThan1Second) {
this.startCountingDown();
}
}
},
},
},
beforeDestroy() {
this.clearTimer();
this.clearTimerDisabledAfterTokenUsed();
this.clearTimerCountingDown();
},
mounted() {
if (this.getTokenOnMount) {
this.refreshToken();
}
},
methods: {
startCountingDown() {
if (this.timerCountingDown) return;
this.countingDown = this.delay / 1000;
this.timerCountingDown = setInterval(() => {
this.countingDown--;
if (this.countingDown <= 0) {
this.clearTimerCountingDown();
}
}, 1000);
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
},
clearTimerDisabledAfterTokenUsed() {
if (this.timerDisabledAfterTokenUsed) {
clearTimeout(this.timerDisabledAfterTokenUsed);
this.timerDisabledAfterTokenUsed = null;
}
},
clearTimerCountingDown() {
if (this.timerCountingDown) {
clearTimeout(this.timerCountingDown);
this.timerCountingDown = null;
}
},
onClick() {
if (!this.delay) {
this.triggerClick();
return;
}
if (this.disabledAfterTokenUsed) return;
if (this.timer) return;
this.timer = setTimeout(this.clearTimer, this.delay);
// 如果延迟时间大于等于2秒,且为整数秒,则展示一个倒计时按钮
if (this.isDelayTimeIntegralSecondsNoLessThan1Second) { this.startCountingDown(); }
this.triggerClick();
},
triggerClick() {
this.$emit('click');
},
refreshToken() {
const token = await apiGetToken({});
this.token = token;
return token;
},
getToken() {
const returnToken = this.token;
// token只允许访问一次
this.token = '';
// 如果被读取时token不存在,说明使用上出问题了
if (!returnToken) this.$message.error('网络异常,请退出或刷新页面后重试');
return returnToken;
},
},
};
</script>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
这个组件支持传入delay属性表示按钮组件需要节流的时间间隔,比如传入300,则在300毫秒的时间段内只有第一次点击会生效。
点击按钮后如果触发请求,会使用token(我们的场景是后续触发的接口请求中需要带上这个token,这种变量我不建议直接在页面业务代码里维护,所以封装到组件里了,这个方案我是不赞成的,但是生活中到处都有需要妥协的地方——你可以把这里的token换成一个布尔值的flag开关用来保证按钮默认情况下只能被使用一次,稍加修改下逻辑都能走通)。这个token在触发请求时会被消耗掉,这样可以保证按钮只能被使用一次,如果需要按钮可以多次操作,则在触发请求之后从调用侧手动刷新token即可。
在delay指定时间内按钮会呈现禁用状态,并且如果delay指定的时间是一个大于等于2秒的整数秒,按钮上会呈现出一个倒计时的状态。
上文中getTypeNumber、getTypeString等函数返回的其实就是Vue里对prop属性的类型声明配置,由于项目里很多地方都会用到,直接像下面这样写的话,是会对代码阅读代码一定的心智负担的(打开一个文件一看代码行数这么长就容易有抵触心理,这里减少一些行数,那里减少一些行数,最后对代码阅读体验的提升还是很客观的):
props: {
value: {
type: String,
default: '',
},
// ...
},2
3
4
5
6
7
这个封装的具体实现如下(这个不是本文重点,不关心的话可以直接忽略):
/**
* 处理传给本文件中其他方法的入参
* 允许传类似这样的数据结构进来:'required'、{ required: true }、['required']、['required', { default: 0 }]
* @param params {string|object|string[]|(string|object)[]}
* @returns {object}
*/
const transformParams = (params = {}) => {
if (!params) return {};
if (typeof params === 'string') {
if (params === 'required') return { required: true };
throw new Error(`params参数${params}错误,请仔细检查`);
}
if (Array.isArray(params)) {
return params.reduce((pre, cur) => {
return { ...pre, ...transformParams(cur) };
}, {});
}
if (typeof params === 'object') return params;
throw new Error(`params参数${params}错误,请仔细检查`);
};
const generateGetTypeFunc = (defaultOpt = {}) => (additionalOpt = {}) => ({
...defaultOpt,
...transformParams(additionalOpt),
});
export const getTypeBoolean = generateGetTypeFunc({ type: Boolean, default: false });
export const getTypeString = generateGetTypeFunc({ type: String, default: '' });
export const getTypeArray = generateGetTypeFunc({ type: Array, default: () => [] });
export const getTypeNumber = generateGetTypeFunc({ type: Number, default: 0 });
export const getTypeObject = generateGetTypeFunc({ type: Object, default: () => {} });
export const getTypeArrayOrNull = generateGetTypeFunc({
validator(value) {
return Array.isArray(value) || value === null;
},
default: () => null,
});
export const getTypeStringOrArray = generateGetTypeFunc({
validator(value) {
return Array.isArray(value) || typeof value === 'string';
},
default: () => '',
});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