Skip to content

封装超时工具方法

前面介绍了关于 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.

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

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

首先是设置计时器:

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) => {
// 异步任务执行失败☹️
}
)
})
}

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

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

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() 会一次性返回一个对象,里面包含:

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

(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 等网络请求,也适用于文件读写、流式处理、任务队列等任何可能超时的异步操作。

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

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