JavaScript 取消异步任务实战:AbortController 不只是给 fetch 用的

先说个界面上的问题,大概率你也见过。

搜索框,用户打字很快,输入「前端」两个字中间经过「前」这个状态。两个请求都发出去了,结果「前」那次因为某种原因后返回。页面上显示的联想词,跟用户当前输入的内容对不上。用户还在那儿等着,以为是网络慢。

这就是典型的「过期响应覆盖了最新响应」。第一种修法是给每个请求编号,回来之后判断是不是最新的。这能修,但要引入额外的状态,而且一旦有多个并发点,编号容易乱。

第二种修法,直接在发新请求之前把旧的掐掉。让「前」那次请求直接死于途中,它连回来的机会都没有。这就是 AbortController 的主场。

下面从一个最朴素的搜索触发开始,一步步改到能用的程度。

先看看没做取消会怎么样

朴素版本大致长这样:

let input = document.querySelector('#search');
let list = document.querySelector('#suggest');

input.addEventListener('input', async (e) => {
  const keyword = e.target.value.trim();
  if (!keyword) {
    list.innerHTML = '';
    return;
  }

  const res = await fetch(`/api/suggest?q=${encodeURIComponent(keyword)}`);
  const data = await res.json();

  list.innerHTML = data.map(item => `<li>${item}</li>`).join('');
});

这段代码有两个问题叠在一起。一是它完全没节流,每敲一个字就打一次请求,用户打「前端开发工程师」这一句话能发出八个请求。二是它没有办法处理返回顺序,谁能后到谁说了算。

分开看,节流好办,debounce 一行就行。乱序才是本文要解决的问题。

AbortController 最基础的用法

三个概念:控制器、信号、取消。

const controller = new AbortController();

fetch('/api/suggest?q=vue', { signal: controller.signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求被取消了');
      return;
    }
    console.error(err);
  });

// 某个时刻
controller.abort();

controller.signal 是传给 fetch 的东西,用来说明「这次请求可以被打断」。调用 controller.abort() 之后,fetch 会抛出一个 DOMException,name 属性是 AbortError。这个字符串很关键,判断取消和判断真正的网络错误,全靠它。

signal 还有一个 aborted 属性是个布尔值,告诉外界它是否已经被取消过。这个属性在写自定义异步任务时用得很多。

把搜索改成可取消的

回到那个搜索框,思路很简单:每次发新请求之前,看看上一次的控制器还在不在。在的话,掐掉。

let currentController = null;

async function runSearch(keyword) {
  // 掐掉上一次
  if (currentController) {
    currentController.abort();
  }
  currentController = new AbortController();

  try {
    const res = await fetch(`/api/suggest?q=${encodeURIComponent(keyword)}`, {
      signal: currentController.signal
    });

    if (!res.ok) {
      throw new Error('HTTP ' + res.status);
    }

    const data = await res.json();
    renderSuggest(data);
  } catch (err) {
    if (err.name === 'AbortError') {
      // 被打断是正常流程,直接忽略
      return;
    }
    console.error('搜索失败', err);
  }
}

input.addEventListener('input', (e) => {
  const keyword = e.target.value.trim();
  if (!keyword) {
    list.innerHTML = '';
    if (currentController) {
      currentController.abort();
      currentController = null;
    }
    return;
  }
  runSearch(keyword);
});

逻辑上没问题。但这里藏着一个很多第一次用的人都踩过的坑:控制器用完必须重新创建。

仔细看上面的代码,每次 runSearch 里都 new AbortController() 了一个新的。为什么不能省?因为一个控制器被 abort 之后,它的 signal 就永久处于「已取消」状态。aborted 永远是 true。你把这个 signal 再传给 fetch,fetch 会立刻抛错,看都不看你一眼。这不是 bug,这是设计——已经取消的信号,凭什么还能用。

我见过有同事写这种东西:

// 错误的写法
const controller = new AbortController();

function search(keyword) {
  // 因为 controller 复用了,所以第一次取消之后
  // 后面所有的请求都会立即失败
  return fetch(url, { signal: controller.signal });
}

第一次搜索正常,用户点了取消,从那之后所有的搜索全废了。调试的时候看着代码也觉得没问题,就是找不到原因。所以记住一句话:一次性控制器,用完就扔。

加一层 debounce 才完整

把取消和防抖组合起来,才算真正可用。防抖负责降低请求频率,取消负责兜住「用户输入停滞的间隙里恰好发出去的那个请求」。

function createSearch({ delay = 250 } = {}) {
  let timer = null;
  let controller = null;

  async function doSearch(keyword) {
    controller?.abort();
    controller = new AbortController();

    try {
      const res = await fetch(`/api/suggest?q=${encodeURIComponent(keyword)}`, {
        signal: controller.signal
      });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return await res.json();
    } catch (err) {
      if (err.name === 'AbortError') return null;
      throw err;
    }
  }

  return function search(keyword) {
    clearTimeout(timer);
    if (!keyword) {
      controller?.abort();
      controller = null;
      return Promise.resolve(null);
    }

    return new Promise((resolve, reject) => {
      timer = setTimeout(() => {
        doSearch(keyword).then(resolve).catch(reject);
      }, delay);
    });
  };
}

这个工厂函数返回一个 search,每次调用会重置定时器,只有用户停下来 250ms 之后才会真正发请求。而发请求之前,上一次的请求一定被取消掉了。两道防线各司其职。

用的时候:

const search = createSearch({ delay: 200 });

input.addEventListener('input', async (e) => {
  const result = await search(e.target.value.trim());
  if (result) renderSuggest(result);
});

注意 if (result) 那一句。被取消的请求会 resolve 成 null,避免用过期数据去渲染页面。

AbortSignal 现在自带工厂方法了

以前要做超时,得自己开个 setTimeout,到点调一下 abort。

// 老写法,现在不用了
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);

try {
  const res = await fetch(url, { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer);
}

现在有两个静态方法可以直接用。AbortSignal.timeout(ms) 返回一个「到点自动取消」的信号:

const res = await fetch(url, { signal: AbortSignal.timeout(3000) });

三行变一行。超时之后,fetch 抛出的 err.name 是 TimeoutError,不是 AbortError。这个区别要留意,因为如果想把超时和手动取消分开处理,就靠这个名字判断。

另一个是 AbortSignal.any([...]),把多个信号合并成一个。任何一个信号被触发,合并后的信号就变成已取消状态。这个功能很有用:既要允许用户手动取消,又要有超时兜底,还能在页面卸载时中断。

const userController = new AbortController();

const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(5000)
]);

try {
  const res = await fetch(url, { signal });
  // ...
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.log('超时了');
  } else if (err.name === 'AbortError') {
    console.log('用户取消或页面卸载');
  }
}

顺带说一下,还有 AbortSignal.abort(),它直接返回一个已经处于取消状态的信号。什么时候用?写库的时候需要一个「什么都不做」的默认值。或者测试里想模拟「已经取消」的场景,直接用它比手动 new 一个 controller 再 abort 快。

把取消能力做到自己的异步任务里

很多异步任务根本不用 fetch。比如延时、轮询、动画、读取文件流。这些东西也需要能取消,尤其是组件卸载的时候。

做法是接收一个 signal,在合适的位置监听它的 abort 事件。先写一个可取消的 sleep:

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    }

    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    function onAbort() {
      clearTimeout(timer);
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    }

    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

这段代码里有几个细节值得说一说。

第一,进入 Promise 之后先检查 signal.aborted。因为有可能 signal 在调用 sleep 之前就已经被取消了,这时候再挂监听就晚了,事件永远不会再触发。这个检查必须放在最前面。

第二,正常 resolve 的时候要把监听摘掉。虽然用了 { once: true },但那是等事件触发之后自动摘。如果事件根本不触发(比如 sleep 正常完成了),这个监听就会一直挂在 signal 上,越积越多,最后造成内存泄漏。signal 这种东西生命周期往往很长,挂在上面的监听不会自己消失。

第三,signal.reason 是新版才有的属性。它表示取消时传进来的原因。默认是一个 DOMException('Aborted', 'AbortError')。你可以 controller.abort(new Error('用户点了取消')) 传一个自定义原因,这样捕获的时候能拿到更多上下文。

有了这个 sleep,就可以拼一个可取消的轮询:

async function poll(url, { interval = 3000, signal } = {}) {
  while (true) {
    const res = await fetch(url, { signal });
    if (res.status !== 202) {
      return res.json();
    }
    await sleep(interval, signal);
  }
}

调用方拿着一个 controller,什么时候不想等了就 abort()。整个轮询立刻停止,不需要额外的停止变量,也不用在每个 await 后面加 if (stopped) return。

事件监听也能绑定 signal

这一条知道的人相对少一些,但用过一次就不想回去。addEventListener 的第三个参数或选项对象里,可以传 signal。信号一取消,监听自动摘掉。

const controller = new AbortController();

window.addEventListener('resize', handleResize, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
window.addEventListener('keydown', handleKeydown, { signal: controller.signal });

// 一次性清掉三个
controller.abort();

传统写法里,每个监听都要配一个对应的 removeEventListener,而且回调函数必须是同一个引用,不能每次重新写匿名函数。一旦漏掉一个,或者回调名写错,就是内存泄漏。有了 signal,一个 abort() 全清。

这个特性在写单页应用里的「页面级事件」时特别舒服。在页面装载时创建一个 controller,把所有需要清理的监听都挂在它上面。离开页面时一个 abort 收尾,收尾逻辑不会漏。

顺便说一句,fetch 的 signal、addEventListener 的 signal、AbortSignal.timeout() 返回的 signal,全都是同一个东西。AbortSignal 本身并不绑定到任何特定 API,它就是一个通用的「取消令牌」协议。你可以在任何异步操作里用它,只要按照那个约定:检查 aborted、监听 abort 事件、抛 AbortError。

在 React 和 Vue 里怎么用

组件卸载之后异步回调还在跑,是前端最常见的泄漏来源。React 里官方推荐的做法就是 AbortController。

import { useEffect, useState } from 'react';

function UserCard({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error('HTTP ' + res.status);
        return res.json();
      })
      .then(setUser)
      .catch(err => {
        if (err.name === 'AbortError') return;
        setError(err);
      });

    return () => controller.abort();
  }, [userId]);

  if (error) return <div>加载失败:{error.message}</div>;
  if (!user) return <div>加载中...</div>;
  return <div>{user.name}</div>;
}

依赖项里的 userId 变化时,或者组件卸载时,effect 的清理函数都会跑,触发 abort。这样既能避免「切换用户时显示上一个用户的数据」,也能避免卸载之后 setState 触发 warning。

Vue 3 里思路一样,只是换成了 onUnmounted:

import { ref, watch, onUnmounted } from 'vue';

const user = ref(null);
const controller = new AbortController();

watch(userId, async (id) => {
  controller?.abort();
  controller = new AbortController();

  try {
    const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
    user.value = await res.json();
  } catch (err) {
    if (err.name !== 'AbortError') console.error(err);
  }
});

onUnmounted(() => {
  controller?.abort();
});

有个小坑:Vue 里如果用的是 watch 的 cleanup 回调,别在里面调用同一个 controller 的 abort 又立刻新建。上次的清理和这次的初始化不要混在一起,容易乱。分开写会清楚很多。

另外如果用的是 <Suspense> 或者 TanStack Query 这类工具,它们内部已经处理了取消。这时候不要在外面再加一层 AbortController,会重复。给它们一个 signal 就够了,比如 TanStack Query 的 queryFn 会收到一个 signal 参数,直接转发给 fetch 就好。

踩坑清单

别复用已经 abort 的 controller。 前面已经说过,但值得重复一遍。症状是「第一次取消之后所有请求都失败」,很多人第一反应是怀疑服务端或者缓存,绕了一大圈才发现是自己把 controller 当成了全局变量。

判断取消一定要用 err.name,别用 err.message。 message 里的内容会变,比如浏览器可能给出的是「The user aborted a request」,Node 里可能完全不同。name 才是稳定的契约。

abort 之后要及时清理引用。 比如在搜索场景里,那个 currentController = null 不能省。虽然下一次请求会覆盖,但如果不清理,闭包里会持续持有一次「已经废弃的 controller」的引用。单个没什么,量大了就是内存问题。

取消不保证是「立即」的。 调了 abort() 之后,还需要等浏览器把取消信号传出去,服务端把连接断掉。如果服务端已经在处理这个请求了,它可能还在跑完。前端这边拿到 AbortError 会立刻返回,但服务端的资源不一定能立刻释放。想要真正省资源,服务端也要处理客户端断连(比如 PHP 里的 connection_aborted() 或者 Nginx 的 client_aborted 判断)。

在 fetch 已经 reject 之后再 abort 是安全的。 比如超时、网络错误之后,控制器会自己什么都不做。不用写 if (!done) controller.abort() 这种防御代码,直接调就行。

多个地方共用同一个 controller 要小心。 比如 A 和 B 两个请求共用,A 结束之后调用 abort,B 也被取消了。每个独立的请求任务,应该有自己的 controller。如果想统一取消一批,用 AbortSignal.any 把外层的总信号和每请求自己的信号合起来,而不是让它们共享一个。

取消之后 Promise 是 rejected 的,不是 resolved。 这一点在 Promise.all 里影响很大。如果其中一个请求被取消,整个 all 会立刻 reject。有些场景希望「取消的请求被忽略,其他照常」,这时候要么自己去把 AbortError 吞掉,要么用 Promise.allSettled。

Node.js 里的差异。 Node 18 之后 fetch 全局可用,也支持 signal,但报错的对象类型不一定是 DOMException。稳妥做法还是判断 err.name === 'AbortError',同时不要用 instanceof DOMException 去筛。

浏览器兼容性。 AbortController 和 AbortSignal 本身早就不是问题了。AbortSignal.timeout() 和 AbortSignal.any() 是比较新的,Chrome 103 和 124 之后才有;AbortSignal.any 尤其新。如果需要兼容老浏览器,用 Promise.race 手动实现一个组合信号也不难,几十行代码。

别忘了 res.ok 检查。 这条和取消没关系,但写在一起说。fetch 只有网络层失败才 reject,HTTP 500 也是 resolve 的。要判断业务错误,必须主动检查 res.ok 或者 status。忘了这一步,你会发现「服务器报错但页面显示了默认值」,逻辑完全乱套。

以前没有 AbortController 的时候

顺带对比一下以前的写法,能更清楚它解决了什么。XMLHttpRequest 时代,取消靠 xhr.abort(),每次请求都要维护一个 xhr 对象,还要处理 readystatechange 里的状态。Promise 不流行之后,fetch 一开始根本没有取消能力,社区里各种 hack 都有:用一个从不 resolve 的 Promise 去 race,或者干脆不管,让响应回来之后判断是否过期。

AbortController 从 2017 年开始提,浏览器逐渐实现,至今已经是事实标准。它最大的价值在于把「取消」这件事从每个 API 自己身上剥出来,变成了一个通用协议。你写一个新异步任务,只要愿意支持取消,就可以接收一个 signal,别人就能用统一的方式操作它。

这也是为什么很多新的 Web API 都带上了 signal 参数:fetch、addEventListener、ReadableStream、WritableStream、FileSystemWritableFileStream,甚至 WebSocket 的某些封装。这东西已经从一个功能,变成了一个约定。

收尾

回到开头那个搜索框。加了 debounce 和 AbortController 之后,用户体验上的差别其实不算巨大——最多就是响应快了一丝,或者少发几个请求。但系统稳定性上的差别很大。用户切换页面、关闭窗口、快速输入的时候,背后少了一大堆「用不上但还在跑」的请求。

前端性能优化里,很多人都习惯盯着打包体积、首屏时间这些指标。其实「该取消的任务及时取消」这类小动作,日积月累下来的收益一点不比那些显眼指标的差。而且这类事情做久了会发现,真正的功夫都不在拉满百分比上,而在那些没人注意的地方。

JavaScript 取消异步任务实战:AbortController 不只是给 fetch 用的
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript 取消异步任务实战:AbortController 不只是给 fetch 用的 https://www.taomawang.com/web/javascript/2851.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务