尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从原理到实战:用AbortController优雅地取消前端请求

从原理到实战:用AbortController优雅地取消前端请求 前端一天到晚都在发请求但很少有人认真想过这个请求到底有没有被真正取消过我之前在一个管理后台里踩过一个坑页面里快速切换查询条件结果旧的请求比新的请求后返回直接把新数据覆盖了。后来排查半天发现就是从没主动中止过请求所有“取消”都只是停留在口头上的“我不看结果了”实际上浏览器该发的包一个不少回调该执行的照样执行。当时解决这个问题用的就是 AbortController。这货是个原生API专治“请求发出去之后我又不想要了”这种场景。这篇文章就把它的原理、用法、和实际项目里最常见的几种配合方式都捋一遍包括超时控制、竞态保护、组件卸载取消、axios里怎么用以及很多人容易忽略的坑。不管你是刚接触 fetch 没多久还是已经在项目里写了几年请求封装应该都能从中翻到点有用的东西。1. 请求中止这件事到底难在哪先说点背景。以前 XMLHttpRequest 时代想取消请求是靠 xhr.abort() 这一个方法硬来。到了 fetch 时代早期版本压根没给你提供取消入口导致社区里出现了各种 polyfill 和包装库。等到浏览器慢慢把 AbortController 变成标准能力之后事情才算有了一个统一的解法。1.1 发出去的请求为什么非要“主动中止”很多人有一个错觉我页面跳走了或者我不需要这个数据了浏览器是不是就会自动把请求断掉现实不是这样。fetch 一旦发出去网络层该走的流程照样走完响应回来之后 promise 该 resolve 还是 resolve你的回调函数该执行还是执行。只是这时候你可能已经不在那个页面了或者数据已经过期了这个“迟到的结果”反而成了脏数据。由此会引出一连串实际问题。第一是竞态条件两个请求先后发出先发出去的因为网络慢反而后返回结果把界面上本该保留的新数据覆盖掉了。第二是资源浪费尤其移动端弱网环境下用户明明已经不想等了请求还在那里慢慢传服务器和客户端带宽都被白占。第三是逻辑干扰你 是在“比如用户连点提交按钮第一发请求还在飞第二发又出去了最理想的交互应该是把上一发直接作废而不是让后端去处理重复订单”。所以说主动中止请求不是“性能洁癖”而是前端工程里一个绕不开的基础能力。很多后端接口没有幂等设计重复请求产生的副作用就得靠前端自己挡掉一层。1.2 AbortController 的核心组合controller 与 signalAbortController 本身是个很轻量的对象只有两个关键成员一个 controller.abort() 方法一个 controller.signal 属性。signal 是一个 AbortSignal 对象你可以把它理解成一根“信号线”fetch、axios、事件监听器这些支持中止的操作拿到这根线之后就会持续监听它的状态。调用 controller.abort() 那一刻signal 会被标记为 aborted所有绑定了这个 signal 的操作会立刻收到通知。fetch 收到通知后会中止网络请求promise 进入 rejected 状态并抛出一个名为 AbortError 的 DOMException。事件监听器收到通知后会自动移除监听比如 addEventListener 里传入 { signal } 的写法。const controller new AbortController(); const { signal } controller; fetch(/api/data, { signal }) .then(res res.json()) .catch(err { if (err.name AbortError) { console.log(这个请求是被我们主动中止的不是网络错误); } }); // 需要取消的时候 controller.abort();从代码上看确实不复杂但真正判断一个人有没有吃透这个东西得看他捕获异常的方式对不对。很多人会直接写 catch 然后弹错误提示结果用户取消请求之后反而看到一条“网络错误”体验非常别扭。正确做法是先判断 err.name 是不是 AbortError是的话静默处理。2. 基础用法从一次 abort 到整个信号机制基础用法看着简单但把 signal 和 fetch 以外的能力打通之后能玩出的花样远比想象中多。这里拆成几个层次来讲。2.1 用同一个 controller 中止多个并发请求AbortSignal 可以被多个请求同时绑定这是它一个特别实用的特性。想象一个报表页面页面里有十来个接口同时拉数据用户退出这个页面时我希望把这个页面发出的所有请求全部停掉。常规做法可能是把每个请求的 controller 单独存进一个数组然后逐个 abort。有 AbortController 之后只要让所有请求共享同一个 signal 就行。const controller new AbortController(); const requestList [ fetch(/api/report/summary, { signal: controller.signal }), fetch(/api/report/detail, { signal: controller.signal }), fetch(/api/report/trend, { signal: controller.signal }), ]; // 任何一个环节不再需要这些数据时 controller.abort();这里要注意一个细节fetch 本身并不是原子操作signal 传入发生在请求初始化阶段如果你把同一个 signal 同时发给多个请求abort 一次就相当于给这批请求群发了一条“全部取消”的指令。它内部实现是每个 fetch 调用都会在 signal 上注册 abort 事件处理器abort 触发时会挨个通知所有注册方。这个机制对并发请求管理很有用不用为每个请求单独维护状态。2.2 signal.aborted 与 abort 事件的监听时机除了通过 fetch 间接使用 signal你还可以直接监听 signal 本身。AbortSignal 上有一个 abort 事件通过 signal.addEventListener(abort, handler) 注册回调另外还有一个 signal.aborted 布尔属性用来判断当前是否已被中止。const controller new AbortController(); controller.signal.addEventListener(abort, () { console.log(signal 已经被中止了); }); console.log(controller.signal.aborted); // false controller.abort(); console.log(controller.signal.aborted); // true这里有一个很容易踩的时序坑如果代码执行顺序是先判断 signal.aborted再决定要不要添加监听那你得先看它是不是已经中止了。如果已经中止再 addEventListener 的 abort 事件是不会触发的因为那一瞬间已经过去了。你在实际代码里经常会遇到这种场景函数运行的时候 signal 已经通过某种渠道被 abort 了此时正确的做法是直接检查 signal.aborted 并立即返回而不是傻等 abort 事件。2.3 fetch 之外把 signal 用到事件监听器上AbortController 不仅能中止请求还能用来解绑事件监听。addEventListener 的 options 参数里可以传入 signal 字段当 signal 触发 abort 时这个监听器会被自动移除。这个能力很多同学没用过其实特别适合用在“一次性逻辑绑定”场景里。const controller new AbortController(); window.addEventListener(resize, handler, { signal: controller.signal }); // 某些逻辑完成后直接通过 abort 移除监听 controller.abort();这个写法比手动 removeEventListener 省事的地方在于你不用在每一个可能退出逻辑的地方都写一遍 removeEventListener只要在合适的时机统一 abort 就行。不过说实话这个特性的心智负担不低项目里如果只是零星几处用到还好大量使用的话代码可读性可能会下降建议只在需要“并发绑定多个监听、统一解除”的场景使用。3. 实战场景前端开发里最常见的几种中止需求铺垫够多了接下来直接上实操。我挑了几个项目里最常遇到的需求每个都给出完整思路和代码示例。3.1 请求超时控制不是 setTimeout 全家桶请求超时是最常见的需求之一。以前写 ajax 项目超时基本靠 setTimeout 和 clearTimeout 配合强行给请求加一个时间上限。fetch 本身没有内置 timeout 配置但通过 AbortController 可以实现一个非常干净的版本。function fetchWithTimeout(url, options {}, timeout 8000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal, }).finally(() clearTimeout(timer)); }这套写法的关键在于 finally 里的 clearTimeout。很多人会忽略这一步导致请求本来就几十毫秒就返回了但那个 8 秒的定时器还挂在那边等它真正触发时 controller.abort() 又执行了一次虽然整体没大碍但如果一个页面里发起几十次请求就会积压一堆无用的定时器。不过说实话这种“定时器 abort”方案遇到一种边缘情况会有点棘手如果 setTimeout 还没来得及触发请求就已经完成了这时候 finally 清掉定时器没问题但如果请求已经完成而定时器和 abort 并发执行可能 abort 时请求已经写入了响应头此时 fetch 已经从 pending 变成 fulfilled 了后面的 catch 也不会被触发。实际开发里这个问题不大大多数时候 abort 一个已完成的 fetch 只会让 signal 变成 aborted 状态不会产生多余副作用。3.2 搜索框竞态保护只响应用户最后一次输入搜索联想这块竞态问题可以说是绕不过去的。用户快速输入多个关键字每个关键字都发一个请求网络环境不稳定的情况下先发出去的请求反而可能最后返回导致下拉框里显示的和输入框里的内容对不上。用 AbortController 做竞态保护非常顺手。每次输入变化时先中止掉上一个请求再发新请求。let controller null; input.addEventListener(input, (e) { const keyword e.target.value; if (controller) { controller.abort(); } controller new AbortController(); fetch(/api/search?keyword${encodeURIComponent(keyword)}, { signal: controller.signal, }) .then(res res.json()) .then(data { renderSuggestions(data); }) .catch(err { if (err.name AbortError) return; // 其他错误正常处理 }); });这套代码的核心价值在于当用户输入速度非常快时前面的多次请求会立刻被 abort只有最后一个请求能顺利进入 then 渲染阶段。实际测试下来弱网环境下这种处理能给用户带来很明显的“跟手”感而不是眼睁睁看着联想结果一会儿跳出来一个旧的。3.3 组件卸载时自动取消请求前端框架里最常见的诉求组件销毁以后请求返回的数据不应该再更新状态。以前很多人用 mounted 里发请求、beforeUnmount 里设一个 this._isDestroyed 标志位来兜底。有 AbortController 之后可以直接把 signal 存起来卸载时统一 abort。拿 React 举例比较简洁的写法是配合 useEffect 的清理函数useEffect(() { const controller new AbortController(); fetch(/api/user/info, { signal: controller.signal }) .then(res res.json()) .then(data setUser(data)) .catch(err { if (err.name AbortError) return; console.error(err); }); return () controller.abort(); }, []);这里 return 出去的清理函数会在组件卸载时执行abort 一调用fetch 就立刻 rejected自然就不会再走进 setUser 去更新已卸载组件上的状态。很多同学以前会用一个 mounted 标志位来挡类似这样let isMounted true; fetch(...).then(data { if (isMounted) setUser(data); });不能说这种写法完全没效果但它只能阻止“数据更新组件状态”无法阻止底层请求把流量跑完。AbortController 是从根源上断掉连接更彻底同时对监控埋点也更友好能真实反映“用户已经离开页面”的场景。3.4 批量任务管理上传、下载、轮询批量上传文件、批量下载素材、定时轮询这类场景AbortController 也能派上大用场。比如用户选了 10 个文件上传中间点了一下“取消全部”如果每个文件都用同一个 signal一次 abort 就能终止所有上传。轮询场景更严重一些。如果页面里用 setInterval 不停地发请求用户切走页面之后轮询若没有清理请求会一直打到后端。很多人会用 clearInterval 去清理轮询但轮询请求本身可能还有一两个正在 pending 中。正确的姿势是 interval 和请求共用一套中止机制const controller new AbortController(); async function poll() { if (controller.signal.aborted) return; try { const res await fetch(/api/task/status, { signal: controller.signal, }); const data await res.json(); updateStatus(data); } catch (err) { if (err.name AbortError) return; // 网络异常处理 } } const timer setInterval(poll, 3000); // 离开页面或者取消任务时 controller.signal.addEventListener(abort, () { clearInterval(timer); });这里有一个细节值得注意我在 poll 函数开头检查了 signal.aborted原因是 setInterval 的回调可能已经进入排队状态即使 clearInterval 了当前这一轮的 poll 还是可能会执行。加一个 aborted 判断能保证轮询任务中止后当前在途的那一次请求也不会被发出去。4. 和其他工具的配合axios、stream、事件总线AbortController 不是只在 fetch 里能用。现在很多主流库都已经向它靠拢下面这几种配合方式在真实项目中的出场率很高。4.1 axios从 CancelToken 切换到 AbortControlleraxios 早期版本提供的是 CancelToken 方案创始人自己也公开说这个 API 在设计上不是很好所以后续版本把 AbortController 也纳入了支持范围。const controller new AbortController(); axios.get(/api/data, { signal: controller.signal, }).catch(error { if (axios.isCancel(error)) { console.log(请求已被取消); } else if (error.name AbortError) { console.log(通过 AbortController 中止); } });如果项目用的是老一点的 axios 版本需要确认版本号是否支持 signal 参数。简单说新版 axios 里两者都能用但我更推荐优先用 signal因为 CancelToken 那套 API 确实显得老旧而且未来大概率会被逐步淘汰。另外axios 的 signal 支持同样遵循底层规则中止后 promise 会进入 rejected 状态错误对象依然会经过拦截器所以你在拦截器里对错误做统一提示时一定要把 AbortError 排除掉别让用户看到“网络异常”之类的弹窗。4.2 与 ReadableStream 结合中止后避免继续读流fetch 的响应体可以按流的方式读取比如 large file 下载、SSE 数据流。这种场景下AbortController 中止后通常会直接终止整个流。但有些特殊场景下你可能只想中止“读取流的动作”而不是把整个请求杀掉。这里有一个实操小技巧如果你是在消费 response.body.getReader() 之后才调用 abort某些浏览器里 reader.read() 会抛出一个 AbortError你需要在这个错误下主动释放 reader。const controller new AbortController(); fetch(/api/stream, { signal: controller.signal }) .then(response { const reader response.body.getReader(); const decoder new TextDecoder(); function read() { reader.read().then(({ done, value }) { if (done) return; console.log(decoder.decode(value)); read(); }).catch(err { if (err.name AbortError) { console.log(流读取已中止); reader.releaseLock(); } }); } read(); });这段代码看起来简单但它在处理“中止后继续读流”这个细节时非常关键。如果 abort 后不调用 releaseLock流的锁可能一直占着后续再想用这个流做点什么都可能遇到 Locked 错误。很多做实时日志、流式聊天的人会在这一块栽跟头建议记下来。4.3 AbortSignal 组合timeout 静态方法现代浏览器还提供了一个很方便的静态方法 AbortSignal.timeout(ms)用它可以少写一点定时器逻辑const res await fetch(/api/data, { signal: AbortSignal.timeout(5000), });这个写法会在 5 秒后自动 abort 请求错误本质上还是 AbortError。它比 setTimeout clearTimeout 的写法更简洁但需要确认目标浏览器的兼容性。老版本浏览器不支持时还是得退回手动定时器方案。另外AbortSignal.any() 这个 API 在新版本浏览器里也开始有了它可以把多个 signal 组合成一个只要任何一个 signal 被中止整体就会中止。这个特性在复杂业务里非常有用比如“页面销毁时要中止”和“超时时要中止”两个条件同时存在用 AbortSignal.any([signal1, signal2]) 就能干净地合并逻辑。5. 常见问题与排查技巧实录AbortController 相关的坑很多不是它本身有多难而是它对“中止后发生了什么”的细节不够敏感。这里整理几个最常见的都是我实际开发中踩过或者帮别人排查过的。5.1 abort 之后 catch 会触发别当成网络错误提示这个前面反复强调过但还是得单独拎出来说。很多人写完 fetch 之后所有错误统一走一个 message 提示结果用户点了取消马上弹一条“请求失败请重试”给人一种取消操作反而出错误的感觉。正确的错误处理姿势如下fetch(/api/data, { signal }) .then(handleResponse) .catch(err { if (err.name AbortError) { // 静默处理不弹任何提示 return; } showErrorToast(请求失败请重试); });具体判断条件可以根据库的不同略有差异但核心思路是一致的主动中止产生的错误不是真正的“异常”是业务逻辑的一部分不该走异常提示通道。5.2 服务端到底有没有收到“取消”指令这是很多人问过的问题。AbortController 只是客户端把连接断开服务端能不能立刻感知到取决于底层连接还在不在以及服务端有没有针对连接断开做处理。HTTP/1.1 和 HTTP/2 下断开连接的行为不完全一样。大多数情况下fetch 中止后浏览器会断开网络连接服务端如果正在处理这个请求它的 socket 会被关闭理论上能够感知到连接异常。但如果服务端已经把响应头发出去了此时 abort 对服务端的“取消”作用就非常有限因为响应已经开始传输了。所以不要把 AbortController 当成取消服务端任务的银弹。如果需要“客户端中止时服务端也真的停止计算”通常得靠额外手段比如发送一个取消指令、用 WebSocket 通知、或者在请求里带一个取消标识。前端可以做的只是“我不再接收这个响应了”后端怎么配合是另一套工程。5.3 定时器一定要清理不然会堆积如果不用 AbortSignal.timeout而是自己写 setTimeout那么请求正常结束后必须清理定时器。很多同学写代码时能记得 abort但忘了 clearTimeout结果定时器到点后执行 controller.abort()虽然不会造成太大危害但日积月累会在页面上留一堆无效定时器。const controller new AbortController(); const timer setTimeout(() controller.abort(), 10000); fetch(/api/data, { signal: controller.signal }) .then(res res.json()) .finally(() clearTimeout(timer));这里推荐用 finally 而不是 then 里清、catch 里也各写一遍代码更简短也能保证无论成功失败都会清理。5.4 中止请求后内存里的引用回收了吗AbortController 作为一个对象如果长期保存在某个模块级变量里请求完成后还没有置 null它引用的 signal 以及 signal 上注册的回调可能会一直占着内存。虽然现在浏览器引擎的垃圾回收已经很智能但我们在框架里使用时还是尽量遵循局部变量的原则能写在函数里的就别挂到全局用完后及时释放引用。在 React 场景下useEffect 返回的清理函数顺手把 controller 置为 null 也是一种好习惯。5.5 常见问题速查表为了方便查我把最常见的几个问题整理成表格。问题现象可能原因解决思路请求中止后 catch 弹了错误提示没有判断 err.name AbortError增加 AbortError 分支静默处理中止后服务端任务还在跑abort 只是客户端断连用连接状态感知或额外发送取消指令所有请求被误杀复用了同一个 signal且调用了 abort按需创建独立 controller不要共用定时器一直在触发没有 clearTimeout在 finally 中清理定时器abort 后流还在读reader 没有释放调用 reader.cancel() 或 releaseLock()旧版本浏览器报错浏览器不支持 AbortController引入 polyfill 或改用 XHR.abort() 方案6. 一些值得收藏的封装思路最后分享一下我实际项目里比较常用的封装思路。方向不复杂核心是把“网络错误”“业务错误”“主动中止”三种类型拆开处理让上层代码不用每次关心细节。6.1 抽一个统一的 request 方法常见做法是封装一个 request 函数内部支持传入外部 signal同时也能合并超时逻辑。这样业务代码里只需要管业务不用每次写 AbortError 判断。async function request(url, { timeout 8000, signal } {}) { const controller new AbortController(); if (signal) { if (signal.aborted) { controller.abort(); } else { signal.addEventListener(abort, () controller.abort(), { once: true }); } } const timer setTimeout(() controller.abort(), timeout); try { const res await fetch(url, { signal: controller.signal }); return await res.json(); } finally { clearTimeout(timer); } }这个封装里值得留意的就是 signal 合并逻辑。如果传入的信号已经中止直接把内部 controller 也 abort如果没有中止就监听它一旦它 abort 就同步 abort 内部 controller。这样外部调用方只要维护自己的 controller内部超时也不会被覆盖。6.2 竞态场景的通用 hook在 React 项目里我会把竞态保护抽成一个简单 hook。比如 useRequest 里面自动管理 controller请求发起时先 abort 上一次组件卸载时自动 abort。业务组件只管调函数不用管取消逻辑。function useRequest(requestFn) { const controllerRef useRef(null); const run useCallback((...args) { if (controllerRef.current) { controllerRef.current.abort(); } const controller new AbortController(); controllerRef.current controller; return requestFn(...args, controller.signal); }, [requestFn]); useEffect(() { return () controllerRef.current?.abort(); }, []); return run; }这里有一个必须注意的点useEffect 的清理函数只能看到首次渲染时的 controllerRef所以你必须在每次 run 时把最新的 controller 存进 ref 里去而不是把它放到 state 里。用 ref 才能保证清理函数读到的是当前的 controller否则很可能在卸载时 abort 了上一次的而当前的还挂在那里。6.3 分阶段取消什么时候做轻量封装什么时候做重型方案我的建议是如果项目里只有一两个接口需要取消没必要上复杂的请求层改造直接在 fetch 里传入 signal 就行。如果全项目有几十个接口、几百个调用点那一定要在请求封装层统一处理超时和 signal 合并否则每个页面都自己写 AbortError 判断代码很快会发散成一片混乱。如果是中后台系统建议把“页面级统一取消”做成一个公共能力。比如路由切换时统一 abort 旧页面所有请求前提是所有请求都注册到了同一个容器里。这个能力虽然实现起来要花点心思但对体验的提升非常明显尤其表单页和数据大屏这种请求密集场景能明显减少旧请求对用户后续操作的干扰。7. 最后再讲一个组合小技巧除了常规的 fetch 中止外AbortController 还能用来做跨模块的“取消信号”。比如你在 A 模块里发起一个耗时任务B 模块可以拿到同一个 signalB 模块在某个时机调 abort()A 模块所有绑定了这个 signal 的操作都会同时停掉。这个模式很适合做复杂的交互联动。我在一个数据大屏项目里用过这个思路屏幕上多个图表分别请求不同接口右上角有个全局刷新按钮点击之后先 abort 掉当前所有图表请求再重新拉数据。实现方式就是全屏共享一个 controller刷新时 controller.abort()然后重新 new 一个 controller 挂到全局。对比之前“用状态标记忽略旧响应”的写法代码量少了很多也更容易理解。另外一个小细节如果你在做 Electron 或桌面容器内嵌 H5 页面AbortController 同样适用于容器内的网络请求而且相比页面卸载后的请求挂起它能更快地释放系统资源对低配机器来说尤其友好。我个人在实际项目里的体会是AbortController 本身只是一个很小的 API但它解决的是前端异步编程里一个非常底层也非常普遍的痛点如何去表达“我不再关心这个结果了”。以前这个意图要靠各种标志位、闭包技巧去模拟现在有了原生能力写法干净多了。如果你还没在项目里用过建议从搜索联想、组件卸载取消这两个场景开始试点改动范围小收益又明显踩坑概率也低。用顺手之后再去改请求层统一封装基本上就不会再有“明明已经中止了为什么还会报错”这种困惑了。
返回列表