陌上人如玉
公子世无双

7.2 useEffect 中请求的竞态

7.2 useEffect 中请求的竞态

一句话:竞态(race condition)指「多个并发请求,后发起的先返回,导致旧数据覆盖新数据」;解法是「取消(AbortController)或忽略(标记位)」,并且StrictMode 下的双执行会暴露这个问题。

一、竞态是怎么产生的

// ❌ 典型的竞态 bug
function Search({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    fetchSearch(keyword).then(setResults);      // ⚠️ 没有取消也没有依赖校验
  }, [keyword]);

  return <List items={results} />;
}
用户在搜索框输入「react」:

时序:
  t=0ms   输入 'r'  → 发起请求 A('r')
  t=50ms  输入 're' → 发起请求 B('re')
  t=100ms 输入 'rea'→ 发起请求 C('rea')

网络可能这样返回:
  t=300ms B 返回('re' 的结果)→ setResults(B)
  t=400ms C 返回('rea' 的结果)→ setResults(C)
  t=500ms A 返回('r' 的结果) → setResults(A)   ⚠️ 旧数据覆盖了最新结果!

用户看到的是 'r' 的结果,而不是 'rea' 的 —— 这就是竞态。
⚠️ 竞态不只出现在搜索:
① 路由参数变化(用户快速切换用户详情页)
② Tab 切换(快速点不同 Tab)
③ 分页快速翻页
④ 筛选条件快速变化
⑤ 重试/刷新按钮连点
⑥ StrictMode 下的「双执行」(同一请求发两次,返回顺序不定)

本质:并发请求的「完成顺序」与「发起顺序」不一致。

二、解法一:AbortController(真正取消)

function Search({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<Item[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();          // ⭐ 每次 effect 一个 controller

    setError(null);
    fetchSearch(keyword, { signal: controller.signal })  // ⭐ 把 signal 传给请求层
      .then(setResults)
      .catch((err) => {
        if (err.name === 'AbortError') return;         // ⭐ 主动取消 → 静默忽略
        setError(err.message);
      });

    return () => controller.abort();                   // ⭐ 依赖变化/卸载时取消
  }, [keyword]);

  return <List items={results} error={error} />;
}
为什么 AbortController 是最好的解法?
① 「真正取消」——请求不会再消耗网络与带宽(浏览器会中断连接)
② 「不会有后续副作用」——被取消的 Promise 会 reject(AbortError),
   所以不会走到「setResults」那一步
③ 它是标准 API(fetch 与新版 axios 都支持)

⚠️ 前提:请求层必须把 signal 透传给 fetch:
   function fetchSearch(keyword, { signal }) {
     return fetch(`/api/search?q=${keyword}`, { signal }).then((r) => r.json());
   }

三、解法二:忽略标记(无法取消时)

// ✅ 适用于「底层不支持取消」的请求(某些 SDK、GraphQL 客户端、老封装)
function Search({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    let cancelled = false;                             // ⭐ 本次 effect 的标记

    fetchSearch(keyword).then((data) => {
      if (cancelled) return;                           // ⭐ 已过期 → 忽略
      setResults(data);
    });

    return () => {
      cancelled = true;                                // ⭐ 依赖变化/卸载时置为 true
    };
  }, [keyword]);

  return <List items={results} />;
}
两种解法的对比:
| 维度 | AbortController | 忽略标记 |
| 是否真正取消 | ✅(断开请求) | ❌(请求照常完成) |
| 是否避免过期数据 | ✅ | ✅ |
| 是否节省带宽 | ✅ | ❌ |
| 是否依赖底层支持 | ✅(需要 signal 透传) | ❌(任何 Promise 都能用) |

结论:优先 AbortController;底层不支持时用标记位。

四、解法三:用「请求序号」判断(也可用于并发去重)

function useLatestRequest<T>(fetcher: (signal: AbortSignal) => Promise<T>) {
  const seqRef = useRef(0);

  return useCallback(async () => {
    const seq = ++seqRef.current;                      // ⭐ 本次请求的序号

    const data = await fetcher(new AbortController().signal);

    if (seq !== seqRef.current) return null;            // ⭐ 不是最新请求 → 丢弃结果
    return data;
  }, [fetcher]);
}

// 使用
function Search({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<Item[]>([]);
  const fetchLatest = useLatestRequest((signal) => fetchSearch(keyword, { signal }));

  useEffect(() => {
    fetchLatest().then((data) => { if (data) setResults(data); });
  }, [fetchLatest]);

  return <List items={results} />;
}
序号模式的特点:
① 与「取消」解耦(用序号判断「谁是最后一次」)
② 适合「不想取消请求,但要保证只用最新结果」的场景
③ 也可以用来「丢弃过期响应」(如输入法联想、实时协作)

五、StrictMode 下的双执行

// React 18 的 StrictMode 在开发环境会「挂载 → 卸载 → 再挂载」
function Comments({ postId }: { postId: string }) {
  const [comments, setComments] = useState<Comment[]>([]);

  useEffect(() => {
    fetchComments(postId).then(setComments);          // ⚠️ 没有取消 → 发两次请求
  }, [postId]);

  return <List items={comments} />;
}
// → 控制台会看到两次请求(这正是 StrictMode 「故意的」)
// ✅ 加上取消后,双执行不再是问题(第一次请求会被立刻取消)
useEffect(() => {
  const controller = new AbortController();

  fetchComments(postId, { signal: controller.signal })
    .then(setComments)
    .catch((err) => { if (err.name !== 'AbortError') setError(err); });

  return () => controller.abort();                     // ⭐ StrictMode 下会立刻取消第一个
}, [postId]);
// → 生产环境(无 StrictMode)只发一次;开发环境发的第一次会被取消
StrictMode 的价值:它【故意】制造「effect 被执行两次」的场景,
   从而把「没做取消/没写清理」的问题暴露出来。
   —— 如果你看到「请求发两次」,说明你的 effect 缺少取消逻辑
      (而这在生产环境的「快速切换参数」场景下会造成竞态)。

⚠️ 不要用「ref 标志位」绕过 StrictMode 的双调用——
   那只是掩盖问题,真正的竞态依然存在。

六、封装一个稳健的请求 Hook

// hooks/useQueryData.ts —— 一个简化版的「带取消与状态管理」的请求 Hook
type State<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

export function useQueryData<T>(
  fetcher: (signal: AbortSignal) => Promise<T>,
  deps: unknown[]
): State<T> & { refetch: () => void } {
  const [state, setState] = useState<State<T>>({ status: 'idle' });
  const [nonce, setNonce] = useState(0);

  // 用 ref 保存最新的 fetcher(避免把它放进依赖导致死循环)
  const fetcherRef = useRef(fetcher);
  useEffect(() => {
    fetcherRef.current = fetcher;
  });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });

    fetcherRef.current(controller.signal)
      .then((data) => setState({ status: 'success', data }))
      .catch((err: Error) => {
        if (err.name === 'AbortError') return;          // ⭐ 取消 → 不改状态
        setState({ status: 'error', error: err });
      });

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [...deps, nonce]);

  const refetch = useCallback(() => setNonce((n) => n + 1), []);

  return { ...state, refetch };
}
// 使用
function UserDetail({ id }: { id: string }) {
  const state = useQueryData((signal) => fetchUser(id, { signal }), [id]);

  if (state.status === 'loading') return <Skeleton />;
  if (state.status === 'error') return <ErrorView message={state.error.message} />;
  if (state.status === 'success') return <div>{state.data.name}</div>;
  return null;
}
⚠️ 这只是「教学版」。生产上建议直接用 TanStack Query / SWR:
① 自动取消(或至少不会造成竞态)
② 缓存与去重(同一 key 只发一次)
③ 状态机(isLoading / isError / data)
④ 重试、窗口聚焦重验证、分页
⑤ 更好的 devtools

七、常见坑

// ① effect 里请求但没写清理
useEffect(() => { fetchData().then(setData); }, [id]);     // ⚠️ 有竞态

// ② 有清理但不判 AbortError
fetchData(...).catch(setError);                             // ⚠️ 取消也会设置错误 → 用户看到假错误

// ③ 用「一个全局 loading」管理多个请求
const [loading, setLoading] = useState(false);
// ⚠️ 请求 A 完成后把 loading 置 false,但请求 B 还在进行 → UI 状态错误

// ④ 在 effect 里「请求 + 依赖里放了不稳定的对象」→ 无限循环
useEffect(() => { fetchData(options); }, [options]);        // ⚠️ options 每次都是新对象

// ⑤ StrictMode 下「请求两次」却用 ref 绕过(掩盖竞态问题)
// ⑥ 只用了「忽略标记」但请求仍在消耗带宽(大文件下载场景)
// ⑦ 在「已经卸载的组件」里 setState(React 18 不再警告,但仍是无用功)
// ⑧ 用「取消」但请求层没透传 signal(取消无效)
function fetchData() { return fetch(url); }                 // ❌ 没用 signal
function fetchData(o) { return fetch(url, { signal: o.signal }); }   // ✅

// ⑨ 认为「加了取消就不需要依赖数组写对」→ 依赖写错会「不重新请求」
// ⑩ 多个请求「共享一个 controller」(取消了一个会影响其它)
// ⑪ 把「竞态」误认为「后端数据错误」(排查方向错)
// ⑫ 在 catch 里判断 err.name 但错误被包装过(如 axios 的 CanceledError)
axios 用 `axios.isCancel(err)` 或 `err.code === 'ERR_CANCELED'` 判断
// ③ 的修复:每个请求独立状态(或用「状态机」)
function useData<T>(fetcher: () => Promise<T>, deps: unknown[]) {
  const [state, setState] = useState<State<T>>({ status: 'idle' });
  // ⭐ 每个 hook 实例有自己的状态 → 不会互相干扰
}

// 或者用「请求计数」
const pendingRef = useRef(0);
const start = () => { pendingRef.current++; setLoading(true); };
const end = () => { pendingRef.current--; if (pendingRef.current === 0) setLoading(false); };

八、面试延伸

  1. 「什么是请求竞态?怎么产生的?」
    竞态(race condition)指「多个并发请求,后发起的先返回,导致旧数据覆盖新数据」。产生原因是「请求的完成顺序与发起顺序不一致」——比如用户在搜索框快速输入 r → re → rea,三个请求并发,如果 r 的响应最后才返回,就会把 rea 的结果覆盖掉。典型场景包括:搜索联想、路由参数快速切换、Tab 切换、分页连点、筛选条件变化,以及 StrictMode 下的双执行。

  2. 「怎么解决竞态?」
    两种主流方案:① AbortController(推荐)——在 useEffect 里为每次请求创建 controller,把 signal 传给请求层,并在清理函数里 abort()。这样依赖变化时上一个请求会被真正取消(其 Promise 会 reject AbortError)。② 忽略标记——let cancelled = false; + 在清理函数里置 true,拿到响应后先判断 cancelled 再 setState。适用于「底层不支持取消」的场景。此外还有「请求序号」方案(用 ref 记录最新序号,过期响应直接丢弃)。

  3. 「为什么推荐 AbortController 而不是忽略标记?」
    因为 AbortController 是「真正取消」——它会中断网络请求(节省带宽与连接资源),而被取消的 Promise 会 reject,因此不会走到 setState 那一步。而「忽略标记」只是「忽略结果」——请求仍然会完整执行、消耗带宽与服务器资源(对大文件下载或高成本接口很不友好)。例外:如果底层请求库不支持 signal,那只能用标记位(或改造成本太高)。

  4. 「为什么 StrictMode 会让请求发两次?」
    因为 React 18 的 StrictMode 在开发环境会故意把组件「挂载 → 卸载 → 再挂载」,以暴露「Effect 缺少清理」的问题——如果你的 effect 没有取消/清理逻辑,就会看到「请求发了两次」。这不是 bug,而是「压力测试」:它模拟「组件被快速卸载重挂载」的场景(这正是竞态的高发场景)。正确应对是「补齐取消逻辑」(加了 AbortController 后,第一次请求会被立刻取消),而不是用 ref 标志位绕过(那只是掩盖问题)。

  5. 「怎么判断一个错误是不是『取消导致的』?」
    fetch 被 abort() 后抛出的错误名是 AbortError(err.name === 'AbortError');axios 新版会抛 CanceledError,可以用 axios.isCancel(err) 或判断 err.code === 'ERR_CANCELED'(旧版用 axios.Cancel)。关键是:取消不应该被当作「错误」展示给用户——它只是「我们不关心这个响应了」,所以 catch 里要 return 而不是 setError。否则用户会看到「请求已取消」这类无意义的提示。

  6. 「竞态和『loading 状态管理』有什么关系?」
    两者常常一起出问题:如果用一个全局 loading 状态管理多个并发请求,就会出现「请求 A 完成了把 loading 置为 false,但请求 B 还在进行」——UI 会显示「已加载完成」但数据缺失。解法:① 每个请求独立状态(用独立的 hook 实例或状态机 { status, data, error });② 用请求计数(pendingCount,只有归零才取消 loading);③ 用「状态机」建模避免「loading 与 data 同时存在」的非法组合。

  7. 「路由参数快速切换时的竞态怎么处理?」
    在 React 里,路由参数变化会触发组件重渲染但不重新挂载(见 6.4),所以 useEffect([id]) 会重新执行——这正好是「竞态」的温床。处理方式是「依赖参数 + 取消」:useEffect(() => { const c = new AbortController(); fetch(id, { signal: c.signal }).then(...); return () => c.abort(); }, [id])。如果用 TanStack Query,只要把 id 放进 queryKey,库内部就会处理「过期响应的丢弃」与「去重」,不用自己写取消逻辑。

  8. 「生产环境下要自己处理竞态吗?」
    取决于「数据层」。如果用了 TanStack Query / SWR,它们内部已经处理了「同一 queryKey 的并发请求去重」与「过期结果丢弃」——通常不需要自己写取消逻辑(但「组件卸载后忽略响应」仍由库处理)。如果自己写 useEffect 请求,就必须自己处理:① 取消(AbortController);② 清理;③ 状态机建模。判断标准是「有没有一个统一的请求层帮你兜底」——没有就自己写,有就依赖它(并理解它的行为)。

一句话速记

竞态 = 「后发起的请求先返回,旧数据覆盖新数据」(搜索、路由参数、Tab、分页、筛选、StrictMode 双执行都是高发场景);解法优先级是「AbortController(真正取消,在 useEffect 清理里 abort())→ 忽略标记(let cancelled = false,底层不支持取消时用)→ 请求序号(丢弃过期结果)」;catch 里必须区分「取消」(AbortError / axios.isCancel)——不要给取消弹提示;StrictMode 的双执行是压力测试(暴露「没写清理」),要补取消而不是用 ref 绕过;不要用一个全局 loading 管多个并发请求(用独立状态或计数);生产上优先用 TanStack Query(自动去重与丢弃过期结果)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.2 useEffect 中请求的竞态

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫