在移动端弱网环境或者服务器瞬时过载的场景下,一次网络请求失败往往并不代表这个请求永远无法成功。如果只是简单地提示用户“网络异常”,体验会非常糟糕。更合理的做法是自动重试,而重试策略里最经典的就是指数退避。这篇文章就来聊聊在React项目中如何把指数退避和无限重试机制落地,包括原理分析、几种实现方案的对比,以及一个可以直接复制使用的自定义Hook。

什么是指数退避,为什么固定间隔重试不够好
指数退避的核心思想很简单:每次重试的等待时间不是固定的,而是随着失败次数按指数级增长。第一次失败等1秒,第二次等2秒,第三次等4秒,第四次等8秒,以此类推。用公式表达就是delay = base * 2^retryCount,其中base是基础延迟时间。
为什么不直接固定每秒重试一次呢?假设服务端因为瞬时流量过大而拒绝请求,成百上千个客户端如果都在用固定间隔疯狂重试,等于在服务端最脆弱的时候持续加压,很可能把一次小故障放大成雪崩。指数退避让重试频率快速衰减,给服务端留出喘息恢复的时间窗口,这也是TCP拥塞控制、AWS SDK等基础设施普遍采用这一策略的原因。
纯粹的指数退避还有一个隐患:所有客户端的计算节奏是一样的,第n次失败后大家都会在同一时刻发起重试,形成周期性的请求尖峰。解决办法是引入随机抖动,在计算出的延迟基础上加一个随机偏移量,比如delay = base * 2^n + Math.random() * 1000,让各个客户端的重试时间点错开。这个小细节在用户量大的应用里非常关键。
在React中实现指数退避的三种方案对比
第一种方案是直接用setTimeout在组件里写重试逻辑。这种方式最直观,把请求封装成一个函数,在catch分支里判断失败次数并递归调用自己。它的缺点是逻辑散落在组件内部,无法复用,而且如果组件在等待期间被卸载,setTimeout回调依然会执行,容易造成内存泄漏和对已卸载组件调用setState的警告。
第二种方案是封装自定义Hook,比如useRetryRequest。这是React生态里最推荐的做法,把重试逻辑收敛到Hook内部,通过useRef保存重试计数器,通过useEffect的清理函数在组件卸载时清除定时器。任何组件只需要传入请求函数就能获得自动重试能力。
第三种方案是使用现成的库,比如axios配合axios-retry拦截器,或者react-query这类数据请求库自带的retry选项。react-query默认就采用指数退避策略,重试延迟默认是Math.min(1000 * 2 ** retryCount, 30000),也就是封顶30秒。如果项目本身已经在用react-query,直接配置retry次数即可,没必要重复造轮子。但如果只是简单的fetch请求且不想引入额外依赖,自定义Hook仍然是最轻量的选择。
手写一个支持无限重试的useRetryRequest Hook
下面给出一个完整的实现。这个Hook支持配置基础延迟、最大延迟封顶值、最大重试次数(不传或传Infinity即为无限重试),并且内置了随机抖动和组件卸载清理逻辑。
import { useState, useEffect, useRef, useCallback } from 'react';
function useRetryRequest(fetchFn, options = {}) {
const {
baseDelay = 1000, // 基础延迟,毫秒
maxDelay = 30000, // 单次延迟封顶,毫秒
maxRetries = Infinity, // 最大重试次数,Infinity表示无限重试
onSuccess,
onError,
} = options;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [retryCount, setRetryCount] = useState(0);
// 用ref保存定时器ID和卸载标记,避免闭包陷阱
const timerRef = useRef(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const execute = useCallback(
async (currentRetry) => {
setLoading(true);
try {
const result = await fetchFn();
if (mountedRef.current) {
setData(result);
setRetryCount(0);
setLoading(false);
onSuccess && onSuccess(result);
}
} catch (err) {
if (!mountedRef.current) return;
setLoading(false);
if (currentRetry >= maxRetries) {
onError && onError(err);
return;
}
// 指数退避 + 随机抖动
const backoff = Math.min(baseDelay * 2 ** currentRetry, maxDelay);
const jitter = Math.random() * baseDelay;
const nextRetry = currentRetry + 1;
timerRef.current = setTimeout(() => {
setRetryCount(nextRetry);
execute(nextRetry);
}, backoff + jitter);
}
},
[fetchFn, baseDelay, maxDelay, maxRetries, onSuccess, onError]
);
const start = useCallback(() => execute(0), [execute]);
return { data, loading, retryCount, start };
}
export default useRetryRequest;使用方式也很简单,把请求函数传进去,解构出start方法在合适的时机触发即可:
function UserList() {
const { data, loading, retryCount, start } = useRetryRequest(
() => fetch('https://api.ipipp.com/users').then(res => {
if (!res.ok) throw new Error('请求失败');
return res.json();
}),
{ baseDelay: 1000, maxRetries: Infinity }
);
useEffect(() => {
start();
}, [start]);
if (loading) return <p>加载中,当前第 {retryCount} 次重试...</p>;
if (!data) return <p>暂无数据</p>;
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}无限重试的边界问题与生产实践建议
“无限重试”听起来美好,实际线上要谨慎对待。首先要考虑的是用户感知问题:如果接口要等待网络恢复,重试期间界面应该有明确的状态提示,比如展示当前重试次数或预计等待时间,否则用户会以为页面卡死了。其次是电量与流量消耗,移动端浏览器在弱网下高频重试会明显耗电,建议配合navigator.onLine判断网络状态,断网时暂停重试,监听online事件后再恢复。
另一个容易被忽略的细节是请求取消。如果用户已经离开了触发请求的页面,继续重试就是浪费资源。可以在Hook中集成AbortController,在组件卸载时调用controller.abort(),让正在进行的请求和排队中的重试一并终止。对于提交表单这类非幂等操作,还要格外小心自动重试可能导致的重复提交,重试机制更适合GET请求或确认过幂等性的接口。
最后一点建议是做好延迟封顶。即使选择无限重试,单次等待时间也不应该无限增长,设置一个30秒到60秒的封顶值是业界通行做法,超过封顶后就按固定间隔稳定重试。这样既保留了指数退避保护服务端的优点,又不会让用户在极端情况下等待过久。把这些细节处理好,你的请求层在弱网环境下就能稳定得多。