创见博客
useEffect应该如何处理异步操作
七崽爱吃小饼干2026/01/07阅读 2专栏 React

核心思路

useEffect 的回调函数本身不能直接是 async 函数(因为会返回 Promise,而 useEffect 期望返回清理函数或 undefined),所以正确的做法是:在 useEffect 内部定义一个异步函数,然后立即调用它。

完整示例代码

下面是几种常见的异步操作场景(以请求接口为例),包含完整的错误处理和清理逻辑:

jsx
import { useEffect, useState } from 'react';

function AsyncEffectDemo() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 1. 定义异步函数
    const fetchData = async () => {
      try {
        setLoading(true);
        // 模拟异步请求(比如调用 API)
        const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
        if (!response.ok) {
          throw new Error(`请求失败: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        setError(err.message);
        setData(null);
      } finally {
        setLoading(false); // 无论成功/失败,都结束加载状态
      }
    };

    // 2. 立即调用异步函数
    fetchData();

    // 3. 可选:清理函数(防止组件卸载后执行 setState)
    return () => {
      // 如果是 axios 等库,可以在这里取消请求
      // 示例:controller.abort()
      console.log('组件卸载/依赖变更,清理异步操作');
    };
  }, []); // 空依赖数组:仅在组件挂载时执行一次

  // 渲染逻辑
  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误:{error}</div>;
  return (
    <div>
      <h3>异步请求结果:</h3>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

export default AsyncEffectDemo;

关键细节解释

1. 为什么不能直接写 async useEffect?

jsx
// ❌ 错误写法:useEffect 回调返回 Promise,React 会警告
useEffect(async () => {
  const data = await fetchData();
}, []);

// ✅ 正确写法:内部定义 async 函数并调用
useEffect(() => {
  const asyncFunc = async () => { /* 异步逻辑 */ };
  asyncFunc();
}, []);

2. 处理取消请求(避免内存泄漏)

如果异步操作还在进行时组件卸载,可能会导致 setState 警告,推荐用 AbortController 取消请求:

jsx
useEffect(() => {
  const controller = new AbortController(); // 创建中止控制器
  const signal = controller.signal;

  const fetchData = async () => {
    try {
      const response = await fetch('https://xxx', { signal }); // 关联信号
      const data = await response.json();
      setData(data);
    } catch (err) {
      // abort也会抛出异常
      // 忽略 "中止" 错误(主动取消请求时触发)
      if (err.name !== 'AbortError') setError(err.message);
    }
  };

  fetchData();

  // 组件卸载/依赖变更时,取消请求
  return () => controller.abort();
}, []);

3. 竞态条件(Race Condition) 问题

先理解什么是 useEffect 中的竞态条件

简单来说,竞态条件指的是:当一个异步请求还未完成时,依赖项发生了变化,触发了新的请求,而旧请求的响应晚于新请求,导致页面最终渲染的是旧请求的数据,覆盖了正确的新数据。

比如:用户快速切换筛选条件(如 userId 从 1 变到 2),userId=1 的请求响应较慢,userId=2 的请求先返回,随后 userId=1 的请求才返回,最终页面显示的是 userId=1 的数据,这就是竞态问题。

核心解决思路

解决的核心是让“过时”的异步操作结果失效,常用的方案有 3 种(按推荐优先级排序):

  1. 使用 AbortController 取消旧请求(最推荐,从源头终止);
  2. 使用“标记变量”忽略旧请求的结果;
  3. 使用 useRef 追踪最新的请求标识。
解决方案 1:AbortController 取消旧请求(官方推荐)

这是最彻底的方案,直接终止未完成的旧请求,从根源上避免竞态。

jsx
import { useEffect, useState } from 'react';

function RaceConditionDemo() {
  const [userId, setUserId] = useState(1);
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // 1. 创建中止控制器
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchUser = async () => {
      setLoading(true);
      try {
        // 2. 请求时关联 signal
        const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, { signal });
        if (!response.ok) throw new Error('请求失败');
        
        const result = await response.json();
        // 3. 只有未被中止的请求,才会更新状态
        setData(result);
      } catch (err) {
        // 忽略主动中止的错误(避免控制台警告)
        if (err.name !== 'AbortError') {
          console.error('请求错误:', err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // 4. 依赖变化/组件卸载时,取消旧请求
    return () => controller.abort();
  }, [userId]); // 依赖 userId,变化时触发新请求

  return (
    <div>
      <button onClick={() => setUserId(1)}>用户1</button>
      <button onClick={() => setUserId(2)}>用户2</button>
      <button onClick={() => setUserId(3)}>用户3</button>
      {loading && <div>加载中...</div>}
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
}

export default RaceConditionDemo;
方案 2:标记变量忽略旧请求结果(兼容旧环境)

如果你的环境不支持 AbortController(如部分老旧浏览器),可以用一个“是否有效”的标记变量,让旧请求的结果不更新状态。

jsx
useEffect(() => {
  // 1. 定义标记变量,标记当前请求是否有效
  let isRequestValid = true;

  const fetchUser = async () => {
    setLoading(true);
    try {
      const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
      const result = await response.json();
      
      // 2. 只有标记为有效时,才更新状态
      if (isRequestValid) {
        setData(result);
      }
    } catch (err) {
      if (isRequestValid) {
        console.error('请求错误:', err);
      }
    } finally {
      if (isRequestValid) {
        setLoading(false);
      }
    }
  };

  fetchUser();

  // 3. 依赖变化/组件卸载时,将标记置为无效
  return () => {
    isRequestValid = false;
  };
}, [userId]);
关键细节说明
  1. AbortController 的优势:不仅能避免竞态,还能节省网络资源(终止未完成的请求),是 React 官方最推荐的方案;
  2. 标记变量的注意事项:变量必须定义在 useEffect 内部,确保每次触发都有独立的标记,不能用 useState(会触发额外渲染);
  3. 竞态的常见场景:
    • 快速切换筛选条件/分页;
    • 搜索框实时联想(输入过快触发多次请求);
    • 组件还在加载时被卸载,又重新挂载。
总结
  1. 优先方案:使用 AbortController 取消旧请求,既解决竞态又节省网络资源;
  2. 兼容方案:用 useEffect 内部的标记变量(如 isRequestValid),忽略旧请求的状态更新;
  3. 核心逻辑:让“过时”的异步操作要么被终止,要么其结果不参与状态更新,确保最终渲染的是最新请求的数据。

这三种方案都能有效解决竞态条件问题,你可以根据项目的环境和复杂度选择最合适的方式。

总结

  1. 核心规则:useEffect 回调不能直接是 async 函数,需在内部定义 async 函数并立即调用。
  2. 错误处理:必须用 try/catch 捕获异步操作的错误,避免未处理的 Promise 异常。
  3. 内存安全:使用 AbortController 取消未完成的异步请求,防止组件卸载后执行 setState。
  4. 竞态条件:使用 AbortController 取消未完成的异步请求,防止旧请求数据覆盖新请求。

这是 React 官方推荐的异步操作写法,既符合 Hook 的规则,又能避免内存泄漏和警告问题。

评论
0/100