React 请求取消协议:利用 AbortController 在 React 组件卸载时自动中止待处理网络请求

张开发
2026/6/11 5:16:30 15 分钟阅读
React 请求取消协议:利用 AbortController 在 React 组件卸载时自动中止待处理网络请求
大家好我是你们的老朋友那个发誓再也不写没有 AbortController 的代码的专家。今天我们不聊那些花里胡哨的框架也不搞那些虚头巴脑的设计模式。今天我们来聊聊一个稍微有点“脏”的话题网络请求的身后事。在 React 的世界里组件就像是一场短暂的派对。用户进来狂欢然后离开。派对结束了是不是该把垃圾带走是不是该把喝醉的朋友送回家如果你的网络请求不懂这个道理那它就是最烦人的“派对肇事者”。它们在派对组件结束后依然赖在舞池里蹦迪不仅浪费带宽还可能导致你的应用出现莫名其妙的 Bug比如“幽灵数据”。来把咖啡放下我们开始这场关于“如何体面地终止请求”的讲座。第一部分幽灵请求与内存泄漏首先让我们想象一个场景。你有一个搜索组件。用户在输入框里打字每次输入你就发一个请求去服务器查数据。这很正常对吧现在用户是个急性子他在输入框里疯狂敲击键盘输入了 “A”然后 “B”然后 “C”。如果没有任何处理你的组件会瞬间发出去 3 个请求。这还没完用户可能觉得手滑又退格删掉了 “C”。这时候组件卸载了。但是那两个没被删掉的请求还在服务器上跑呢。它们就像两个不知死活的幽灵还在往你的组件里塞数据。当你再次打开这个组件时你会得到什么可能是“C”的数据可能是“B”的数据甚至可能是一个乱码。这就是竞态条件。更糟糕的是如果组件卸载后服务器还在给这个组件发数据那个组件其实已经挂了Unmounted但它的state还在setState还在执行。这会导致内存泄漏甚至可能导致你的应用崩溃。所以我们的核心目标只有一个当组件死亡时我们要有办法给还在路上的请求发个信号“嘿别送了我不想要了下车”这就是AbortController登场的时候了。第二部分AbortController 是什么鬼在 ES2017 之前HTTP 请求就像是一列没有刹车的火车。你发车了就不管了直到到达终点。如果火车请求中途脱轨了组件卸载它依然会撞向终点。AbortController就是那根刹车线。它不是魔法它是一个标准的 Web API。它的核心思想是“信号”。你可以创建一个AbortSignal然后把这个信号传递给你的请求。当你想取消请求时你只需要调用abort()方法。这个方法会触发AbortSignal的一个事件告诉请求“停”在 React 中我们通常利用useEffect的清理函数cleanup function来实现这一点。第三部分原生 Fetch 的完美实践让我们先从最标准的fetchAPI 开始。这就像是用最原始的食材做饭但做出来的菜往往最健康。核心原则在useEffect里创建AbortController。把controller.signal传给fetch。在useEffect返回的清理函数里调用controller.abort()。下面是代码请仔细阅读这比任何教科书都管用import React, { useState, useEffect } from react; function UserProfile({ userId }) { const [user, setUser] useState(null); const [error, setError] useState(null); useEffect(() { // 1. 创建 AbortController 实例 const controller new AbortController(); const signal controller.signal; console.log(请求用户 ${userId} 的数据...); // 2. 发起请求传入 signal fetch(https://api.example.com/users/${userId}, { signal }) .then(response { if (!response.ok) { throw new Error(网络请求失败); } return response.json(); }) .then(data { // 3. 成功拿到数据更新 state setUser(data); setError(null); }) .catch(err { // 4. 关键步骤判断错误类型 // 如果是 AbortError说明是组件卸载导致的取消这不算真正的错误 if (err.name AbortError) { console.log(请求被取消组件已卸载); } else { // 真正的网络错误或其他异常 setError(err.message); } }); // 5. 清理函数组件卸载时执行 return () { console.log(组件即将卸载正在发送取消信号...); controller.abort(); }; }, [userId]); // 依赖项是 userId if (error) return div错误: {error}/div; if (!user) return div加载中.../div; return ( div h1{user.name}/h1 p邮箱: {user.email}/p /div ); } export default UserProfile;这里有几个点需要特别注意controller.abort()的位置它必须在useEffect返回的函数里。React 会在组件卸载前先执行这个清理函数。signal参数这是fetch接受的第二个参数对象。它告诉 fetch“嘿如果这个信号变真了你就停下。”AbortError的处理这是新手最容易翻车的地方。当你调用abort()时Promise 会 reject错误对象的名字是AbortError。如果你在 catch 里把它当成真正的网络错误比如 404 或 500来处理你的 UI 就会疯狂闪烁“加载失败”明明数据是正常的。你必须区分这是“我主动取消的”还是“服务器挂了”。第四部分Axios 的“叛逆”与驯服说到网络请求谁还没用过 Axios 呢它是个好孩子但在“请求取消”这件事上它有点倔强不像 Fetch 那么原生。Fetch 是 W3C 的标准AbortController 也是标准。但 Axios 是第三方库它的设计初衷并没有把 AbortController 放在第一位。不过Axios 从 v0.22.0 开始就支持 AbortController 了。这就像是你给一匹老马装上了涡轮增压器。驯服 Axios 的代码示例import axios from axios; import React, { useState, useEffect } from react; function ProductList() { const [products, setProducts] useState([]); const [loading, setLoading] useState(false); useEffect(() { const controller new AbortController(); const fetchData async () { setLoading(true); try { // 1. 传入 signal const response await axios.get(https://api.example.com/products, { signal: controller.signal }); setProducts(response.data); } catch (err) { // 2. 同样判断 AbortError if (axios.isCancel(err)) { console.log(Axios 请求被取消:, err.message); } else { console.error(Axios 请求出错:, err); } } finally { setLoading(false); } }; fetchData(); return () { controller.abort(); }; }, []); return ( div h2商品列表/h2 {loading ? p正在加载请稍候.../p : ul{/* 渲染列表 */}/ul} /div ); }注意那个axios.isCancel(err)Axios 提供了一个工具函数来判断错误是否是因为取消操作。这比手动判断err.name AbortError要优雅一点也更符合 Axios 的风格。第五部分竞态条件的终极解法前面我们讲了怎么取消请求。但有时候取消请求是为了解决更棘手的问题竞态条件。让我们回到那个“快速点击”的场景。用户点击了“加载详情”请求 A 发出去了。然后用户手一抖或者觉得无聊又点了一次“加载详情”请求 B 发出去了。如果请求 A 的响应回来得比请求 B 慢而组件又恰好在请求 A 回来之前卸载了或者请求 B 回来后覆盖了 A用户看到的可能就是错误的 A 的数据。这时候仅仅取消“旧”的请求是不够的。我们需要一种机制确保我们只处理最新的请求的结果。策略每次请求开始时生成一个唯一的requestId可以用时间戳。在setState或处理数据时检查当前的数据是否属于这次请求。如果不属于说明是旧请求回来的直接丢弃。代码示例import React, { useState, useEffect } from react; function SearchComponent() { const [query, setQuery] useState(); const [results, setResults] useState([]); const [loading, setLoading] useState(false); const [currentRequestId, setCurrentRequestId] useState(0); useEffect(() { const controller new AbortController(); const signal controller.signal; // 生成唯一 ID const requestId Date.now(); setCurrentRequestId(requestId); setLoading(true); setResults([]); // 清空旧数据避免展示过时的结果 const fetchSearch async () { try { const response await fetch(https://api.example.com/search?q${query}, { signal }); const data await response.json(); // 核心逻辑检查是否是最新请求 if (requestId currentRequestId) { setResults(data); } else { console.log(丢弃过时的结果:, data); } } catch (err) { if (err.name ! AbortError) { console.error(搜索失败, err); } } finally { setLoading(false); } }; fetchSearch(); return () { controller.abort(); }; }, [query]); // 只有 query 变化时才发请求 return ( div input typetext value{query} onChange{(e) setQuery(e.target.value)} / {loading p搜索中.../p} ul {results.map(item li key{item.id}{item.title}/li)} /ul /div ); } export default SearchComponent;在这个例子中每次query变化我们都更新currentRequestId。当数据回来时我们比对requestId。如果发现回来的数据不是最新的ID 不匹配我们就直接console.log丢弃它。这就像是你在排队买票前面的人插队了虽然你买了票但如果插队的人先拿到了票你就得把票扔了因为那是别人的票。第六部分自定义 Hook 封装的艺术如果你在多个组件里都这么写useEffectAbortController你会发现代码重复率高达 80%。作为一名资深专家我们怎么能容忍这种低级重复呢我们需要封装一个自定义 Hook。这个 Hook 应该接收url和options然后返回data,error,loading。让我们来编写useFetchWithAbortimport { useState, useEffect } from react; /** * 一个支持 AbortController 的自定义 Hook * param {string} url - 请求地址 * param {object} options - fetch 的额外配置 */ export function useFetchWithAbort(url, options {}) { const [data, setData] useState(null); const [error, setError] useState(null); const [loading, setLoading] useState(false); useEffect(() { const controller new AbortController(); const signal controller.signal; const fetchData async () { setLoading(true); setError(null); try { const response await fetch(url, { ...options, signal }); if (!response.ok) { throw new Error(HTTP error! status: ${response.status}); } const json await response.json(); setData(json); } catch (err) { // 如果是取消错误我们通常不设置 error 状态因为这不是真正的错误 if (err.name ! AbortError) { setError(err); } } finally { setLoading(false); } }; fetchData(); return () { controller.abort(); }; }, [url, options]); // 依赖项 return { data, error, loading }; }使用示例import React from react; import { useFetchWithAbort } from ./hooks/useFetchWithAbort; function Articles() { // 使用 Hook代码瞬间清爽 const { data: articles, loading, error } useFetchWithAbort( https://jsonplaceholder.typicode.com/posts ); if (loading) return div正在加载文章.../div; if (error) return div加载失败: {error.message}/div; return ( ul {articles.map(article ( li key{article.id}{article.title}/li ))} /ul ); }现在你的代码里到处都是这种优雅的 Hook而不再是那些像意大利面一样纠缠不清的useEffect块。第七部分那些不该中止的请求虽然我们极力推崇“请求取消”但凡事都有例外。在 React 的某些特殊场景下过早地中止请求可能会导致问题。1. 服务端渲染 (SSR) 的陷阱如果你在 Next.js 或 Gatsby 中使用useEffect并依赖useEffect的清理函数来取消请求你可能会遇到麻烦。在 SSR服务端渲染过程中useEffect里的代码根本不会执行。这意味着清理函数也不会执行。如果组件在服务端渲染然后被卸载比如用户跳转了页面那么在服务端发起的请求如果有的话可能永远不会被取消。解决方案在useEffect中判断typeof window ! undefined或者使用isMounted标志位。useEffect(() { let isMounted true; const fetchData async () { // ... if (isMounted) { setData(data); } }; fetchData(); return () { isMounted false; controller.abort(); }; }, []);2. 流式传输如果你在处理像 SSEServer-Sent Events或 ReadableStream 这样的流式数据AbortController可能会切断数据流。一旦流被切断你就无法再接收后续的数据了。在这种情况下你需要更精细的控制。你可能需要保留连接只是停止处理数据或者使用流式读取器来优雅地关闭。3. 重复请求的“取消”逻辑有时候我们希望“旧”的请求被取消而“新”的请求继续。但在某些复杂的业务逻辑中比如订阅服务你可能希望两个请求同时进行虽然这通常不是好主意。第八部分进阶技巧与最佳实践作为一名“资深专家”我觉得有必要分享一些我在生产环境踩过的坑和总结的经验。1. 请求 ID 的管理在处理复杂列表或无限滚动时管理请求 ID 非常重要。不要只用Date.now()它可能不够精确。你可以用一个计数器或者在组件外部维护一个 Map。2. 不要滥用 AbortController如果你发起了 100 个请求然后组件卸载你就要调用 100 次abort()。这会触发 100 个 Promise 的 reject。虽然浏览器处理这个很快但如果你在 catch 块里没有正确过滤AbortError你的控制台会瞬间被红色的错误日志淹没。最佳实践在自定义 Hook 中封装好AbortError的过滤逻辑不要把脏活累活暴露给业务组件。3. TypeScript 类型支持如果你使用 TS记得给AbortController和AbortSignal定义类型或者在代码中明确注释。interface AbortControllerLike { signal: { aborted: boolean; onabort: ((event: Event) void) | null; }; abort(): void; }4. 调试技巧当你怀疑有请求没有被取消时可以在controller.abort()之前加一个console.log(Aborting request...)。然后在网络面板里观察请求的状态。通常你会发现请求的状态会变成Cancel。这是一个很棒的调试信号。第九部分React Query / SWR 的视角现在很多项目都在用 TanStack Query (React Query) 或 SWR。它们会自动处理请求缓存、去重和取消。但是理解底层的AbortController机制对于调试 TanStack Query 的错误至关重要。当你看到 TanStack Query 抛出一个错误你可以检查错误对象里是否有cause属性。如果cause是一个AbortError那就意味着这个请求是因为组件卸载被取消的而不是因为数据过期或网络错误。结论不要因为用了 React Query 就觉得可以忽略AbortController。了解它能让你在面对那些“明明有缓存为什么还要重新请求”或者“为什么报错”的奇怪问题时一眼看穿真相。第十部分总结与最后的唠叨好了朋友们我们的时间差不多了。我们今天深入探讨了 React 中网络请求的“身后事”。我们学会了如何使用AbortController来在组件卸载时优雅地终止请求。记住这三点永远不要让请求在组件卸载后继续执行。这是 React 开发的铁律。永远要区分AbortError和真正的网络错误。这能救你的 UI 于水火之中。封装是王道。把AbortController的逻辑封装进useEffect里或者封装进一个自定义 Hook让你的业务代码保持干净。网络请求就像是你的快递员。当你的房子组件被拆了快递员还在往里面塞包裹那不仅浪费钱还会把你的地基搞坏。所以下次写代码的时候记得给fetch或axios递上一根绳子AbortController在组件离开的时候把它拉回来。祝你们的网络请求都能体面地到达终点绝不留下任何垃圾谢谢大家

更多文章