R26第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

请求竞态与取消

请求竞态与取消 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这一课解决异步请求的经典事故:快速切换时,先发出但更慢的旧请求最后到达,把新页面覆盖掉。读完你应该能解释竞态的成因,并会用清理函数与 AbortController 两种手段保证「界面只认最后一次请求」。

问题场景

课程详情页顶部有两个课程标签,点击后请求接口拿详情。为了模拟真实网络,「课程 1」配了 3 秒延迟,「课程 2」只配 300 毫秒。先点课程 1 再马上点课程 2:

// 有竞态的版本:谁后到谁说了算
function CoursePage({ courseId }) {
  const [course, setCourse] = useState(null);

  useEffect(() => {
    fetchCourse(courseId).then(setCourse); // 慢的旧响应也会 setCourse
  }, [courseId]);

  // ...
}

结果:界面先显示课程 2,3 秒后又被课程 1 的响应覆盖——页面停在了一个你没点过的课程上。

核心解释

竞态(race condition)指多个异步操作的结果到达顺序和发起顺序不一致,后发先至。请求有快有慢,React 不会替你判断哪个响应是「最新的」,两次 fetchCourse 都会执行 then(setCourse),谁晚到谁覆盖谁。

修复思路是给每轮请求定一条归属规则:只有最后一次发起的请求才有资格写状态。useEffect 的清理函数正好提供时机——下一次 Effect 运行前、以及组件卸载前,都会先执行上一轮的清理:

useEffect(() => {
  let ignore = false; // 本轮请求的「通行证」

  fetchCourse(courseId).then((data) => {
    if (!ignore) setCourse(data); // 过期的请求拿不到通行证
  });

  return () => {
    ignore = true; // courseId 变了或组件卸载:本轮作废
  };
}, [courseId]);

ignore 声明在 Effect 内部,每轮各发一张。courseId 从 1 变成 2 时,先跑第一轮的清理(作废 1 的通行证),再跑第二轮(发新证)。于是课程 1 的响应再慢也写不进来。

如果想连请求本身都省掉,用 AbortController:把 signal 传给 fetch,清理时 abort(),浏览器会真正中断连接,catch 里会收到 AbortError——注意别把它当故障弹给用户。

示例拆解

本课实验区是可运行的对照版本,用开关切换两种行为:

useEffect(() => {
  let ignore = false;
  setLoading(true);
  fetchCourse(courseId).then((data) => {
    if (!ignore || buggy) { // buggy=true 时跳过归属检查,复现事故
      setCourse(data);
      setLoading(false);
    }
  });
  return () => { ignore = true; };
}, [courseId, buggy]);
  • fetchCourse 在实验里用 setTimeout 模拟延迟,课程 1 慢、课程 2 快,和真实网络行为一致。
  • 「已修复」模式:点课程 1 再立刻点课程 2,界面最终停在课程 2,课程 1 的响应被 ignore 拦下。
  • 「有 bug」模式:同样操作,3 秒后界面被课程 1 覆盖,复现事故。
  • 真实项目里把 fetchCourse 换成 fetch(url, { signal }) 并在清理里 controller.abort(),归属策略不变。

动手任务

打开本课实验区:

  1. 勾选「有 bug 模式」,点课程 1 后 0.5 秒内点课程 2,等 3 秒观察覆盖现象,用自己的话描述发生了什么。
  2. 取消勾选重复同样操作,确认界面最终停在课程 2,且加载文案里显示的 id 始终是最后点击的。
  3. 进阶:把 fetchCourse 改成接收 signal,在清理函数里 abort(),确认取消后不再出现「丢弃响应」的时机——这一步在本地项目里做更接近真实。

常见错误

  • 把清理函数写成异步的:清理必须是同步函数,abort() 要直接写在函数体里,不能 return 一个 Promise。
  • ignore 声明在组件体里:每次渲染都重新执行组件函数,组件体里的变量跟着重置,通行证失效;必须在 Effect 内部声明。
  • abort 后照常提示错误:catch 里要先判断 error.name === 'AbortError',主动取消不是故障。
  • 以为取消能替代一切:ignore 归属策略不依赖浏览器支持,任何异步任务都能用;AbortController 只是额外省流量,两者互补而非二选一。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能说清竞态成因:响应到达顺序不等于发起顺序,React 不会自动挑选最新响应
  • 知道清理函数的两个触发时机:下一次 Effect 之前、组件卸载之前
  • 能说出两种策略的分工:ignore 管归属,AbortController 管取消

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码较难

阅读代码(React 18+)。搜索关键字先输入「re」触发请求 A,300ms 内又输入「react」触发请求 B。A 在 500ms 后返回,B 在 100ms 后返回。最终列表显示哪次的结果?

function UserSearch({ keyword }) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    let ignore = false;
    fetch('/api/users?kw=' + keyword)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUsers(data);
      });
    return () => {
      ignore = true;
    };
  }, [keyword]);

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}
Q2故障定位中等

bug 现场:详情页根据 id 请求文章,快速切换两篇文章时偶尔显示错文章的内容——先点 A 再点 B,A 的响应后到覆盖了 B 的。下列修法最可靠的是?

useEffect(() => {
  fetch('/api/article/' + id)
    .then((r) => r.json())
    .then((data) => setArticle(data));
}, [id]);

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...