人人都会AI编程

14.4 请求防抖、节流、重试、竞态问题处理

更新时间:2026-07-11

在数据请求中,网络延迟、用户快速操作、服务端不稳定等因素会引发一系列棘手问题:频繁触发请求导致服务器压力过大、旧请求响应覆盖新数据、请求失败后无感知等。本节聚焦四种核心处理策略,帮助你在 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)这类数据请求库,它们内置了竞态保护、缓存、重试等能力,可以省去大量手工实现的样板代码,让开发者专注于业务逻辑。