React 竞态条件防御:在 useEffect 中利用闭包清理函数解决异步数据覆盖的工程实践

各位同学,大家好!

欢迎来到今天的“React 幽灵猎人”训练营。我是你们的讲师,一个在 React 的坑里摸爬滚打多年,头发比发际线后退得还慢的资深工程师。

今天我们要聊的话题,听起来很高大上,很学术,甚至有点吓人——竞态条件

在计算机科学里,竞态条件通常意味着“系统崩溃”或者“数据丢失”。但在 React 的世界里,竞态条件更像是一个幽灵,它潜伏在你的 useEffect 里面,在你写完代码、部署上线、甚至用户都以为程序跑得很完美的时候,突然跳出来给你一记闷棍,然后把你的 UI 弄得像鬼屋一样乱七八糟。

今天,我们不谈 Redux,不谈 Context,我们只谈最核心、最致命的那个钩子:useEffect。我们要一起揭开闭包的神秘面纱,学会如何用清理函数这把“银色子弹”,把那些异步数据覆盖的幽灵,一枪毙命。

准备好了吗?让我们把键盘擦干净,开始干活。


第一章:幽灵的诞生——当“快”变成了“坏”

首先,我们来还原一下这个幽灵诞生的场景。

假设你在做一个电商 App 的搜索功能。这很常见,对吧?用户在输入框里打字,你就要发请求去后台查数据。

为了简单,我们假设这个请求是同步的(虽然现实中都是异步的,但我们先从简单的开始)。代码大概长这样:

import { useState, useEffect } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleInputChange = (e) => {
    setQuery(e.target.value);
  };

  useEffect(() => {
    // 这里的逻辑是:每当 query 变了,就发请求
    console.log('发送请求,查询内容:', query);
    fetch('/api/search?q=' + query)
      .then(res => res.json())
      .then(data => {
        setResults(data); // 更新 UI
      });
  }, [query]); // 依赖项是 query

  return (
    <div>
      <input type="text" onChange={handleInputChange} placeholder="输入搜索词..." />
      <ul>
        {results.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}

这段代码看起来完美无缺,对吧?符合 React 的所有规范。但是,让我给你讲个恐怖故事。

假设你的网络很快,你的电脑性能很强,用户是个急性子。他输入了 “a”,然后几乎是瞬间输入了 “ab”,然后是 “abc”,然后是 “abcd”。

发生了什么?

  1. 时刻 T1:用户输入 “a”。useEffect 触发。query 是 “a”。请求 A 发出。
  2. 时刻 T2:用户输入 “ab”。useEffect 再次触发。query 是 “ab”。请求 B 发出。
  3. 时刻 T3:用户输入 “abc”。useEffect 再次触发。query 是 “abc”。请求 C 发出。
  4. 时刻 T4:请求 A 完成了。它拿到了 “a” 的数据。它调用 setResults(dataA)
  5. 时刻 T5:请求 B 完成了。它拿到了 “ab” 的数据。它调用 setResults(dataB)
  6. 时刻 T6:请求 C 完成了。它拿到了 “abc” 的数据。它调用 setResults(dataC)

到了最后,屏幕上显示的是 “abc” 的搜索结果。但是,用户其实已经不再输入了,甚至可能已经把页面关了。那个 “abc” 的请求,就像一个过期的罐头,虽然还在保质期内,但你根本不需要它了。

更糟糕的是,如果请求 A 是从服务器获取的“热门商品”,而请求 C 是“冷门商品”,用户最后看到的是冷门商品,但他想看的是热门商品。这叫数据覆盖,也叫做竞态条件

如果你是用户,你会想砸了手机。如果你是老板,你会想砸了写这段代码的工程师。


第二章:罪魁祸首——闭包的“时光机”

那么,为什么 React 没有阻止这种情况?为什么 useEffect 会发这么多请求?

这就不得不提到 React 闭包的一个经典特性:“陈旧的环境”

useEffect 第一次运行的时候,React 会把它包在一个闭包里。这个闭包里记录了当时的 query 是 “a”。虽然 query 变成了 “ab”,但是,那个闭包里的变量并没有自动更新。

这就好比你拍了一张“a”的照片,然后你把“a”擦掉了,写上了“ab”。但是那张照片(闭包)还是停留在“a”的时候。

所以,当请求 A 完成并执行 setResults 时,它执行的是闭包里的逻辑,它并不知道 query 已经变成了 “ab”。

关键点来了:React 是如何发现这个问题的?

React 的 useEffect 依赖数组 [query] 就像个严格的保安。如果 query 变了,保安就会说:“嘿,旧的 useEffect 要下班了,新的要上岗了,赶紧跑!” 这就是清理函数。

但是! 如果请求 A 是在 query 变成 “ab” 之前发出的,那么在请求 A 完成之前,query 肯定没变,所以 useEffect 不会触发清理函数。请求 A 依然在后台跑。

直到请求 A 完成,它才带着“陈旧的数据”冲向 setState


第三章:银色子弹——AbortController(现代方案)

既然知道了幽灵的存在,我们怎么杀它?

现代浏览器(以及 React 18+)给我们提供了一把非常优雅的武器:AbortController

它的原理很简单:在清理函数里,告诉浏览器“取消这次请求”。

一旦请求被取消,浏览器就不会再处理返回的数据,也就不会更新我们的 UI。这就像是你给快递员打电话说:“别送了,我搬家了!”快递员就会把包裹退回,或者扔进垃圾桶。

让我们修改一下代码:

useEffect(() => {
  // 1. 创建一个 AbortController 实例,就像拿着遥控器
  const controller = new AbortController();
  const signal = controller.signal;

  console.log('发送请求,查询内容:', query);

  // 2. 在 fetch 请求中传入 signal
  fetch('/api/search?q=' + query, { signal })
    .then(res => {
      if (!res.ok) throw new Error('Network response was not ok');
      return res.json();
    })
    .then(data => {
      // 3. 检查信号是否被中止
      if (signal.aborted) {
        console.log('请求被取消,忽略结果');
        return;
      }
      setResults(data);
    })
    .catch(err => {
      // 注意:如果请求被取消,fetch 会抛出 AbortError
      if (err.name === 'AbortError') {
        console.log('请求已取消,这是正常的清理行为');
      } else {
        console.error('请求失败', err);
      }
    });

  // 4. 返回清理函数,这是银色子弹的发射按钮
  return () => {
    console.log('组件即将卸载或依赖项变化,取消请求');
    controller.abort();
  };
}, [query]);

效果如何?

当用户快速输入 “a” -> “ab” -> “abc” 时:

  1. 用户输入 “a”,请求 A 发出。
  2. 用户输入 “ab”,请求 B 发出。此时,React 检测到依赖项变化,运行清理函数 controller.abort() 请求 A 被取消!
  3. 用户输入 “abc”,请求 C 发出。此时,React 再次运行清理函数,取消请求 B。

最终,只有请求 C 完成了。请求 A 和 B 都在半路被“枪毙”了。数据覆盖的问题完美解决。

这招好吗?太好了!

但是,同学们,我也得泼点冷水。AbortController 有个“坑”。

如果你在 fetch.then 回调里直接调用 setResults,如果此时组件已经卸载了,React 会警告你:

“Can’t perform a React state update on an unmounted component.”

虽然这通常不会导致应用崩溃,但它就像是在垃圾堆里大喊大叫,很烦人。

所以,我们要加个判断。虽然上面的代码里写了 if (signal.aborted),但更标准的写法是检查组件是否还在挂载。我们可以用一个 useRef 来标记挂载状态。

const isMounted = useRef(true);

useEffect(() => {
  isMounted.current = true;
  const controller = new AbortController();

  fetch(...)
    .then(data => {
      // 先检查信号,再检查组件是否挂载
      if (!controller.signal.aborted && isMounted.current) {
        setResults(data);
      }
    });

  return () => {
    isMounted.current = false; // 标记组件已卸载
    controller.abort(); // 取消请求
  };
}, [query]);

第四章:老派巫师——手动清理与 Ref

虽然 AbortController 是现代标准,但并不是所有场景都能用它。比如:

  1. 你在用旧版浏览器(IE11)或者某些老项目。
  2. 你用的是 WebSocket,而不是 HTTP 请求。
  3. 你用的是第三方库,它不支持 AbortSignal。

这时候,我们就得用“老派巫师”的魔法了:标志变量

核心思想:在清理函数里,把标志设为 false,在异步回调里,检查标志,如果为 false 就不更新 UI。

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

  useEffect(() => {
    let isActive = true; // 这就是我们的标志位
    setLoading(true);

    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const result = await response.json();

        // 关键步骤:检查标志位
        if (isActive) {
          setData(result);
          setLoading(false);
        }
      } catch (err) {
        if (isActive) {
          setError(err);
          setLoading(false);
        }
      }
    };

    fetchData();

    // 清理函数
    return () => {
      console.log('组件卸载,停止处理数据');
      isActive = false; // 主动切断连接
    };
  }, []);

  return (
    <div>
      {loading ? <p>加载中...</p> : <p>数据:{JSON.stringify(data)}</p>}
    </div>
  );
}

这个方法的妙处在于:

它不依赖浏览器的特性。只要你在清理函数里执行了 isActive = false,那么无论异步操作何时返回,只要它返回了,if (isActive) 这道门就会把它挡在外面。

这就像是你给门装了一个锁。不管小偷(异步数据)什么时候来,只要门关了,他就进不来。


第五章:进阶挑战——useRef 的“最新值”陷阱

既然我们知道了 useEffect 会捕获旧值,那我们能不能在 useEffect 里拿到最新的值呢?

答案是:可以,用 useRef

useRef 返回的对象,在组件的整个生命周期内,它的 .current 属性始终指向同一个内存地址,所以它不会被 React 的闭包机制“冻结”。

这是一个非常强大的技巧。

function AdvancedComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // 1. 创建一个 ref 来存储最新的 query
  const queryRef = useRef(query);

  useEffect(() => {
    // 2. 每次 query 变化,更新 ref
    queryRef.current = query;
  }, [query]);

  useEffect(() => {
    const fetchData = async () => {
      // 3. 在异步函数里,读取 ref 的 current
      // 即使外部的 query 变了,这里拿到的也是最新的
      const currentQuery = queryRef.current;
      console.log('当前请求的 query 是:', currentQuery);

      const res = await fetch(`/api/search?q=${currentQuery}`);
      const data = await res.json();
      setResults(data);
    };

    fetchData();
  }, []); // 依赖数组是空的,useEffect 只运行一次
}

等等,这里有个大坑!

如果你在 fetchData 里面使用了 queryRef.current,虽然你拿到了最新的值,但你没有取消之前的请求啊!

如果你依赖 useRef 来获取最新值,你通常会配合 AbortController 使用,或者配合手动清理标志位使用。

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

  const fetchData = async () => {
    const currentQuery = queryRef.current; // 获取最新值
    const res = await fetch(`/api/search?q=${currentQuery}`, { signal: controller.signal });
    const data = await res.json();

    if (!isCancelled) {
      setResults(data);
    }
  };

  fetchData();

  return () => {
    isCancelled = true; // 手动清理
    controller.abort(); // 取消请求
  };
}, []);

useRef 的作用在于,当你需要在一个长期运行的副作用(比如一个定时器,或者一个 WebSocket 连接)中,获取最新的 props 或 state 时,它是一个绝佳的“时间旅行望远镜”。


第六章:WebSocket 的噩梦——实时通信中的竞态

聊完 HTTP 请求,我们聊聊更刺激的——WebSocket。

WebSocket 是长连接,一旦建立,就会一直保持。如果处理不好,竞态条件会变成“数据爆炸”。

假设你有一个聊天室应用。用户 A 发送消息,服务端广播给所有人。

如果用户 A 快速点击了三次“发送”,而网络稍微有点延迟,可能会发生这种情况:

  1. 消息 1 发出。
  2. 消息 2 发出。
  3. 消息 3 发出。

服务端收到了三条消息。用户 A 的本地状态可能还没更新,导致他又发了第四条。

更糟糕的是,如果服务端回传消息(ACK),或者服务端有某种“心跳检测”,如果处理不好,你的 UI 会像抽风一样疯狂跳动。

对于 WebSocket,我们的防御策略是:

  1. 消息去重:给每条消息加一个 ID,如果收到 ID 相同的消息,忽略。
  2. 状态锁:在发送消息的函数里,设置一个 isSending 状态。如果 isSending 为 true,阻止新的发送请求。
  3. 连接管理:确保在组件卸载时,关闭 WebSocket 连接。这通常是清理函数最关键的工作。
function ChatRoom({ userId }) {
  const [messages, setMessages] = useState([]);
  const [socket, setSocket] = useState(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const newSocket = new WebSocket('wss://api.chat.com');

    newSocket.onopen = () => {
      setIsConnected(true);
      console.log('WebSocket 连接已建立');
    };

    newSocket.onmessage = (event) => {
      const message = JSON.parse(event.data);

      // 防御:检查消息是否属于当前用户(简单的业务逻辑防御)
      if (message.senderId === userId) {
        // 检查是否已经存在(防止重复)
        setMessages(prev => {
          const exists = prev.some(m => m.id === message.id);
          if (exists) return prev;
          return [...prev, message];
        });
      }
    };

    setSocket(newSocket);

    // 清理函数:这是 WebSocket 的生命线
    return () => {
      console.log('断开 WebSocket 连接');
      newSocket.close();
    };
  }, [userId]);

  const sendMessage = (text) => {
    if (!isConnected || !socket) return;

    const message = {
      id: Date.now(),
      text,
      senderId: userId,
      timestamp: Date.now()
    };

    socket.send(JSON.stringify(message));
    setMessages(prev => [...prev, message]); // 立即更新本地状态(乐观更新)
  };

  return (
    <div>
      <ul>
        {messages.map(m => <li key={m.id}>{m.text}</li>)}
      </ul>
      <button onClick={() => sendMessage('Hello')}>发送</button>
    </div>
  );
}

在这个例子里,清理函数 return () => newSocket.close() 是至关重要的。如果你忘了它,当用户离开聊天室时,WebSocket 依然在后台运行,服务端依然在向你推送消息,你的 onmessage 回调依然会触发,导致内存泄漏和逻辑错误。


第七章:调试的艺术——如何发现幽灵

写好了代码,怎么知道有没有竞态条件呢?

React 官方提供了一些工具,但作为老司机,我们有自己的直觉和技巧。

1. Chrome Performance 面板

打开 Chrome 的 Performance 面板,录制你的操作(比如快速输入搜索词)。然后回放。

你会看到大量的 fetch 请求。如果请求的数量超过了你预期的数量(比如用户只输入了 3 次,却发出了 10 个请求),那就是有竞态条件。

2. 依赖项警告

如果你在 useEffect 的依赖数组里漏掉了某个变量,比如 query,那么清理函数就永远不会运行。

// 错误示范
useEffect(() => {
  fetch('/api/data');
}, []); // 缺少 query

// 这意味着,当 query 变化时,上一次的请求还在跑,新的请求也发了。
// 但因为依赖数组没变,清理函数没跑,旧请求没法被取消。

3. 控制台日志

这是最笨但也最有效的方法。在清理函数和请求回调里加 console.log

useEffect(() => {
  console.log('Effect 开始,Query:', query);
  const controller = new AbortController();

  fetch(..., { signal: controller.signal })
    .then(data => {
      console.log('数据回来了,Query:', query); // 这里打印的 query 是闭包里的旧值!
      setResults(data);
    });

  return () => {
    console.log('清理函数运行,取消请求');
    controller.abort();
  };
}, [query]);

如果你发现“数据回来了”的日志里的 Query 是旧的,而“清理函数运行”的日志里 Query 是新的,你就知道闭包出问题了。


第八章:架构视角——如何构建防御系统

作为资深工程师,我们不能每次写代码都像拆弹专家一样小心翼翼。我们需要建立一套防御体系。

1. 自定义 Hook 封装

把通用的异步请求逻辑封装成一个 Hook,比如 useFetch。在这个 Hook 里内置 AbortController 和清理逻辑。

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

  useEffect(() => {
    abortControllerRef.current = new AbortController();
    setLoading(true);

    fetch(url, { signal: abortControllerRef.current.signal })
      .then(res => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url]);

  return { data, error, loading };
}

使用起来就简单多了:

function UserProfile({ userId }) {
  const { data, loading, error } = useFetch(`/api/users/${userId}`);
  // ... 渲染逻辑
}

2. 乐观更新

对于一些副作用不严重的操作(比如点赞、删除),我们可以先更新 UI,然后再发请求。如果请求失败了,再回滚 UI。

const toggleLike = async () => {
  setLiked(!liked); // 立即更新 UI,给用户反馈
  try {
    await api.toggleLike(id);
  } catch (err) {
    setLiked(!liked); // 失败了,恢复原状
  }
};

这种方式虽然不能完全消除竞态条件,但它能极大提升用户体验,因为用户感觉不到网络延迟带来的“等待”。

3. 防抖与节流

对于高频触发的事件(比如 input),我们可以使用 lodash.debouncelodash.throttle。这能减少 useEffect 的触发次数,从而减少请求次数。

import { debounce } from 'lodash';

const debouncedHandleChange = debounce((e) => {
  setQuery(e.target.value);
}, 300);

<input onChange={debouncedHandleChange} />

第九章:React 18 的变化——并发模式的影响

最后,我们得聊聊 React 18。React 18 引入了并发模式(Concurrent Rendering)。

并发模式让 React 能够同时准备多个版本的 UI。这意味着,你的组件可能会被挂起、中断,然后再恢复。

这对竞态条件意味着什么?

以前: 用户输入 -> useEffect 触发 -> 请求发出 -> 组件渲染。
现在: 用户输入 -> useEffect 触发 -> 请求发出 -> React 打断渲染 -> 用户又输入 -> useEffect 触发 -> 请求发出 -> React 恢复第一次渲染

在并发模式下,useEffect 的清理函数可能会被多次调用。这比以前更麻烦了,因为你的清理函数可能还没跑完,新的清理函数又来了。

所以,在 React 18 中,AbortController 变得更加重要了。你必须在清理函数里处理这种“多次调用”的情况。

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

  const fetchData = async () => {
    try {
      const res = await fetch('/api/data', { signal: controller.signal });
      const data = await res.json();

      if (!isCancelled) {
        setResults(data);
      }
    } catch (e) {
      if (e.name === 'AbortError' && !isCancelled) {
        console.log('请求被取消');
      }
    }
  };

  fetchData();

  return () => {
    isCancelled = true; // 标志位必须设为 true
    controller.abort();
  };
}, []);

并发模式让 React 变得更智能,但也让副作用变得更复杂。如果你还在用 React 17 的思维写 React 18 的代码,你肯定会遇到各种奇怪的 Bug。


第十章:总结与心态

好了,同学们,今天的讲座接近尾声。

我们今天讲了什么?

  1. 竞态条件:异步操作导致的数据覆盖,是 React 开发中的头号幽灵。
  2. 闭包陷阱useEffect 捕获旧值是根本原因。
  3. 银色子弹:使用 AbortController 取消未完成的请求。
  4. 老派巫师:使用 useRef 和标志位手动清理。
  5. WebSocket:长连接场景下的连接管理与消息去重。
  6. 防御体系:封装自定义 Hook,使用乐观更新,防抖节流。
  7. 并发模式:React 18 带来的新挑战,需要更严格的清理逻辑。

最后,我想送给大家一句人生格言,也适用于编程:

“永远不要信任异步操作。它们可能会迟到,可能会失败,最重要的是,它们可能会在你不需要的时候回来找你。”

在 React 的世界里,清理函数就是你的后悔药。它是你在 useEffect 里唯一能掌控全局、决定“是否继续执行”的权力。

当你写下一个 useEffect 时,一定要问自己一个问题:
“如果现在组件卸载了,我发出的这个请求应该怎么办?”

如果你的答案是“不管了,反正发出去就发出去吧”,那么你就是在制造幽灵。

如果你的答案是“必须取消,必须停止,必须清空”,那么恭喜你,你已经掌握了 React 竞态条件防御的核心精髓。

希望今天的讲座能帮大家把那些潜伏在代码里的幽灵,统统清理干净。记住,写代码就像养宠物,你不能让它们在后台乱跑,你得时刻看着它们。

现在,拿起你们的键盘,去修复那些该死的 Bug 吧!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注