注册

如何优雅的防止按钮重复点击

1. 业务背景


在前端的业务场景中:点击按钮,发起请求。在请求还未结束的时候,一个按钮可以重复点击,导致接口重新请求多次(如果后端不做限制)。轻则浪费服务器资源,重则业务逻辑错误,尤其是入库操作。


传统解决方案:使用防抖函数,但是无法解决接口响应时间过长的问题,当接口一旦响应时间超过防抖时间,测试单身20年的手速照样还是可以点击多次。


更稳妥的方式:给button添加loadng,只有接口响应结果后才能再次点击按钮。需要在每个使用按钮的页面逻辑中单独维护loading变量,代码变得臃肿。


那如果是在react项目中,这种问题有没有比较优雅的解决方式呢?



vue项目解决方案参考:juejin.cn/post/749541…



2. useAsyncButton


在 React 项目中,对于这种按钮重复点击的问题,可以使用自定义 Hook 来优雅地处理。以下是一个完整的解决方案:



  1. 首先创建一个自定义 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
};
}


  1. 在组件中使用这个 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;

这个解决方案有以下优点:



  1. 统一管理:将请求状态管理逻辑封装在一个 Hook 中,避免重复代码
  2. 自动处理 loading:不需要手动管理 loading 状态
  3. 防重复点击:在请求过程中自动禁用按钮或阻止重复请求
  4. 类型安全:使用 TypeScript 提供类型检查
  5. 灵活性:可以通过 options 配置成功/失败的回调函数
  6. 可复用性:可以在任何组件中重用这个 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 回调主要有以下几个原因:



  1. 关注点分离

// 不使用 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(); // 更清晰的调用方式
};


  1. 统一错误处理

// 不使用 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('请求失败')
// 错误处理被集中管理
});


  1. 自动重试场景

const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => setData(data),
onError: (error) => {
if (retryCount < 3) {
retryCount++;
run(); // 可以在失败时自动重试
}
}
});


  1. 状态联动

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

0 个评论

要回复文章请先登录注册