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

83 阅读5分钟

1. 业务背景

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

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

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

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

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

2. useAsyncButton

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

  1. 首先创建一个自定义 Hook useAsyncButton
typescript
 体验AI代码助手
 代码解读
复制代码
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:
typescript
 体验AI代码助手
 代码解读
复制代码
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呢?

typescript
 体验AI代码助手
 代码解读
复制代码
async () => {
  // 这里是你的接口请求
  const response = await fetch('your-api-endpoint');
  const data = await response.json();
  return data;
}

3. onSuccess

确实我们可以直接在调用 run() 后通过 .then()await 来获取数据。提供 onSuccess 回调主要有以下几个原因:

  1. 关注点分离
tsx
 体验AI代码助手
 代码解读
复制代码
// 不使用 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. 统一错误处理
tsx
 体验AI代码助手
 代码解读
复制代码
// 不使用 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. 自动重试场景
tsx
 体验AI代码助手
 代码解读
复制代码
const { run } = useAsyncButton(fetchData, {
  onSuccess: (data) => setData(data),
  onError: (error) => {
    if (retryCount < 3) {
      retryCount++;
      run(); // 可以在失败时自动重试
    }
  }
});
  1. 状态联动
tsx
 体验AI代码助手
 代码解读
复制代码
const { run } = useAsyncButton(fetchData, {
  onSuccess: (data) => {
    setData(data);
    // 可能需要触发其他请求
    refetchRelatedData();
    // 或更新其他状态
    setOtherState(true);
  }
});

所以,虽然你完全可以不使用 onSuccess 回调,但它能帮助你:

  • 更好地组织代码结构
  • 统一管理成功/失败处理逻辑
  • 方便进行状态联动
  • 在需要扩展功能时更加灵活

选择使用与否取决于你的具体需求,如果是简单的场景,直接使用 await run() 也完全可以。

4. 禁止一段时间内点击

评论区有人说了,我要是想在某一段时间内防止重复点击怎么整?

我们可以扩展 useAsyncButton 的功能,添加一个防冷却时间(cooldown)的特性。这在一些特定场景下很有用,比如发送验证码按钮需要等待 60 秒才能再次点击:

typescript
 体验AI代码助手
 代码解读
复制代码
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
  };
}

使用示例:

typescript
 体验AI代码助手
 代码解读
复制代码
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/749864…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。