怎样实现 sleep、带超时的请求和可取消的 Promise?
sleep 就是用 setTimeout 在指定时间后 resolve 的 Promise;超时可以用 Promise.race 让请求和一个定时拒绝的 Promise 比赛,但这只是不再等结果,请求本身没停;要真正停下工作,要用 AbortController 把 signal 传进去,fetch 和自己写的 sleep 都能响应它,AbortSignal.timeout 能直接生成到点自动取消的 signal。
简答
轮询接口时想每次间隔一秒再请求,可以写
await sleep(1000)。sleep就是返回一个 Promise,在setTimeout到点时 resolve 它。给请求加超时,最简单的写法是
Promise.race([request, timeout]):timeout是一个到点就 reject 的 Promise,谁先有结果就用谁的。但race只是让调用方不再等,请求本身还在跑,结果来了也没人用。想真正停下工作,要用
AbortController:把controller.signal传给fetch或自己写的异步函数,调用controller.abort()后,它们监听到abort事件,自己清掉定时器或中断请求,并以signal.reason拒绝。AbortSignal.timeout(ms)直接给出一个到点自动取消的 signal,拒绝原因是TimeoutError。Promise 本身没有“取消”这个状态,所谓可取消,是让产生结果的那段代码配合 signal 停下来,再把 Promise 拒绝掉。
sleep 和 Promise.race 超时
先看最基础的两个工具:sleep 支持传入 signal,withTimeout 用 race 给任意 Promise 加时限。
function sleep(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(signal.reason);
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`超时 ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function main() {
const t0 = Date.now();
await sleep(50);
console.log(Date.now() - t0 >= 45); // true
console.log(await withTimeout(sleep(10).then(() => 'ok'), 100)); // ok
try { await withTimeout(sleep(200), 30); } catch (e) { console.log(e.message); } // 超时 30ms
const controller = new AbortController();
const p = sleep(1000, { signal: controller.signal });
controller.abort();
try { await p; } catch (e) { console.log(e.name); } // AbortError
try { await sleep(1000, { signal: AbortSignal.timeout(20) }); } catch (e) { console.log(e.name); } // TimeoutError
}
main();
第一行比较的是 >= 45 而不是 >= 50,因为定时器只保证“不早于大约这个时间”触发,不同环境的计时精度不同,留一点余量让示例稳定。
为什么 withTimeout 里要 finally 清掉定时器?请求先完成时,那个超时定时器还挂着,不清的话它到点仍会 reject 一个已经没人关心的 Promise,在 Node 里还会让进程多等一会儿才退出。
withTimeout(sleep(200), 30) 在 30 毫秒时报了超时,可是 sleep(200) 的定时器并没有被清掉,它照样会在 200 毫秒时 resolve。换成真实请求就是:页面已经提示超时了,请求还在占着网络,服务端也照常处理。这就是 race 的局限,它只决定“用谁的结果”,管不了另一方。MDN 对 race 的描述也是:返回的 Promise 跟随第一个敲定的 Promise 的状态。
用 AbortController 让工作真正停下
sleep 里接收 signal,在 abort 事件里 clearTimeout,这才是真正把工作停了。MDN 写明,abort() 不传原因时,reason 是一个名为 AbortError 的 DOMException;AbortSignal.timeout() 到点时 reason 是名为 TimeoutError 的 DOMException。示例最后两行打印的就是这两个名字。
函数开头先检查 signal.aborted,因为 signal 可能在传进来之前就已经取消了,这时 abort 事件不会再触发,不检查就会白等。resolve 时顺手 removeEventListener,避免同一个 signal 被多次复用时监听器越积越多。
fetch 已经内置了这套逻辑,所以浏览器里给请求加超时,直接写 fetch(url, { signal: AbortSignal.timeout(5000) }) 即可,到点请求会被中止,Promise 以 TimeoutError 拒绝。既要支持超时、又要支持用户手动取消时,用 AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) 把两个 signal 合成一个,MDN 写明任意一个输入取消,合成的 signal 就取消。
MDN 还提到,AbortSignal.timeout() 计的是“活跃时间”,页面进入 bfcache 或所在的 worker 被挂起时会暂停计时。需要按墙上时间严格计时的场景要注意这点。
常见追问
为什么不能直接给 Promise 加一个 cancel 方法?
可以在外面包一层,暴露
cancel去 reject,但这和race一样,只是让等待的人提前收到拒绝,产生结果的代码不知道要停。真正有用的取消,要让执行工作的函数拿到一个能监听的信号,AbortSignal就是浏览器和 Node 都支持的标准做法。
怎样区分是超时还是用户主动取消?
看拒绝原因的
name:AbortSignal.timeout给出的是TimeoutError,controller.abort()不传参数时是AbortError。也可以在abort(reason)里传自定义原因,signal.reason会原样带出来。更多取消细节见《AbortController 能取消什么,不能取消什么?》。