JavaScript深度实践:手写可取消异步任务管理器与超时重试策略

你有没有遇到过这样的场景:用户快速切换页面时,前一个页面的请求还在路上,等它返回后结果被错误地填充到了当前页面;或者一个耗时操作已经不再需要了,但因为没有取消机制,它还在后台占用资源。这类问题本质上是同一个原因——原生 Promise 没有取消能力

好消息是,现代浏览器提供了 AbortControllerAbortSignal 这套原生 API,专门用来解决异步操作的取消问题。一开始大家主要用它来取消 fetch 请求,但它的用法远不止于此。这篇文章会带你从基础用法出发,一步步封装出一个可以取消任意异步任务、支持超时和重试的工具模块。所有代码都可以直接在浏览器里跑,不需要任何框架。


先理解两个核心对象:AbortController 与 AbortSignal

整个机制的核心思想非常简单:有一个“控制器”(AbortController),它能发出一个“信号”(AbortSignal)。任何监听这个信号的异步任务,都能在控制器调用 abort() 时收到通知,然后主动终止自己的工作。

代码长这样:

// 创建一个控制器
const controller = new AbortController();
// 获取它对应的信号
const signal = controller.signal;

// 将这个信号传给某个支持 AbortSignal 的API,比如 fetch
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求被取消了');
    } else {
      console.error('请求失败', err);
    }
  });

// 在需要的时候取消
controller.abort();

上面这个例子中,调用 abort() 后,fetch 内部会抛出一个 AbortError(注意不是异常,而是一个名字为 ‘AbortError’ 的 DOMException)。我们可以通过 err.name 来区分它是被取消还是真的网络错误。

这里有一个容易被忽略的点:AbortController 是一次性的。一旦你调用了 abort(),这个信号就永远处于“已中止”状态。如果你需要重复发起可取消的请求,就得在每次操作前创建新的 AbortController 实例。


不只是fetch:让任何异步函数变得可取消

AbortSignal 真正强大的地方在于,你可以把它塞进任何异步逻辑里。它提供了一个 aborted 属性(只读布尔值)和一个 throwIfAborted() 方法,以及一个 onabort 事件监听器。利用这些,我们可以把普通的 Promise 包装成“可取消的 Promise”。

下面这个函数可以包装任意返回 Promise 的工厂函数,使其支持 AbortSignal:

/**
 * 创建一个可取消的异步任务
 * @param {AsyncFunction} taskFn 一个返回Promise的异步函数,会接收signal作为参数
 * @param {AbortSignal} signal 外部传入的信号
 * @returns {Promise} 包装后的Promise,signal取消时会reject AbortError
 */
function createCancellableTask(taskFn, signal) {
  return new Promise((resolve, reject) => {
    // 如果信号在创建任务前就已经被中止了,直接拒绝
    if (signal.aborted) {
      return reject(new DOMException('任务已被取消', 'AbortError'));
    }

    // 监听取消事件
    const onAbort = () => {
      reject(new DOMException('任务已被取消', 'AbortError'));
    };
    signal.addEventListener('abort', onAbort, { once: true });

    // 执行原始任务,传递signal让它内部可以检查取消状态
    taskFn(signal)
      .then(resolve, reject)
      .finally(() => {
        // 清理监听器,避免内存泄漏
        signal.removeEventListener('abort', onAbort);
      });
  });
}

使用示例:

const controller = new AbortController();

const task = createCancellableTask(async (signal) => {
  for (let i = 0; i < 10; i++) {
    // 在循环中检查取消状态
    signal.throwIfAborted();
    await new Promise(resolve => setTimeout(resolve, 500));
    console.log('处理步骤', i + 1);
  }
  return '完成';
}, controller.signal);

task
  .then(result => console.log(result))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('任务中途取消');
    }
  });

// 2秒后取消
setTimeout(() => controller.abort(), 2000);

运行这段代码,你会看到控制台只打印了前几次步骤,然后就被取消了。关键是 signal.throwIfAborted() 这个调用——如果此时 signal 已经被中止,它会立刻抛出一个 AbortError,从而终止循环。如果你的异步任务里有比较长的计算过程,建议在关键节点上主动调用这个方法,保证取消能被及时响应。


封装一个带超时和取消的请求函数

光有取消还不够,实际开发中我们经常需要“一定时间内没响应就自动取消”,也就是超时控制。我们可以把超时当作一种特殊的取消:由定时器触发 controller.abort()

下面这个封装函数同时支持外部取消和内部超时:

/**
 * 发起一个带超时和取消功能的fetch请求
 * @param {string} url 请求地址
 * @param {object} options 请求配置,可包含 timeout(毫秒)和 signal(外部AbortSignal)
 * @returns {Promise} 响应数据
 */
async function fetchWithTimeout(url, options = {}) {
  const { timeout = 10000, signal: externalSignal, ...fetchOptions } = options;

  // 每次请求创建独立的AbortController
  const controller = new AbortController();
  const signal = controller.signal;

  // 如果有外部signal,需要让外部取消也能触发内部取消
  if (externalSignal) {
    if (externalSignal.aborted) {
      throw new DOMException('请求已被外部取消', 'AbortError');
    }
    externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
  }

  // 超时定时器
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeout);

  try {
    const response = await fetch(url, { ...fetchOptions, signal });
    // 请求成功,清除超时定时器
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return await response.json();
  } catch (err) {
    clearTimeout(timeoutId);
    // 如果是AbortError,判断是否因为超时引起
    if (err.name === 'AbortError') {
      if (signal.aborted && !externalSignal?.aborted) {
        throw new DOMException(`请求超时 (${timeout}ms)`, 'TimeoutError');
      }
      throw err; // 外部取消直接抛出AbortError
    }
    throw err;
  }
}

这里做了一层判断:如果 signal.aborted 为真且外部 signal 没有被取消,说明是超时触发的取消,我们抛出一个自定义的 TimeoutError 以便调用方区分超时和主动取消。

调用方式:

// 常规超时控制
fetchWithTimeout('/api/slow-endpoint', { timeout: 5000 })
  .then(data => console.log('数据', data))
  .catch(err => {
    if (err.name === 'TimeoutError') {
      console.warn('请求超时,请稍后重试');
    } else if (err.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

// 结合外部取消(比如用户离开当前视图)
const pageController = new AbortController();
fetchWithTimeout('/api/data', { timeout: 8000, signal: pageController.signal });

// 页面卸载时取消所有请求
window.addEventListener('beforeunload', () => {
  pageController.abort();
});

在单页应用中,这个模式非常实用。你可以在页面或组件的 effect 中创建一个 AbortController,在 cleanup 时调用 abort(),这样该页面所有异步操作都会自动清理,不会遗留到下一个页面。


添加重试逻辑:失败自动重试,但取消和超时立即停止

超时之后我们通常不想立即失败,而是给个机会重试。但要注意:如果重试过程中用户主动取消了,或者外部 signal 被中止了,重试应该立刻停止,而不是固执地重试完所有次数。

下面的函数在 fetchWithTimeout 基础上增加了最大重试次数和重试间隔:

/**
 * 带重试的请求
 * @param {string} url
 * @param {object} options 包含 retries(重试次数)、retryDelay(间隔ms)、超时和signal
 */
async function fetchWithRetry(url, options = {}) {
  const { retries = 2, retryDelay = 1000, ...restOptions } = options;

  let lastError;
  for (let i = 0; i <= retries; i++) {
    try {
      return await fetchWithTimeout(url, restOptions);
    } catch (err) {
      lastError = err;
      // 如果是主动取消(AbortError)或是不该重试的错误,直接抛
      if (err.name === 'AbortError') throw err;
      if (i < retries) {
        console.log(`第${i + 1}次尝试失败,${retryDelay}ms后重试...`, err.message);
        await new Promise(resolve => setTimeout(resolve, retryDelay));
      }
    }
  }
  throw lastError;
}

注意这里的判断:一旦捕获到 AbortError(无论来自外部取消还是超时),我们直接停止重试并抛出。因为你不可能在用户明确要取消的时候还继续重试——这不符合用户预期。

使用这个函数与 fetchWithTimeout 类似,只是多了两个参数:

const data = await fetchWithRetry('/api/risky-endpoint', {
  timeout: 3000,
  retries: 3,
  retryDelay: 1500,
  signal: someController.signal
});

更复杂的场景:管理多个并发可取消任务

有时候你需要在页面上同时运行多个独立的异步操作,希望在页面销毁时一次性全部取消。如果一个一个去 abort 会很麻烦。我们可以写一个简单的任务管理器,统一派发 AbortSignal:

class TaskManager {
  constructor() {
    this.controller = new AbortController();
  }

  get signal() {
    return this.controller.signal;
  }

  // 注册一个任务,返回一个包装后的Promise
  run(taskFn) {
    return createCancellableTask(taskFn, this.signal);
  }

  // 取消所有任务
  abort() {
    this.controller.abort();
  }

  // 销毁当前管理器,创建新的控制器(用于页面重新进入时重置)
  reset() {
    this.controller = new AbortController();
  }
}

在页面组件中,你可以这样做:

// 初始化
const manager = new TaskManager();

// 同时启动多个任务
const task1 = manager.run(async (signal) => {
  const res = await fetchWithTimeout('/api/user', { signal });
  return res;
});
const task2 = manager.run(async (signal) => {
  const res = await fetchWithRetry('/api/config', { signal, retries: 1 });
  return res;
});

// 等待所有任务完成
Promise.all([task1, task2]).then(([user, config]) => {
  console.log('数据加载完成', user, config);
}).catch(err => {
  if (err.name === 'AbortError') {
    console.log('任务管理器取消了所有操作');
  }
});

// 页面卸载时取消全部
window.addEventListener('beforeunload', () => manager.abort());

这个管理器特别适合单页应用中的路由级数据加载。进入新路由时创建一个新的 TaskManager,离开时调用 abort(),干净利落。


处理事件监听器中的取消:避免内存泄漏的常用模式

除了网络请求和计算密集型任务,AbortSignal 还能用来清理事件监听器。addEventListener 的第三个参数现在支持传入一个 signal,一旦 signal 被中止,监听器会自动移除。

这个特性在需要临时监听某些事件时格外有用。比如你实现一个“拖拽”功能,在 mousedown 时给 document 添加 mousemove 和 mouseup 监听,拖拽结束后需要手动移除它们。用 AbortSignal 可以简化:

function handleDragStart(startEvent) {
  const controller = new AbortController();
  const { signal } = controller;

  let lastX = startEvent.clientX;
  let lastY = startEvent.clientY;

  document.addEventListener('mousemove', (e) => {
    const deltaX = e.clientX - lastX;
    const deltaY = e.clientY - lastY;
    lastX = e.clientX;
    lastY = e.clientY;
    // 执行拖拽移动逻辑
    console.log('移动', deltaX, deltaY);
  }, { signal });

  document.addEventListener('mouseup', () => {
    controller.abort(); // 两个监听器会同时移除
    console.log('拖拽结束');
  }, { signal, once: true }); // mouseup本身只需要触发一次
}

这样做的好处是不需要保存监听器引用再手动移除,代码更简洁,不容易因为忘记移除而导致内存泄漏。


一个完整的实战场景:搜索框防抖 + 请求取消 + 重试

我们来综合运用以上技巧,实现一个常见的搜索输入功能:

  • 用户输入时做防抖处理
  • 每次新搜索时取消上一次未完成的请求
  • 请求失败或超时时自动重试一次
  • 组件销毁时取消所有进行中的操作

下面这段代码模拟了在单页组件中可能的样子(纯 JS 版本,方便直接测试):

function createSearchHandler() {
  let currentController = null;
  let debounceTimer = null;

  async function search(query) {
    // 取消上一次请求
    if (currentController) {
      currentController.abort();
    }

    // 创建新的控制器用于本次操作
    currentController = new AbortController();
    const { signal } = currentController;

    try {
      const result = await fetchWithRetry(`/api/search?q=${encodeURIComponent(query)}`, {
        timeout: 5000,
        retries: 1,
        retryDelay: 800,
        signal
      });
      console.log('搜索结果', result);
      return result;
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log('搜索被新请求取代');
      } else {
        console.error('搜索失败', err);
      }
    }
  }

  // 返回一个带防抖的搜索函数
  return function debouncedSearch(query) {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => search(query), 300);
  };
}

// 使用
const searchInput = document.getElementById('search-input');
const handleSearch = createSearchHandler();

searchInput.addEventListener('input', (e) => {
  handleSearch(e.target.value);
});

核心点在于,每次新的搜索到来时,我们通过 currentController.abort() 取消上一次请求,这样前一个请求如果还在进行中就会被中止,避免了“输入结束但显示的是上一次结果”的经典 Bug。防抖和取消组合在一起,让搜索体验丝滑很多。


注意事项和常见陷阱

在实际项目中使用这套模式时,有几件事值得留意:

  • AbortError 的区分:不同的浏览器对 AbortError 的 name 属性处理一致,但如果你在 Node.js 环境(如18版本之前)可能没有全局的 AbortController,可以用 abort-controller 这个 polyfill。
  • 与 Promise.race 的关系:很多人直接用 Promise.race 实现超时,但它做不到真正的取消——底层请求仍然在占用资源,只是结果被忽略了。AbortController 可以真正中断网络请求,这一点在资源敏感的场景下很重要。
  • 信号传递的边界:如果你的项目里用了第三方库并且它支持 AbortSignal,直接把 signal 传进去就好(比如 axios 从 0.22.0 开始支持)。如果不支持,就需要用 createCancellableTask 包装。
  • 不要忘记清理:始终在合适的时机(比如组件卸载、页面销毁)调用 abort() ,否则可能会有未完成的异步任务在后台继续执行,浪费资源甚至引发状态错乱。

JavaScript深度实践:手写可取消异步任务管理器与超时重试策略
收藏 (0) 打赏

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

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

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

淘吗网 javascript JavaScript深度实践:手写可取消异步任务管理器与超时重试策略 https://www.taomawang.com/web/javascript/2302.html

常见问题

相关文章

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

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