作者:谢杰
该文章是并发异步操作系列文章第三篇。
前面介绍了关于 Promise 的相关静态方法,本篇文章来做一个实战,封装一个超时工具方法。
需求
先说一下需求,非常简单,执行异步任务的时候,异步任务完成的时间是不定的,因此我们做一个超时的功能。
超时函数(异步任务, 能接受的时间, 遥控器)
超时函数接收 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 取个名字,假设就叫小preturn new Promise((resolve, reject)=>{// 这里需要做什么?})
}
接下来思考🤔 返回的这个 promise(取名叫小p)内部的函数需要做什么?
其实无非就是两件事情:
- 先设置一个计时器进行计时
- 开始执行传入的异步任务
如果到了时间异步任务还没执行完,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);}// 用超时错误结束外层 Promisereject(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);// 把成功结果传递给外层 Promiseresolve(v);},(e) => {// 原始 promise 失败:同样先清除超时定时器clearTimeout(timer);// 把失败原因传递给外层 Promisereject(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);}// 用超时错误结束外层 Promisereject(new Error(`执行超时 ${ms}ms`));}, ms);// 监听原始 promise 的完成情况promise.then((v) => {// 原始 promise 成功:先清除超时定时器,防止误触发clearTimeout(timer);// 把成功结果传递给外层 Promiseresolve(v);},(e) => {// 原始 promise 失败:同样先清除超时定时器clearTimeout(timer);// 把失败原因传递给外层 Promisereject(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();
这样一来,我们可以先创建好 out、resolve、reject,然后在函数体里自由安排计时器和原始 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(),底层任务被实际中止,这才是“真取消”。
具体步骤:
- 方法签名改为:
function withTimeout(promiseOrFactory, ms, onAbort){}
promiseOrFactory 表示第一个参数既可能是原来那种 promise 异步任务,也有可能是一个工厂函数。
- 根据 promiseOrFactory
接下来需要根据第一个参数来做不同的事情:
if (typeof promiseOrFactory === "function") {// ...
} else {// ...
}
- 工厂分支
这里的工厂分支是一个重点,我们需要在底层任务开始之前,把 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,并且接受 signaltaskPromise = promiseOrFactory(signal);
}
- 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,并且接受 signaltaskPromise = 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-