
最近在做一个内部后台系统时遇到一个非常典型的问题用户在搜索框里输入关键字每敲一个字母都会发一次请求。防抖加了可当我快速切换筛选条件时上一次较慢的请求仍然可能后返回把新数据直接覆盖掉。为了修复这种数据错乱我把AbortController翻出来重新研究了一遍顺手解决了好几个困扰已久的请求取消、超时中断和内存泄漏问题。这里说的AbortController是浏览器原生提供的一个接口专门用来“主动中止”一个或多个正在进行的网络请求。以前我们做请求取消要么用XMLHttpRequest自带的abort()方法要么用第三方库比如 axios 里的CancelToken但AbortController是标准方案配合fetch使用非常顺手在 React、Vue 这些框架里做竞态控制的场景下尤其好用。如果你也在做前端请求管理比如搜索框联想、上传下载取消、接口超时处理或者正被“旧请求覆盖新数据”这种问题折磨这篇文章会把AbortController的原理、用法、进阶封装和坑位一次性讲清楚。我不做理论念经全程都是可以直接抄走的代码和实操结论。1. 项目概述为什么前端需要“请求中止”在实际业务里请求中止不是一个“用不到的高级功能”而是每天都在发生的基础需求。只是很多团队一开始意识不到等线上出了数据错乱、页面卡死才回头补课。1.1 请求中止的三个核心场景第一个场景是用户主动取消。比如用户上传一个 2GB 的视频文件到一半发现选错文件了这时候点击“取消”按钮请求还在继续上传显然不合理。下载同理用户点了“停止下载”如果前端不把网络请求真正断开浏览器底层连接会一直占着尤其是在弱网环境下体验会非常糟糕。第二个场景是请求竞态。这是最常见的隐性 bug 来源。用户在搜索框输入“苹果”又改成“苹果手机”再改成“苹果手机多少钱”防抖后可能同时发出 3 个请求每个接口返回时间不一样先发出的不一定先返回。如果最后一次输入对应的是最快的请求那没问题但更常见的是第一次慢请求最后返回把搜索列表错误地渲染成了旧结果。要解决这个问题根本上就是要在发新请求时把上一个未完成的请求直接取消掉。第三个场景是超时控制。移动端网络不稳定接口 5 秒没响应界面一直转圈用户只能干等。常见的做法是搭配setTimeout在指定时间后主动中止请求让界面进入“请求超时请重试”的状态。以前想在 fetch 里做超时很不方便有了AbortController就自然多了。1.2 前端请求中止的技术演变早年间大家都在用XMLHttpRequest取消请求直接调用xhr.abort()就行。后来fetch成为标准设计上更现代但早期并没有内建的取消机制导致很多团队不得不继续用 XHR或者借助第三方库对请求做包装。axios 里的CancelToken曾经是比较流行的方案但官方在后面版本中把它标记为“基于已废弃的 CancelToken API”慢慢也开始推荐使用AbortController的signal方案。从本质上讲AbortController是一个“信号控制器”它和网络请求本身解耦。换句话说不只是 fetch 能用它任何当你需要“通过外部信号中止某种异步操作”的场景都可以挂一个AbortSignal上去。所以在 Node.js 的http模块、prisma 数据库操作、甚至一些流式处理里也能看到AbortSignal的身影。1.3 AbortController 的核心 API 与设计思想AbortController本身只有两个核心成员一个是signal属性类型是AbortSignal另一个是abort(reason?)方法作用是触发中止信号。signal可以作为一个标记传入fetch(options.signal)当abort()被调用时浏览器会中断正在进行的请求同时让 fetch 返回的 Promise reject错误码是AbortError。这种设计思路很像“广播机制”controller 发出一个信号所有注册到这个信号上的请求都能感知。一个 controller 甚至可以同时控制多个请求比如批量上传 10 个文件时一个“全部取消”按钮就可以直接调一次abort()把 10 个请求全部取消。真正优雅的地方就在这里请求方和取消方无需互相持有引用只需要共享同一个signal对象即可。2. 基础用法从零到一掌握 fetch AbortController这一节先不整花活把最基础的用法吃透。你后面遇到的大部分问题追根究底都是基础用法没写好。2.1 最小可运行示例3 秒后自动取消请求假设我们请求一个很慢的接口比如 10 秒后才能返回但我们希望最多等 3 秒。const controller new AbortController(); const signal controller.signal; fetch(https://api.example.com/slow-data, { signal }) .then((res) res.json()) .then((data) { console.log(拿到数据, data); }) .catch((err) { if (err.name AbortError) { console.log(请求已被取消); } else { console.error(请求出错, err); } }); // 3秒后主动取消 setTimeout(() { controller.abort(); }, 3000);这段代码有一个很关键的细节err.name AbortError。当请求被abort()中止时fetch 抛出的错误并不是普通的网络错误而是名字为AbortError的DOMException。如果你不区分这个错误类型而是统一弹一个“网络异常”的提示用户就会看到“我没取消请求怎么就报错了”的诡异现象。我在实际项目里见过不少团队取消了请求但没有在 catch 里判断AbortError结果每次输入关键字都被当作请求失败前端弹了一堆错误提示。这个细节一定要记住。2.2 给 signal 添加自定义监听拿到取消节点除了让 fetch 感知中止我们还可以直接在signal上注册事件这样在请求被取消的那一帧就有机会同步更新 UI 状态、清理资源、打点上报。const controller new AbortController(); const { signal } controller; signal.addEventListener(abort, () { console.log(请求被中止是否已中止, signal.aborted); // 常用场景关闭 loading、打日志、清空定时器 loading false; }); fetch(/api/data, { signal });这里注意一点signal事件里的event.type就是abort但我们一般不需要 event 对象直接读取signal.aborted就能判断当前状态。如果你有多个取消来源比如不同的按钮都能取消同一个请求这个事件回调也可以帮你区分“到底是谁触发了取消”。一个小建议监听事件时尽量加上{ once: true }因为一个请求只会被取消一次没必要让回调一直挂在 signal 上。不过abort事件本身也比较特殊signal 被中止后事件不会重复触发加不加 once 影响不大但养成这个习惯没有坏处。2.3 兼容旧版 axios从 CancelToken 迁移到 signal如果你还在用 axios 的老版本取消方案建议尽快迁到AbortController。新版本 axios1.x已经支持直接传signalconst controller new AbortController(); axios.get(/api/user, { signal: controller.signal }); // 取消请求 controller.abort();它的内部原理是axios 在发起 XHR 请求时如果options.signal存在会监听abort事件一旦触发就调用底层的xhr.abort()。所以在 axios 里用AbortController本质上依然是借助 XHR 的能力但对外暴露的接口统一到了标准 APIAPI 设计更干净也不需要再维护 CancelToken 那套自定义代码。提示如果你的项目里 axios 版本较老无法支持signal建议至少升级到 1.x 再考虑批量替换。老项目如果不敢动依赖也可以继续用 CancelToken 顶一阵但要清楚它已经是废弃趋势新代码尽量直接上AbortController。3. 进阶场景实操把 AbortController 用到真实业务里基础用法学会之后就该面对真正的业务场景了。这里我挑了几个高频又容易踩坑的案例逐个拆解。3.1 请求竞态如何确保最后一次请求结果不被覆盖搜索框联想、筛选列表、分页跳转这类场景都会遇到竞态问题。我用一个SearchService类来封装“永远只保留最新请求”的能力class SearchService { constructor() { this.controller null; } search(keyword) { // 每发起新请求前取消上一次未完成的请求 if (this.controller) { this.controller.abort(); } // 创建新的控制器 this.controller new AbortController(); return fetch(/api/search?q${encodeURIComponent(keyword)}, { signal: this.controller.signal, }); } } const service new SearchService(); // 模拟用户快速输入输入 a - ab - abc service.search(a); service.search(ab); service.search(abc).then((res) res.json()).then((data) { console.log(最终结果, data); });当第三次调用search(abc)时search(a)和search(ab)的请求都已经被中止。由于被中止的请求会 reject如果在调用方没有 catch控制台会报Uncaught (in promise) AbortError所以调用方也需要统一 catch 一次。这种模式的本质是“用取消代替忽略”。如果不取消我们可能要用一个递增的requestId或者timestamp来判断响应是否是最后发出的请求代码就会复杂得多。用AbortController直接从源头掐断旧请求不仅省事还减少了没必要的网络流量。3.2 超时控制两种写法与选型建议超时是刚需但写法上有讲究。我先给出最常用的手动写法顺便说明为什么不建议所有场景都直接用原生的AbortSignal.timeout()。第一种写法setTimeoutAbortController。这是兼容性最好、也最灵活的做法。async function fetchWithTimeout(url, timeout 5000) { const controller new AbortController(); const timer setTimeout(() { controller.abort(); }, timeout); try { const res await fetch(url, { signal: controller.signal }); return await res.json(); } finally { // 无论成功失败都要清掉定时器避免定时器空转 clearTimeout(timer); } }第二种写法AbortSignal.timeout(timeout)。如果你只需要一个单纯的超时取消而且目标浏览器足够新可以直接用这个静态方法const res await fetch(https://api.example.com/data, { signal: AbortSignal.timeout(5000), });AbortSignal.timeout()的好处是内置了超时和取消逻辑不需要手动创建 controller。但它的局限性也很明显你不能在超时之外再额外手动取消同一个请求。比如“用户点击取消按钮 超时自动取消”这种组合场景还是需要自己维护一个 controller。我的建议是简单请求用AbortSignal.timeout()快速解决需要同时支持用户手动取消、还要把取消原因上报的场景就老老实实用第一种手动写法。3.3 文件上传取消大文件也能随时中断上传场景往往需要更精细的控制比如显示进度、允许取消、取消后释放表单数据。我们来看一个完整的取消上传示例const controller new AbortController(); const formData new FormData(); const fileInput document.querySelector(#fileInput); formData.append(file, fileInput.files[0]); fetch(/api/upload, { method: POST, body: formData, signal: controller.signal, }) .then((res) res.json()) .then((data) { console.log(上传成功, data); }) .catch((err) { if (err.name AbortError) { console.log(用户主动取消了上传); } else { console.error(上传失败, err); } }); // 点击“取消上传”按钮 cancelBtn.addEventListener(click, () { controller.abort(); });很多同学会问取消上传后服务器那边怎么知道上传被中断了答案是浏览器会直接断开这个请求的 TCP 连接。但是如果服务端没有监听连接中断事件可能还会继续处理已经接收完的那部分数据。所以严格来说取消上传的语义更多是“客户端不再发送剩余数据”而不是“服务端立即丢弃数据”。如果你要处理“取消后服务端必须停止处理”的场景需要在服务端额外实现中断感知逻辑比如监听req.on(close)或req.on(aborted)Node.js 中再做资源清理。3.4 批量请求管理一个 controller 中断多个请求AbortController最爽的用法之一就是“一对多”。假设页面上有 3 个独立的图表接口用户切换 Tab 后需要全部取消const controller new AbortController(); const urls [/api/chart/a, /api/chart/b, /api/chart/c]; const tasks urls.map((url) fetch(url, { signal: controller.signal }).then((res) res.json()) ); Promise.all(tasks) .then((results) { console.log(三个图表的数据, results); }) .catch((err) { if (err.name AbortError) { console.log(批量请求已全部取消); } }); // 用户切换 Tab 时一次性取消所有请求 tabChangeBtn.addEventListener(click, () { controller.abort(); });注意一点当一个 signal 被中止后这个控制器就不能再复用了。你如果想让用户切回来再次发起请求必须重新new AbortController()生成一个全新的 signal。所以实际业务里我会把这个“新建 controller 并挂载到请求上”的动作封装成一个函数避免在多个地方重复创建。4. 常见问题与排查技巧实录这部分是我整理的自留款都是实际开发中容易被绊倒的细节。如果你使用AbortController时遇到诡异现象大概率能在下面找到答案。4.1 取消请求后组件卸载还会触发 setState 警告React 开发里常见这样的错误组件卸载后异步请求才回来然后执行 setState控制台给出“Cant perform a React state update on an unmounted component”的警告。有的同学用 AbortController 取消了请求但还是有警告原因是在catch中没有正确拦截 AbortError错误继续外抛被外层逻辑当成网络错误处理。正确姿势是在请求函数里统一判断 AbortError并且不要把取消当作异常抛给上层useEffect(() { const controller new AbortController(); fetch(/api/data, { signal: controller.signal }) .then((res) res.json()) .then((setData)) .catch((err) { // 取消的请求不处理直接忽略 if (err.name AbortError) return; setError(err); }); return () controller.abort(); }, []);清理函数里执行controller.abort()后fetch 的 Promise 会 reject但因为我们已经在 catch 里把 AbortError 过滤掉了所以组件卸载时不会触发任何 setState。4.2 请求已经完成再调用 abort 会怎样有同学担心接口已经返回数据了这时候再执行controller.abort()会不会报错答案是不会。对一个已经结束的请求调用abort()是安全的abort()只是把 signal 的状态置为“已中止”并且触发 abort 事件但不会有任何异常抛出。不过要注意如果你的signal上挂了 abort 事件监听请求已经成功后事件依然会触发。所以如果你在 abort 回调里做了 loading 关闭操作重复触发并不会导致逻辑错误但如果你在 abort 回调里做了controller.abort()本身这种重复操作就要小心死循环建议用if (signal.aborted)提前判断。4.3 内存泄漏别让 signal 事件监听器一直挂在全局AbortController本身很轻但如果使用不当还是会造成内存泄漏。最常见的场景是在全局或长生命周期的对象里创建 controller然后signal.addEventListener(abort, handler)当模块要被销毁时没有执行removeEventListener。不过abort事件和普通事件略有不同它只会触发一次。如果你确定 controller 和请求是同生共死的泄漏概率很低。但如果你把signal当成一个通用“取消信号”传给多个模块而模块只在特定生命周期内需要感知取消那销毁时最好把监听器移除。function TaskManager() { this.controller new AbortController(); this.onAbort () { // 处理取消逻辑 }; this.controller.signal.addEventListener(abort, this.onAbort); this.destroy () { this.controller.signal.removeEventListener(abort, this.onAbort); this.controller null; }; }4.4 取消请求后控制台仍然报网络错误有些浏览器环境下请求被abort()中止后控制台 Network 面板里会显示一个红色的 “failed” 或 “canceled” 请求。很多同学以为这是 bug其实这是正常现象。从网络层面看请求确实断了显示 canceled 就是浏览器在告诉你“这个请求没正常完成”。重点是这个显示不会影响代码逻辑只要你在 Promise 的 catch 里正确捕获了 AbortError就不会把错误抛给用户。如果你非要让控制台干净一点可以考虑在发起请求前做一层参数校验把明显不需要发出的请求提前拦掉但这不是必须的。4.5 关于 AbortSignal.timeout 的兼容性补充AbortController本身的兼容性已经很好了主流的现代浏览器都支持。但AbortSignal.timeout()是相对较新的 API在部分浏览器和旧版 Node.js 里并不存在。如果你要面向用户环境做兼容建议自己写一个封装函数避免依赖这个静态方法。封装思路很简单判断AbortSignal.timeout是否存在不存在就用setTimeout手动 abort。function createTimeoutSignal(timeout) { if (typeof AbortSignal.timeout function) { return AbortSignal.timeout(timeout); } const controller new AbortController(); setTimeout(() controller.abort(), timeout); return controller.signal; }这样既利用了新特性又保证了老环境下的可运行性。4.6 服务端是否感知到请求被取消不少后端同学会问前端 abort 了数据库操作会停吗答案是HTTP 请求层面连接会断但服务端是否停止处理取决于你的服务端代码是否有感知断开的机制。比如在 Node.js 里可以监听请求对象的close事件如果是 Express 数据库操作在连接断开后继续执行数据库查询并不报错但这部分开销已经产生了。所以如果你的业务有“用户取消上传服务端也要立刻停止处理大文件”这种强一致性需求不能只依赖前端 abort还需要后端配合。前端取消只是第一道防线真正要做的是控制好服务端的超时和断开处理逻辑。5. 封装建议一个通用的带超时 fetch 函数把上面这些要点汇总我这里分享一个我在项目里实际在用的封装函数。它兼顾了外部信号、超时中断、取消原因传递代码不多但很实用。async function request(url, options {}) { const { timeout 10000, signal } options; const controller new AbortController(); // 外部已有 signal 时同步外部 abort if (signal) { if (signal.aborted) { throw new DOMException(请求已被取消, AbortError); } signal.addEventListener(abort, () controller.abort(), { once: true }); } // 超时取消 const timer setTimeout(() controller.abort(), timeout); try { const res await fetch(url, { ...options, signal: controller.signal, }); return res; } finally { clearTimeout(timer); } }这里有两个细节值得展开第一为什么要传入外部 signal 时还新建 controller因为 fetch 一次只能挂一个 signal。如果外部已经有一个signal我们没法直接给它加超时所以要在内部再建一个 controller然后监听外部 signal 的 abort 事件让内部 controller 跟着 abort。这样就实现了“多个取消来源”的组合。第二finally里的clearTimeout很关键。如果不清理定时器即使请求 1 秒就返回了那个setTimeout依然会在 10 秒后触发controller.abort()。虽然此时 abort 对已完成请求没有副作用但定时器白跑一次而且如果同一时间创建了大量请求这些定时器会堆积造成无谓的性能损耗。这种封装能覆盖大部分业务场景组件卸载时外部 signal 触发取消、统一超时防呆、以及用户手动 abort 的情况。调用方只需要传一个signal进去拿到的是标准的 fetch Response行为可预期。6. 个人经验与一点小技巧自己团队之前花了不少时间在请求竞态的坑里爬最后把核心原因都归结为前端没有统一的取消机制。引入AbortController之后也不代表每个请求都应该挂它。我自己的原则是只有这四类请求值得取消第一类是会互相覆盖的接口比如搜索关键字、筛选条件、分页切换。这类接口用取消来保证“最后一次请求优先”很划算。第二类是耗时长的接口比如文件上传下载必须给用户随时中断的出口。第三类是组件销毁时会发回调的请求必须用取消防止 setState 报错。第四类是对超时敏感的关键接口比如支付状态查询、登录态校验要用超时控制避免页面无限 loading。如果请求本身是轻量的、返回极快、不具备覆盖关系的硬套AbortController反而增加代码阅读成本。技术方案要为业务服务不是每个任务都需要一个 controller。最后分享一个小技巧。如果你正在用 React可以写一个简单的 hooks 来复用取消逻辑function useAbortController() { const controllerRef useRef(null); useEffect(() { controllerRef.current new AbortController(); return () controllerRef.current?.abort(); }, []); const abort () controllerRef.current?.abort(); const signal () controllerRef.current?.signal; return { abort, signal }; }这个 hooks 的妙处在于组件卸载时自动 abort不用在业务代码里手动管理清理。请求发出前把 signal 传给请求函数取消按钮直接调用abort()逻辑非常清爽。踩过几次坑之后你会发现请求中止本身不难难的是把“中止后的各种分支”都处理干净而AbortController恰好把这种分支收敛到了标准错误类型里值得你在每个前端项目里认真用起来。