在数据请求中,网络延迟、用户快速操作、服务端不稳定等因素会引发一系列棘手问题:频繁触发请求导致服务器压力过大、旧请求响应覆盖新数据、请求失败后无感知等。本节聚焦四种核心处理策略,帮助你在 React 应用中构建稳健的请求逻辑。
14.4.1 请求防抖(Debounce)
场景:搜索框输入联想、窗口 resize 事件、表单实时校验。用户每敲一个字符就发一次请求,既浪费带宽,又可能因响应顺序错乱导致显示与输入不一致。
原理:在指定时间内,如果事件再次触发,则重新计时;只有规定时间内没有新触发时,才执行请求。
实现:封装一个通用的 useDebounce Hook,将频繁变化的值“降频”输出,再配合 useEffect 发起请求。
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchBar() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 500);
useEffect(() => {
if (debouncedKeyword.trim()) {
fetchSearchResults(debouncedKeyword).then(setResults);
}
}, [debouncedKeyword]);
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}
关键细节:防抖延迟应略长于用户平均输入间隔(通常 300~500ms),避免请求过早发出;记得在组件卸载或值变化时清除计时器。
14.4.2 请求节流(Throttle)
场景:滚动加载、按钮防重复提交。与防抖不同,节流确保在固定时间间隔内只执行一次,无论触发多频繁。
原理:在上一次执行后,开启一个冷却时间,期间内的触发都被忽略,直到冷却结束。
实现:使用 useRef 记录时间戳或锁状态。
function useThrottle(fn, delay) {
const lastTime = useRef(0);
return useCallback((...args) => {
const now = Date.now();
if (now - lastTime.current >= delay) {
fn(...args);
lastTime.current = now;
}
}, [fn, delay]);
}
function LoadMoreButton({ onLoadMore }) {
const handleClick = useThrottle(onLoadMore, 2000);
return <button onClick={handleClick}>加载更多</button>;
}
对于按钮防重复,更简单的做法是在请求期间禁用按钮,通过 loading 状态控制,这比节流更符合用户预期。
14.4.3 请求重试(Retry)
场景:网络抖动、服务端临时不可用(如 503、429)。自动重试可提升用户体验,避免偶发性失败打断操作。
核心要素:
- 重试次数:通常 2~3 次,避免无限重试。
- 重试间隔:固定间隔(如 1s)或递增延迟(退避策略)。
- 可重试条件:仅针对网络错误或特定状态码(5xx),不应重试客户端错误(4xx 除 408、429 外)。
Axios 拦截器实现:
import axios from 'axios';
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 毫秒
const api = axios.create();
api.interceptors.response.use(null, async (error) => {
const { config, response } = error;
// 没有 config(请求未发出)或手动禁止重试,直接抛出
if (!config || config.retry === false) return Promise.reject(error);
// 初始化重试计数
config.retryCount = config.retryCount || 0;
// 检查是否超过重试次数
if (config.retryCount >= MAX_RETRIES) {
return Promise.reject(error);
}
// 仅重试网络错误或 5xx,408 请求超时也可以重试
if (response?.status >= 500 || response?.status === 408) {
config.retryCount += 1;
// 指数退避:1s, 2s, 4s...
const delay = RETRY_DELAY * Math.pow(2, config.retryCount - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return api(config); // 重试
}
return Promise.reject(error);
});
注意:重试幂等的 GET 请求相对安全,但对 POST 请求要谨慎,防止重复创建资源。最好配合服务端幂等性设计。
14.4.4 竞态问题处理(Race Condition)
场景:多个相同请求几乎同时发出,后一个请求的响应可能比前一个先到达,导致界面显示的是旧数据。典型例子:搜索框输入 “react” 时,先请求 “re” 再请求 “rea”,如果网络波动导致 “rea” 的响应先返回,界面先显示 “rea” 的结果,随后 “react” 的响应才到,覆盖了预期结果。
解决方案①:使用标志位(AbortController 或 ignore flag)
在现代 React 中,推荐使用 AbortController 配合 fetch 或 Axios,当新请求发起时取消旧请求。
function SearchResults({ keyword }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
}
}
}
fetchData();
return () => controller.abort();
}, [keyword]);
return <ul>{results.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
在 useEffect 的清理函数中调用 controller.abort(),当 keyword 变化时,前一个 effect 清理会取消未完成的请求,从而避免旧结果覆盖新结果。
Axios 中使用 CancelToken (v0.22.0 前) 或 signal (v0.22.0 后):
useEffect(() => {
const controller = new AbortController();
axios.get('/api/search', {
params: { q: keyword },
signal: controller.signal,
}).then(setResults).catch(err => {
if (!axios.isCancel(err)) console.error(err);
});
return () => controller.abort();
}, [keyword]);
解决方案②:使用 TanStack Query 的自动处理
React Query 默认会为每个 queryKey 处理竞态问题:当同一个 queryKey 有多个进行中的请求时,它只会接受最新的一个请求的结果。你的组件不需要手动取消请求,大大简化代码。
function SearchResults({ keyword }) {
const { data: results } = useQuery({
queryKey: ['search', keyword],
queryFn: () => axios.get(`/api/search?q=${keyword}`).then(res => res.data),
enabled: !!keyword,
});
return <ul>{results?.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
解决方案③:手动 ignore 标志(旧式兼容)
在早期 React 中(不使用取消请求的库),可在 effect 中使用一个 ignore 布尔值,避免在组件卸载或依赖变化后更新状态。
useEffect(() => {
let ignore = false;
fetchData(keyword).then(data => {
if (!ignore) setResults(data);
});
return () => { ignore = true; };
}, [keyword]);
这种方式无法真正取消网络请求,但可防止过时响应更新状态。
14.4.5 实战组合:搜索框综合示例
结合防抖、竞态处理、错误重试,一个健壮的搜索框实现如下(使用 TanStack Query 简化):
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 400);
const { data, isLoading, error } = useQuery({
queryKey: ['search', debouncedKeyword],
queryFn: ({ signal }) =>
axios.get(`/api/search?q=${debouncedKeyword}`, { signal })
.then(res => res.data)
.catch(err => {
// 可以让 React Query 自动重试,这里仅示例自定义重试逻辑
if (err.response?.status >= 500) throw err; // 触发 React Query 重试
throw err;
}),
enabled: debouncedKeyword.trim().length > 0,
retry: 2,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
});
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索..."
/>
{isLoading && <p>加载中...</p>}
{error && <p>请求出错,正在重试...</p>}
{data && (
<ul>
{data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</div>
);
}
小结
- 防抖:适合连续高频事件,只关心最终值(搜索)。
- 节流:适合需要定期执行,保持执行频率(滚动加载)。
- 重试:针对网络波动,增加请求鲁棒性,注意幂等性。
- 竞态处理:用
AbortController或 React Query 等现代工具避免 UI 显示过期数据。
在实际开发中,建议优先使用 TanStack Query(React Query)这类数据请求库,它们内置了竞态保护、缓存、重试等能力,可以省去大量手工实现的样板代码,让开发者专注于业务逻辑。