外观
有这么一些可能发生连续点击的场景:
第三种情况从按钮层面去处理会涉及到一个变量状态的维护,而且这种场景很大情况下已经不能算前端程序问题了。如果要处理这种情况,建议是从接口层面去做接口地址和参数组合的重复性判断,如果相同接口和最近一次的请求触发的参数一样,并且上一次请求是成功的,可以弹框提示用户进行二次确认后再提交。这个弹框的确定和取消按钮对应一个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);
};
};这其实就是一个高阶函数(返回函数的函数)。跟节流按钮组件相比,这种方式的适用性更广。比如纯文本内容的确认框,其实我们是不用常规的显示组件的方式去写的,那些写起来太繁琐——毕竟使用的地方太多了。而且对比按钮组件,在重构老项目时,使用节流函数也会更方便一些。举个例子,我们可能会这样封装确认框:
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));
});
};这样在调用侧(Vue组件里)只需要这么写:
{
methods: {
async confirmToDoSomething() {
if (!(await this.$doConfirm('确定要这么做吗,老铁?'))) return;
// 点击确定按钮之后的逻辑
await IEatThis();
await YouEatThat();
await SpNothingForHim();
},
},
}这种要改成使用按钮组件是比较麻烦的,但是要改成使用节流函数就很容易,像下面这样:
{
methods: {
async confirmToDoSomething() {
if (!(await this.$doConfirm('确定要这么做吗,老铁?'))) return;
// 点击确定按钮之后的逻辑
this.yesIConfirmIWillDoThat();
},
yesIConfirmIWillDoThat: throttle(async function () {
await IEatThis();
await YouEatThat();
await SpNothingForHim();
}),
},
}这个组件封装得逻辑有点多,所以看起来会有点复杂。可以先跳过代码看后面的思路说明。
<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>这个组件支持传入delay属性表示按钮组件需要节流的时间间隔,比如传入300,则在300毫秒的时间段内只有第一次点击会生效。
点击按钮后如果触发请求,会使用token(我们的场景是后续触发的接口请求中需要带上这个token,这种变量我不建议直接在页面业务代码里维护,所以封装到组件里了,这个方案我是不赞成的,但是生活中到处都有需要妥协的地方——你可以把这里的token换成一个布尔值的flag开关用来保证按钮默认情况下只能被使用一次,稍加修改下逻辑都能走通)。这个token在触发请求时会被消耗掉,这样可以保证按钮只能被使用一次,如果需要按钮可以多次操作,则在触发请求之后从调用侧手动刷新token即可。
在delay指定时间内按钮会呈现禁用状态,并且如果delay指定的时间是一个大于等于2秒的整数秒,按钮上会呈现出一个倒计时的状态。
上文中getTypeNumber、getTypeString等函数返回的其实就是Vue里对prop属性的类型声明配置,由于项目里很多地方都会用到,直接像下面这样写的话,是会对代码阅读代码一定的心智负担的(打开一个文件一看代码行数这么长就容易有抵触心理,这里减少一些行数,那里减少一些行数,最后对代码阅读体验的提升还是很客观的):
props: {
value: {
type: String,
default: '',
},
// ...
},这个封装的具体实现如下(这个不是本文重点,不关心的话可以直接忽略):
/**
* 处理传给本文件中其他方法的入参
* 允许传类似这样的数据结构进来:'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: () => "",
});