[转] 封装超时工具方法 (withTimeout)

作者:谢杰

该文章是并发异步操作系列文章第三篇。

前面介绍了关于 Promise 的相关静态方法,本篇文章来做一个实战,封装一个超时工具方法。

需求

先说一下需求,非常简单,执行异步任务的时候,异步任务完成的时间是不定的,因此我们做一个超时的功能。

超时函数(异步任务, 能接受的时间, 遥控器)

超时函数接收 3 个参数:

  1. 异步任务
  2. 能接受的时间:也就是用户传入的超时时间。
  3. 遥控器:说一下这个遥控器,还记得之前的《异步任务取消机制》那篇文章么,当时介绍了一个遥控器,还有一个接收器,遥控器发送“取消任务”的信号,接收器收到信号后取消异步任务。

第一版

我们先封装第一版。先确定方法签名:

/**
 * @template T
 * @param promise   异步任务
 * @param ms        超时时间(毫秒)
 * @param onAbort   用于执行取消/清理动作
 * @returns 				返回一个带超时机制的 Promise
 */
function withTimeout(promise, ms, onAbort){}

假设这个方法已经写好了,调用该方法后,返回的也是一个 promise,准确来讲,是在原有的异步任务的基础上包了一层。例如外面调用示例:

const timeoutTask = withTimeout(task, 2000, () => controller.abort());

这里的 timeoutTask 任务就是带有超时机制的异步任务,你可以:

await timeoutTask;

最多等待 2 秒,因为我们设置的超时时间就是 2 秒。

好了,确定了方法签名以及方法调用后的效果后,接下来就因为来完成 withTimeout 的实现了。

首先,需要返回一个 promise,如下:

function withTimeout(promise, ms, onAbort){
  // 给返回的这个 promise 取个名字,假设就叫小p
  return new Promise((resolve, reject)=>{
    // 这里需要做什么?
  })
}

接下来思考🤔 返回的这个 promise(取名叫小p)内部的函数需要做什么?

其实无非就是两件事情:

  1. 先设置一个计时器进行计时
  2. 开始执行传入的异步任务

如果到了时间异步任务还没执行完,reject 掉小p.

如果异步任务在规定时间内完成,这里也分两种情况:

  • 异步任务正常执行完毕,那么就 resolve 掉小p.
  • 异步任务执行失败,reject 掉小p.

接下来我们一件一件来完成。

首先是设置计时器:

function withTimeout(promise, ms, onAbort){
  let timer = null;
  
  return new Promise((resolve, reject)=>{
    timer = setTimeout(()=>{
      // 代码来到这里,说明到时间了,异步任务却还没有执行完
      // 那么就需要手动取消掉
      // 怎么取消呢?没错,调用 onAbort(遥控器)来取消
      try{
        onAbort && onAbort();
      } catch (err) {
        console.error("onAbort 执行出错:", err);
      }
    }, ms)
  })
}

除了结束掉异步任务,还需要 reject 掉小p,失败的原因标注为“执行超时”,如下:

function withTimeout(promise, ms, onAbort){
  let timer = null;
  
  return new Promise((resolve, reject)=>{
    timer = setTimeout(()=>{
      try{
        onAbort && onAbort();
      } catch (err) {
        console.error("onAbort 执行出错:", err);
      }
       // 用超时错误结束外层 Promise
      reject(new Error(`执行超时 ${ms}ms`));
    }, ms)
  })
}

接下来就是执行传入异步任务,注意这里传入的是异步任务的 IIFE:

const task = (async () => {
  // ...
})(); // 注意这里是一个 async IIFE

因此在 withTimeout 内部,可以直接对这个任务执行 then 操作:

function withTimeout(promise, ms, onAbort){
  let timer = null;
  
  return new Promise((resolve, reject)=>{
    timer = setTimeout(()=>{
      try{
        onAbort && onAbort();
      } catch (err) {
        console.error("onAbort 执行出错:", err);
      }
      reject(new Error(`执行超时 ${ms}ms`));
    }, ms);
    
    // 异步任务的执行
    promise.then(
      (v) => {
        // 异步任务执行成功😊
      },
      (e) => {
        // 异步任务执行失败☹️
      }
    );
  })
}

那么异步任务执行成功和失败,我们分别要做什么呢?

  • 成功:resolve 掉小p,将执行的结果(v)传递出去
  • 失败:reject 掉小p,将失败原因(e)传递出去

另外,无论是成功还是失败,都需要将计时器停掉,因此代码如下:

promise.then(
  (v) => {
    // 原始 promise 成功:先清除超时定时器,防止误触发
    clearTimeout(timer);
    // 把成功结果传递给外层 Promise
    resolve(v);
  },
  (e) => {
    // 原始 promise 失败:同样先清除超时定时器
    clearTimeout(timer);
    // 把失败原因传递给外层 Promise
    reject(e);
  }
);

最终完整代码如下:

function withTimeout(promise, ms, onAbort) {
  // 保存定时器句柄,用于后续清理,避免内存泄漏或“过时回调”触发
  let timer = null;

  // 返回一个新的 Promise,用来包装原始 promise,并加上超时逻辑
  return new Promise((resolve, reject) => {
    // 启动超时定时器:到了 ms 毫秒还没等到 promise settle,就触发超时
    timer = setTimeout(() => {
      try {
        // 如果传入了 onAbort 回调,执行它
        // 常见做法:在这里调用 controller.abort() 取消底层异步任务
        onAbort && onAbort();
      } catch (err) {
        console.error("onAbort 执行出错:", err);
      }
      // 用超时错误结束外层 Promise
      reject(new Error(`执行超时 ${ms}ms`));
    }, ms);

    // 监听原始 promise 的完成情况
    promise.then(
      (v) => {
        // 原始 promise 成功:先清除超时定时器,防止误触发
        clearTimeout(timer);
        // 把成功结果传递给外层 Promise
        resolve(v);
      },
      (e) => {
        // 原始 promise 失败:同样先清除超时定时器
        clearTimeout(timer);
        // 把失败原因传递给外层 Promise
        reject(e);
      }
    );
  });
}

改进版

上面那一版实现,虽然功能上面没有任何问题,但其实可读性上面差强人意,这里我们可以用 Promise 新的 API 来进行改进,通过 Promise.withResolvers() 方法创建一个别名为 out 的 promise(也就是前面的小p),这样就不需要像传统写法那样在 new Promise 构造器里“嵌套”逻辑了。

Promise.withResolvers() 会一次性返回一个对象,里面包含:

  • promise:我们最终要返回的 Promise 实例(这里命名为 out
  • resolve:外部可调用的 resolve 函数
  • reject:外部可调用的 reject 函数
const { promise: out, resolve, reject } = Promise.withResolvers();

这样一来,我们可以先创建好 outresolvereject,然后在函数体里自由安排计时器和原始 promise 的监听逻辑,不必把所有流程都写进 new Promise 的回调里,可读性和可维护性都会更好。

改进后的代码如下:

function withTimeout(promise, ms, onAbort) {
  const { promise: out, resolve, reject } = Promise.withResolvers();
  let timer = null;

  timer = setTimeout(() => {
    try {
      onAbort && onAbort();
    } catch (err) {
      console.error("onAbort 执行出错:", err);
    }
    reject(new Error(`执行超时 ${ms}ms`));
  }, ms);

  // 根据传递进来的promise的执行结果来决定out这个promise的状态
  promise.then(
    (v) => {
      clearTimeout(timer);
      resolve(v);
    },
    (e) => {
      clearTimeout(timer);
      reject(e);
    }
  );

  return out;
}

这样 withTimeout 的逻辑更扁平、职责更清晰,也能避免“new Promise 反模式”的嵌套结构。

细节优化版

在上一版的基础上,还能继续优化。我们看到,异步任务结束后,无论是成功还是失败,都会清除计时器。而目前的写法比较重复,可以优化为 finally,保证计时器在任务结算后必定被清除。

function withTimeout(promise, ms, onAbort) {
  const { promise: out, resolve, reject } = Promise.withResolvers();
  let timer = null;

  timer = setTimeout(() => {
    try {
      onAbort && onAbort();
    } catch (err) {
      console.error("onAbort 执行出错:", err); 
    }
    reject(new Error(`执行超时 ${ms}ms`));
  }, ms);

  // 任务分支:透传原始 promise 的结果到 out,并在无论成功/失败后清理定时器
  promise.then(resolve, reject).finally(() => clearTimeout(timer));

  return out; 
}

取消底层任务

到目前为止,我们上面所实现的版本看上去好像没什么问题,但是,前面的实现表面上能实现超时拒绝,但其实只是把外层 Promise 置为 rejected。

底层真正执行的任务(例如 fetch()、文件读写、网络请求)并不会停止,只是调用方不再等结果而已。

要做到真正的取消,关键是把 AbortSignal 注入到底层任务。

但当前函数签名只接收“已经创建好的 Promise”,这时信号已来不及传入。为此我们对第一个入参做了小改造:它既可以是既有的 Promise,也可以是工厂函数 (signal) => Promise

也就是说,这一版的优化,让外界的调用能采用两种形式:

// 兼容以前的调用方式
// 该方式 promise 已经创建完,超时只能拒绝外层 Promise
withTimeout(fetch(url), 2000, () => controller.abort());
// 第一个参数变为了一个工厂函数
withTimeout(
  (signal) => fetch(url, { signal }), 
  2000,
  // 可选:额外清理动作(比如关闭本地资源、日志等)
  () => { /* custom cleanup */ }
);

当第一个参数是工厂函数时,内部的超时分支会触发 abort(),底层任务被实际中止,这才是“真取消”。

具体步骤:

  1. 方法签名改为:
function withTimeout(promiseOrFactory, ms, onAbort){}

promiseOrFactory 表示第一个参数既可能是原来那种 promise 异步任务,也有可能是一个工厂函数。

  1. 根据 promiseOrFactory

接下来需要根据第一个参数来做不同的事情:

if (typeof promiseOrFactory === "function") {
  // ...
} else {
	// ...
}
  1. 工厂分支

这里的工厂分支是一个重点,我们需要在底层任务开始之前,把 AbortSignal 传递进去。这样在超时的时候,不仅可以让外层 Promise 进入 rejected 状态,还能通知底层任务立刻中止运行(比如 fetch 会直接断开网络连接,流会关闭)。

具体实现思路如下:

(1)创建一个 AbortController:它能生成一个 signal,作为“中止信号”传给底层任务。

(2)封装 onAbort

  • 如果用户没有传 onAbort,那我们就默认在超时时调用 controller.abort()
  • 如果用户传了 onAbort,那就把“中止底层任务”和“用户清理逻辑”结合起来,保证两者都能执行,而且顺序是先中止底层 → 再执行用户清理

(3)执行工厂函数:把 signal 传给它,让底层任务在必要时能够感知到中止。

let taskPromise = null;
let controller = null;

if (typeof promiseOrFactory === "function") {
  // 工厂函数分支:我们创建 AbortController,把 signal 注入到底层任务
  controller = new AbortController();
  const signal = controller.signal;

  // 如果调用方没传 onAbort,我们默认在超时时调用 controller.abort()
  // 如果调用方传了 onAbort,我们把 abort 动作和用户清理动作“组合”起来
  const userOnAbort = onAbort;
  onAbort = async () => {
    // 先中止底层(真正取消)
    try {
      controller.abort();
    } catch {}
    // 再执行用户的清理逻辑(允许是异步)
    if (typeof userOnAbort === "function") await userOnAbort();
  };

  // 由调用方工厂函数真正创建底层 Promise,并且接受 signal
  taskPromise = promiseOrFactory(signal);
}
  1. promise分支

这一分支用于兼容旧写法:此时第一个参数已经是创建完成的 Promise(比如直接传了 fetch(url))。任务已经启动,也就意味着我们无法注入 AbortSignal

因此,超时时最多只能触发 onAbort,但它能否真正取消底层任务,就取决于调用方自己在 onAbort 里怎么实现(比如提前把 controller.abort() 放进去)。

在具体实现上,我们的代码很简单,只需要把这个 Promise 赋值给内部的 taskPromise 就行了:

taskPromise = promiseOrFactory;

最后看一下完整的实现代码:

function withTimeout(promiseOrFactory, ms, onAbort) {
  const { promise: out, resolve, reject } = Promise.withResolvers();
  let timer = null;

  // 如果传入的是工厂函数,则创建可取消的底层任务
  let taskPromise = null;
  let controller = null;

  if (typeof promiseOrFactory === "function") {
    // 工厂函数分支:我们创建 AbortController,把 signal 注入到底层任务
    controller = new AbortController();
    const signal = controller.signal;

    // 如果调用方没传 onAbort,我们默认在超时时调用 controller.abort()
    // 如果调用方传了 onAbort,我们把 abort 动作和用户清理动作“组合”起来
    const userOnAbort = onAbort;
    onAbort = async () => {
      // 先中止底层(真正取消)
      try {
        controller.abort();
      } catch {}
      // 再执行用户的清理逻辑(允许是异步)
      if (typeof userOnAbort === "function") await userOnAbort();
    };

    // 由调用方工厂函数真正创建底层 Promise,并且接受 signal
    taskPromise = promiseOrFactory(signal);
  } else {
    // 兼容旧用法:接收一个已创建的 Promise(此时无法往里注入 signal)
    taskPromise = promiseOrFactory;
  }

  // 超时分支:到点后尝试取消底层任务(若为工厂函数用法即会真正中止)
  timer = setTimeout(async () => {
    try {
      onAbort && (await onAbort());
    } catch (err) {
      console.error("onAbort 执行出错:", err); // 记录但不阻断超时结算
    }
    reject(new Error(`执行超时 ${ms}ms`));
  }, ms);

  // 任务分支:透传结果 + 统一清理定时器
  taskPromise.then(resolve, reject).finally(() => clearTimeout(timer));

  return out;
}

这一版实现,通过引入“工厂函数 + AbortSignal”,不仅能在语义上“超时拒绝”,还能在实现上真正中止底层任务。

写在最后

超时控制是异步编程中非常常见的需求。

从最初的 new Promise 包装,到使用 Promise.withResolvers() 扁平化逻辑,再到用 finally 统一清理计时器,以及最后一版本“工厂函数 + AbortSignal”实现取消底层异步任务,我们一步步优化了可读性与鲁棒性。

这种模式不仅适用于 fetch 等网络请求,也适用于文件读写、流式处理、任务队列等任何可能超时的异步操作。

在实际项目中,其实还可以进一步扩展,例如:

  • 返回一个可手动调用的 cancel() 方法,支持主动中止任务;
  • 自定义 TimeoutError 类型,让调用方能够精准区分错误原因;
  • ms 参数做合法性检查,确保超时逻辑稳定运行。

掌握并灵活运用这些技巧,能让你的异步任务更可控、更健壮,也能为后续的并发、重试、资源清理等高级玩法打下坚实的基础。


-EOF-

posted @ 2025-10-29 14:44  Zhentiw  阅读(7)  评论(0)    收藏  举报