跳到主要内容
预计阅读 43 分钟

React篇 | 请求竞态处理

前言

你一定在项目中见过这种 Bug:用户在搜索框快速输入 “abc”,先请求了 “a”,再请求了 “ab”,最后请求了 “abc”。结果 “abc” 的响应先回来了,页面显示正确。但紧接着 “a” 的响应也回来了(它最慢),页面被覆盖成了 “a” 的结果——用户明明输入的是 “abc”,看到的却是 “a” 的搜索结果。

这就是竞态条件(Race Condition)

这个问题在面试中出现频率很高,因为它同时考察了你对异步编程、React 生命周期、网络请求的理解。面试官常见的问法:

  • 什么是请求竞态?在哪些场景下会出现?
  • 你知道几种解决方案?各有什么优缺点?
  • 在 useEffect 里发请求,怎么处理竞态?
  • AbortController 是什么?怎么用?
  • React Query / SWR 是怎么处理竞态的?

本章就来把这些问题一网打尽。


诊断自测

Q1:下面的代码存在竞态问题吗?如果存在,在什么场景下会出 Bug?

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [query]);

  return <List items={results} />;
}
点击查看答案

存在竞态问题。当 query 快速变化时(比如用户连续输入),会同时发出多个请求。如果后发的请求先返回,再被先发的请求覆盖,就会出现 UI 与用户输入不一致的 Bug。

比如:query 从 “a” → “ab” → “abc” 变化,发出三个请求。如果 “abc” 的响应先回来,“a” 的响应后回来,最终 results 会是 “a” 的结果,而不是用户期望的 “abc”。

Q2:AbortController 和”忽略过期响应”这两种方案有什么本质区别?

点击查看答案

AbortController取消请求本身——浏览器不会等待服务端的响应,TCP 连接可能被关闭,节省了网络资源和浏览器处理响应的开销。

忽略过期响应只是在响应回来后不去更新状态——请求本身仍然完成了,浏览器仍然接收并解析了响应数据,只是前端代码选择不用它。

简单说:AbortController 是”不要了”,忽略过期响应是”收到了但不用”。在性能和资源利用上,AbortController 更优。

Q3:React 的 useEffect 清理函数在竞态处理中扮演什么角色?

点击查看答案

useEffect 的清理函数在下次 effect 执行前组件卸载时自动运行。这正好对应了”上一次请求应该被取消/忽略”的时机。利用清理函数,我们可以:

  1. 在清理函数中调用 abort() 取消上一次请求
  2. 或者设置一个标志位 ignore = true,让上一次请求的回调不再更新状态

这是 React 中处理异步竞态最惯用的模式。


一、什么是竞态条件?

1.1 定义

竞态条件(Race Condition)是指:程序的行为依赖于多个异步操作的完成顺序,而这个顺序是不可预测的。

在前端的语境下,最常见的竞态条件是:多个请求按顺序发出,但响应的返回顺序不确定,导致 UI 显示了错误的(过期的)数据。

1.2 常见场景

场景一:搜索框快速输入

这是最经典的场景。用户每输入一个字就触发一次搜索请求,快速输入时多个请求并行,响应顺序不确定。

// 用户输入 "react" → 依次发出 "r", "re", "rea", "reac", "react" 五个请求
// 如果 "r" 的请求最慢(服务端匹配范围最大),它最后返回
// 页面最终显示的是 "r" 的搜索结果,而不是 "react"

场景二:Tab/页签快速切换

用户快速在多个 Tab 之间切换,每个 Tab 都会请求数据。如果用户最终停在 Tab C,但 Tab A 的请求最后才返回,页面可能显示 Tab A 的数据。

场景三:分页快速翻页

用户连续点击”下一页”,从第 1 页翻到第 5 页。如果第 1 页的请求最后返回,列表会显示第 1 页的数据。

场景四:路由快速切换

用户快速点击不同的导航链接,每次路由切换都触发数据加载。如果上一个路由的请求在新路由渲染后才返回,并尝试 setState,就会出问题。


二、方案一:AbortController

AbortController 是浏览器原生的 API,用于取消 fetch 请求。它是处理请求竞态最推荐的方案。

2.1 基本用法

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();

    setLoading(true);
    fetch(`/api/search?q=${query}`, {
      signal: controller.signal, // 把 signal 传给 fetch
    })
      .then(res => res.json())
      .then(data => {
        setResults(data);
        setLoading(false);
      })
      .catch(err => {
        // AbortError 不是真正的错误,不需要处理
        if (err.name !== 'AbortError') {
          console.error(err);
          setLoading(false);
        }
      });

    // 清理函数:取消上一次请求
    return () => {
      controller.abort();
    };
  }, [query]);

  return (
    <>
      {loading && <Spinner />}
      <List items={results} />
    </>
  );
}

2.2 工作原理

  1. 每次 query 变化时,useEffect 先执行上一次的清理函数(调用 controller.abort()
  2. abort() 会让上一个 fetch 的 promise 被 reject,错误类型是 AbortError
  3. 然后执行新的 effect,创建新的 AbortController,发起新的请求
  4. 组件卸载时,同样会执行清理函数取消未完成的请求

2.3 封装成自定义 Hook

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

// 使用
function SearchResults({ query }) {
  const { data, loading } = useFetch(`/api/search?q=${query}`);
  // ...
}

2.4 配合 axios 使用

axios 同样支持 AbortController(v0.22.0+):

useEffect(() => {
  const controller = new AbortController();

  axios.get(`/api/search?q=${query}`, {
    signal: controller.signal,
  })
    .then(res => setResults(res.data))
    .catch(err => {
      if (!axios.isCancel(err)) {
        console.error(err);
      }
    });

  return () => controller.abort();
}, [query]);

三、方案二:忽略过期响应

如果你不想取消请求(比如服务端计算量大,取消后下次还得重新算),可以让请求正常完成,但忽略过期的响应。

3.1 布尔标志位法

这是 React 官方文档推荐的方式:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let ignore = false; // 标志位

    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) { // 只有标志位为 false 时才更新
          setResults(data);
        }
      });

    return () => {
      ignore = true; // 清理时把标志位设为 true
    };
  }, [query]);

  return <List items={results} />;
}

3.2 原理解析

这里利用了 JavaScript 的闭包特性:

  1. 每次 effect 执行时,创建一个局部变量 ignore = false
  2. .then 回调通过闭包引用这个 ignore
  3. 当 query 变化时,React 先执行上一次 effect 的清理函数,把上一次的 ignore 设为 true
  4. 上一次请求的响应回来后,检查自己的 ignore——已经是 true 了,不更新状态

每次 effect 都有自己的 ignore,互不干扰。这其实就是每个 effect 有自己的闭包这一特性的经典应用。

3.3 版本号法

另一种类似的思路是用版本号/请求 ID:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const latestRequestRef = useRef(0);

  useEffect(() => {
    const requestId = ++latestRequestRef.current;

    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        // 只有最新的请求才更新
        if (requestId === latestRequestRef.current) {
          setResults(data);
        }
      });
  }, [query]);

  return <List items={results} />;
}

每次发请求前递增版本号,响应回来后检查当前版本号是否和发请求时一致。如果不一致,说明又有新请求发出了,当前响应已过期。

布尔标志位 vs 版本号: 功能上等价。布尔标志位更直观,是 React 官方文档推荐的写法。版本号的优势是不依赖 useEffect 的清理函数,在某些非 Hook 场景(如 class 组件)中更方便。


四、方案三:使用数据请求库

在实际项目中,直接手写 fetch + 竞态处理的代码虽然可行,但容易遗漏。更推荐使用成熟的数据请求库,它们已经内置了竞态处理。

4.1 React Query(TanStack Query)

import { useQuery } from '@tanstack/react-query';

function SearchResults({ query }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['search', query],
    queryFn: async ({ signal }) => {
      // React Query 自动传入 AbortSignal
      const res = await fetch(`/api/search?q=${query}`, { signal });
      return res.json();
    },
    enabled: !!query, // query 为空时不请求
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <List items={data} />;
}

React Query 自动处理竞态的方式:

  • queryKey 变化时,自动取消上一次请求(通过 AbortSignal)
  • 内置去重:相同 queryKey 的并发请求只发一次
  • 自动缓存:相同 queryKey 的数据不重复请求

4.2 SWR

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(res => res.json());

function SearchResults({ query }) {
  const { data, error, isLoading } = useSWR(
    query ? `/api/search?q=${query}` : null,
    fetcher
  );

  if (isLoading) return <Spinner />;
  if (error) return <Error />;
  return <List items={data} />;
}

SWR 处理竞态的方式:内部维护一个请求计数器,只有最后一次请求的结果才会被采用(类似版本号法)。

4.3 为什么推荐用库?

手写竞态处理代码要考虑很多细节:

  • 取消请求 / 忽略过期响应
  • loading 状态管理
  • 错误处理
  • 重试逻辑
  • 缓存
  • 去重
  • 组件卸载时的清理

这些逻辑每次手写都容易遗漏一两个。React Query 和 SWR 把这些都封装好了,你只需要关注业务逻辑。


五、useEffect 中的竞态处理最佳实践

5.1 React 官方推荐的模式

React 官方文档给出的最佳实践是布尔标志位 + async/await

useEffect(() => {
  let ignore = false;

  async function fetchData() {
    try {
      const response = await fetch(`/api/data/${id}`);
      const data = await response.json();
      if (!ignore) {
        setData(data);
      }
    } catch (err) {
      if (!ignore) {
        setError(err);
      }
    }
  }

  fetchData();

  return () => {
    ignore = true;
  };
}, [id]);

注意:不要把 async 函数直接传给 useEffect,因为 useEffect 期望返回的是清理函数或 undefined,而 async 函数返回的是 Promise:

// ❌ 错误:不要这样写
useEffect(async () => {
  const data = await fetch(...);
}, []);

// ✅ 正确:在 effect 内部定义 async 函数
useEffect(() => {
  async function fetchData() { /* ... */ }
  fetchData();
}, []);

5.2 同时使用 AbortController + 标志位

在生产环境中,最稳妥的方案是同时使用两者

useEffect(() => {
  let ignore = false;
  const controller = new AbortController();

  async function fetchData() {
    try {
      const res = await fetch(`/api/data/${id}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      if (!ignore) {
        setData(data);
      }
    } catch (err) {
      if (err.name === 'AbortError') return; // 正常取消,不处理
      if (!ignore) {
        setError(err);
      }
    }
  }

  fetchData();

  return () => {
    ignore = true;
    controller.abort();
  };
}, [id]);

AbortController 负责节省网络资源(取消已发出的请求),标志位负责兜底(万一 abort 没生效或请求已经完成了,确保不更新状态)。

5.3 防抖 + 竞态处理的组合

在搜索场景中,通常还会加上防抖,减少请求次数:

function useSearch(query) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    let ignore = false;
    const controller = new AbortController();

    // 防抖:延迟 300ms 再发请求
    const timer = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        if (!ignore) {
          setResults(data);
          setLoading(false);
        }
      } catch (err) {
        if (err.name !== 'AbortError' && !ignore) {
          setLoading(false);
        }
      }
    }, 300);

    return () => {
      ignore = true;
      controller.abort();
      clearTimeout(timer); // 清除未执行的定时器
    };
  }, [query]);

  return { results, loading };
}

这里的清理函数做了三件事:

  1. 设置 ignore = true(兜底)
  2. controller.abort()(取消已发出的请求)
  3. clearTimeout(timer)(取消还没发出的请求)

三者配合,完整地处理了竞态和资源清理。


常见误区

误区一:“用了防抖就不需要处理竞态了”

防抖只是减少了请求次数,不能消除竞态。即使加了 300ms 防抖,两次防抖后的请求仍然可能并行,响应顺序仍然不确定。防抖解决的是”太频繁”的问题,竞态解决的是”乱序”的问题,两者是互补的,不是替代的。

误区二:“AbortController 会导致服务端取消处理”

AbortController 取消的是浏览器端的请求。调用 abort() 后,浏览器会关闭 TCP 连接(或者停止处理响应),但服务端可能已经在处理请求了——abort 不会通知服务端”不要继续了”。服务端可能仍然完成了计算、写入了数据库,只是客户端不再接收响应。如果你需要服务端也取消,需要额外的机制(比如取消令牌)。

误区三:“在 Promise.then 里面 setState 不会有问题,因为 React 会 batch”

React 的 batch 机制确保多个 setState 合并为一次渲染,但这和竞态是两回事。竞态问题是用了过期数据来 setState,不管 batch 不 batch,结果都是错的。batch 解决的是渲染效率问题,竞态解决的是数据正确性问题。

误区四:“组件卸载后 setState 会报错,所以不处理也没关系”

在 React 18 中,组件卸载后 setState 不再打印警告(React 团队认为这个警告造成了过多误解)。但这不意味着不需要清理——未清理的请求仍然会消耗资源、占用网络带宽。此外,竞态问题不仅发生在组件卸载时,更常见的是组件仍在挂载但 props 变化了的场景。


小结

本章我们从搜索框的经典 Bug 出发,系统讲解了请求竞态的成因和三种解决方案。

核心要点

  1. 竞态条件:多个请求并行,响应顺序不确定,过期响应覆盖了最新数据
  2. AbortController:取消请求本身,节省网络资源。配合 useEffect 清理函数使用
  3. 忽略过期响应:布尔标志位或版本号法,让过期的回调不更新状态
  4. 数据请求库:React Query / SWR 内置竞态处理,推荐在生产项目中使用
  5. 最佳实践:AbortController + 标志位双重保险,配合防抖减少请求次数
  6. useEffect 清理函数是处理竞态的关键时机——在依赖变化时自动清理上一次请求

本章思维导图

React:请求竞态处理
  • 什么是竞态条件
    • 多个异步操作,完成顺序不可预测
    • 过期响应覆盖最新数据
  • 常见场景
    • 搜索框快速输入
    • Tab 切换
    • 分页翻页
    • 路由切换
  • 方案一:AbortController
    • fetch + signal 取消请求
    • useEffect 清理函数中调用 abort()
    • 错误处理:过滤 AbortError
    • axios 也支持
  • 方案二:忽略过期响应
    • 布尔标志位法(闭包 + let ignore)
    • 版本号法(useRef 计数)
    • 请求仍完成,只是不用结果
  • 方案三:数据请求库
    • React Query:自动 abort + 缓存 + 去重
    • SWR:内部计数器 + stale-while-revalidate
  • 最佳实践
    • AbortController + 标志位双重保险
    • 防抖 + 竞态处理配合使用
    • async 函数定义在 effect 内部

练习挑战

第一题 ⭐(基础):找出竞态 Bug

下面的代码有竞态问题,请指出问题所在并修复:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return user ? <div>{user.name}</div> : <Loading />;
}
点击查看答案与解析

问题:当 userId 快速变化时,多个请求并行发出,可能后发先至,导致显示了错误用户的信息。

修复方案(AbortController):

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      });

    return () => controller.abort();
  }, [userId]);

  return user ? <div>{user.name}</div> : <Loading />;
}

关键改动两处:1) 创建 AbortController 并把 signal 传给 fetch;2) 在清理函数中 abort。

第二题 ⭐⭐(进阶):实现一个带竞态保护的 useAsync Hook

实现一个通用的 useAsync Hook,支持:

  • 自动处理竞态(使用 AbortController)
  • 返回 data / error / loading 三个状态
  • 依赖变化时自动重新请求
// 期望的使用方式
const { data, error, loading } = useAsync(
  (signal) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
  [id]
);
点击查看答案与解析
function useAsync(asyncFn, deps) {
  const [state, setState] = useState({
    data: null,
    error: null,
    loading: true,
  });

  useEffect(() => {
    let ignore = false;
    const controller = new AbortController();

    setState(prev => ({ ...prev, loading: true, error: null }));

    asyncFn(controller.signal)
      .then(data => {
        if (!ignore) {
          setState({ data, error: null, loading: false });
        }
      })
      .catch(err => {
        if (err.name === 'AbortError') return;
        if (!ignore) {
          setState({ data: null, error: err, loading: false });
        }
      });

    return () => {
      ignore = true;
      controller.abort();
    };
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps

  return state;
}

这个 Hook 同时使用了 AbortController 和布尔标志位做双重保护。调用者通过 asyncFn 的参数拿到 signal,自己决定如何使用(传给 fetch 或其他支持 abort 的 API)。

第三题 ⭐⭐⭐(综合):处理依赖请求的竞态

现在有一个更复杂的场景:先根据 userId 获取用户信息,再根据用户信息中的 teamId 获取团队信息。两步请求都可能存在竞态。请实现这个逻辑,确保没有竞态问题。

// 期望效果:
// 1. userId 变化 → 取消之前的所有请求(用户请求 + 团队请求)
// 2. 始终显示最新 userId 对应的用户和团队信息
点击查看参考实现
function UserTeamInfo({ userId }) {
  const [user, setUser] = useState(null);
  const [team, setTeam] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    const controller = new AbortController();
    const { signal } = controller;

    async function fetchUserAndTeam() {
      setLoading(true);
      setError(null);

      try {
        // 第一步:获取用户信息
        const userRes = await fetch(`/api/users/${userId}`, { signal });
        const userData = await userRes.json();

        if (ignore) return; // 检查是否已过期
        setUser(userData);

        // 第二步:用用户的 teamId 获取团队信息
        const teamRes = await fetch(`/api/teams/${userData.teamId}`, { signal });
        const teamData = await teamRes.json();

        if (ignore) return; // 再次检查
        setTeam(teamData);
        setLoading(false);
      } catch (err) {
        if (err.name === 'AbortError') return;
        if (!ignore) {
          setError(err);
          setLoading(false);
        }
      }
    }

    fetchUserAndTeam();

    return () => {
      ignore = true;
      controller.abort(); // 同一个 controller 取消所有请求
    };
  }, [userId]);

  // ...
}

核心要点:

  1. 同一个 AbortControllersignal 传给两个 fetchabort() 时两个请求都会被取消
  2. 在每步异步操作之后都检查 ignore,避免在过期的 effect 中继续执行后续请求
  3. 使用 async/await 让链式请求的逻辑更清晰

自我检测

读完本章后,对照下面的清单检验一下自己的掌握程度。

  • 能解释什么是请求竞态,并举出至少三个常见场景
  • 能手写使用 AbortController 处理竞态的 useEffect 代码
  • 能手写使用布尔标志位处理竞态的 useEffect 代码
  • 能解释 AbortController 和”忽略过期响应”的区别和适用场景
  • 能说出为什么防抖不能替代竞态处理
  • 能描述 React Query / SWR 处理竞态的方式
  • 能在实际项目中正确组合防抖 + AbortController + 标志位来处理搜索场景
  • 知道 useEffect 的 async 函数不能直接作为回调,以及正确的写法

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90