如何优雅的防止按钮重复点击
1. 业务背景
在前端的业务场景中:点击按钮,发起请求。在请求还未结束的时候,一个按钮可以重复点击,导致接口重新请求多次(如果后端不做限制)。轻则浪费服务器资源,重则业务逻辑错误,尤其是入库操作。
传统解决方案:使用防抖函数,但是无法解决接口响应时间过长的问题,当接口一旦响应时间超过防抖时间,测试单身20年的手速照样还是可以点击多次。
更稳妥的方式:给button添加loadng,只有接口响应结果后才能再次点击按钮。需要在每个使用按钮的页面逻辑中单独维护loading变量,代码变得臃肿。
那如果是在react项目中,这种问题有没有比较优雅的解决方式呢?
vue项目解决方案参考:juejin.cn/post/749541…
2. useAsyncButton
在 React 项目中,对于这种按钮重复点击的问题,可以使用自定义 Hook 来优雅地处理。以下是一个完整的解决方案:
- 首先创建一个自定义 Hook
useAsyncButton
:
import { useState, useCallback } from 'react';
interface RequestOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export function useAsyncButton<T>(
requestFn: (...args: any[]) => Promise<T>,
options: RequestOptions = {}
) {
const [loading, setLoading] = useState(false);
const run = useCallback(
async (...args: any[]) => {
if (loading) return; // 如果正在加载,直接返回
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, requestFn, options]
);
return {
loading,
run
};
}
- 在组件中使用这个 Hook:
import { useAsyncButton } from '../hooks/useAsyncButton';
const MyButton = () => {
const { loading, run } = useAsyncButton(async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}, {
onSuccess: (data) => {
console.log('请求成功:', data);
},
onError: (error) => {
console.error('请求失败:', error);
}
});
return (
<button
onClick={() => run()}
disabled={loading}
>
{loading ? '加载中...' : '点击请求'}
</button>
);
};
export default MyButton;
这个解决方案有以下优点:
- 统一管理:将请求状态管理逻辑封装在一个 Hook 中,避免重复代码
- 自动处理 loading:不需要手动管理 loading 状态
- 防重复点击:在请求过程中自动禁用按钮或阻止重复请求
- 类型安全:使用 TypeScript 提供类型检查
- 灵活性:可以通过 options 配置成功/失败的回调函数
- 可复用性:可以在任何组件中重用这个 Hook
useAsyncButton
直接帮你进行了try catch
,你不用再单独去做异常处理。
是不是很简单?有的人可能有疑问了,为什么下方不就能拿到接口请求以后的数据吗?为什么还需要onSuccess呢?
async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}
3. onSuccess
确实我们可以直接在调用 run()
后通过 .then()
或 await
来获取数据。提供 onSuccess
回调主要有以下几个原因:
- 关注点分离:
// 不使用 onSuccess
const { run } = useAsyncButton(async () => {
const response = await fetch('/api/data');
return response.json();
});
const handleClick = async () => {
const data = await run();
// 处理数据的逻辑和请求逻辑混在一起
setData(data);
message.success('请求成功');
doSomethingElse(data);
};
// 使用 onSuccess
const { run } = useAsyncButton(async () => {
const response = await fetch('/api/data');
return response.json();
}, {
onSuccess: (data) => {
// 数据处理逻辑被清晰地分离出来
setData(data);
message.success('请求成功');
doSomethingElse(data);
}
});
const handleClick = () => {
run(); // 更清晰的调用方式
};
- 统一错误处理:
// 不使用 callbacks
const handleClick = async () => {
try {
const data = await run();
setData(data);
} catch (error) {
// 每个地方都需要写错误处理
message.error('请求失败');
}
};
// 使用 callbacks
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => setData(data),
onError: (error) => message.error('请求失败')
// 错误处理被集中管理
});
- 自动重试场景:
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => setData(data),
onError: (error) => {
if (retryCount < 3) {
retryCount++;
run(); // 可以在失败时自动重试
}
}
});
- 状态联动:
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => {
setData(data);
// 可能需要触发其他请求
refetchRelatedData();
// 或更新其他状态
setOtherState(true);
}
});
所以,虽然你完全可以不使用 onSuccess
回调,但它能帮助你:
- 更好地组织代码结构
- 统一管理成功/失败处理逻辑
- 方便进行状态联动
- 在需要扩展功能时更加灵活
选择使用与否取决于你的具体需求,如果是简单的场景,直接使用 await run()
也完全可以。
4. 禁止一段时间内点击
评论区有人说了,我要是想在某一段时间内防止重复点击怎么整?
我们可以扩展 useAsyncButton
的功能,添加一个防冷却时间(cooldown)的特性。这在一些特定场景下很有用,比如发送验证码按钮需要等待 60 秒才能再次点击:
import { useState, useCallback, useRef } from 'react';
interface AsyncButtonOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
cooldown?: number; // 冷却时间(毫秒)
}
export function useAsyncButton<T>(
requestFn: (...args: any[]) => Promise<T>,
options: AsyncButtonOptions = {}
) {
const [loading, setLoading] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
const timerRef = useRef<NodeJS.Timeout>();
const startCooldown = useCallback(() => {
if (!options.cooldown) return;
setCooldownRemaining(options.cooldown / 1000);
const startTime = Date.now();
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.ceil((options.cooldown! - elapsed) / 1000);
if (remaining <= 0) {
clearInterval(timerRef.current);
setCooldownRemaining(0);
} else {
setCooldownRemaining(remaining);
}
}, 1000);
}, [options.cooldown]);
const run = useCallback(
async (...args: any[]) => {
if (loading || cooldownRemaining > 0) return;
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
startCooldown();
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, cooldownRemaining, requestFn, options, startCooldown]
);
return {
loading,
cooldownRemaining,
run,
disabled: loading || cooldownRemaining > 0
};
}
使用示例:
import { useAsyncButton } from '../hooks/useAsyncButton';
const SendCodeButton = () => {
const { loading, cooldownRemaining, disabled, run } = useAsyncButton(
async () => {
// 发送验证码的接口请求
const response = await fetch('/api/send-code');
return response.json();
},
{
cooldown: 60000, // 60秒冷却时间
onSuccess: () => {
console.log('验证码发送成功');
},
onError: (error) => {
console.error('验证码发送失败', error);
}
}
);
return (
<button
onClick={() => run()}
disabled={disabled}
>
{loading ? '发送中...' :
cooldownRemaining > 0 ? `${cooldownRemaining}秒后重试` :
'发送验证码'}
</button>
);
};
export default SendCodeButton;
来源:juejin.cn/post/7498646341460787211