前端知库

怎样实现 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 加时限。

JavaScript
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 能取消什么,不能取消什么?》。