先记住这个答案
Promise.race 接收一组 Promise,返回的 Promise 跟随最先敲定的那一个,不管它是成功还是失败。所以只要再造一个「N 毫秒后 reject」的 Promise,和原任务一起传进去,原任务先完成就拿到数据,定时器先到就得到超时错误。超时之后,原 Promise 不会被取消,它会继续执行,完成后的结果被 race 忽略。所以还要在结束时清掉定时器;如果希望请求真的停下,要用 AbortController 去中断它。
Promise.race取最先敲定的结果,成功失败都算- 超时只是不再等,原任务照样执行完
- 用
finally清掉定时器,避免多余的定时器 - 真正取消请求要把
signal传给fetch
用 Promise.race 写一个超时封装
下面的原任务要 200 毫秒,超时时间设成 100 毫秒。
function withTimeout(promise, ms) {
let timer
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('超时')), ms)
})
// 无论谁先完成,都把定时器清掉
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
const task = new Promise((r) => setTimeout(() => { console.log('原任务仍然跑完了'); r('数据') }, 200))
withTimeout(task, 100).then(
(v) => console.log('成功', v),
(e) => console.log('失败', e.message)
)
task.then((v) => console.log('原 Promise 结果', v))查看输出与解释
失败 超时
原任务仍然跑完了
原 Promise 结果 数据100 毫秒时超时 Promise 先 reject,于是 race 返回的 Promise 就被拒绝了。200 毫秒时原任务照样执行,打印日志并兑现为「数据」。直接 then 它的地方能拿到结果,只有 race 不再理它。
finally 里清定时器是为了另一种情况:原任务 10 毫秒就成功了,定时器却还挂着,到点再 reject 一次。这次 reject 虽然会被忽略,但定时器白白占着。在 Node.js 里,没清掉的定时器还会让进程晚退出。
让原任务真的停下来
Promise 本身没有取消能力,race 只是换了一个人不再等。如果原任务是网络请求,超时后浏览器还会继续接收响应,占着连接。要真正中断,得让原任务自己支持取消。
fetchJavaScriptasync function fetchWithTimeout(url, ms) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), ms) // 到点直接中断请求
try {
const res = await fetch(url, { signal: controller.signal })
return await res.json()
} finally {
clearTimeout(timer)
}
}
// 超时后 fetch 以 AbortError 拒绝,Network 面板里请求状态是 canceled这里不需要 race 了,abort() 会让 fetch 自己以 AbortError 拒绝。现在浏览器还提供了 AbortSignal.timeout(ms),一行就能生成一个到点自动中断的 signal,超时时的错误名是 TimeoutError。
- 自己写的异步任务怎么支持取消
- 让函数接收一个
signal参数,在每个耗时步骤之前检查signal.aborted,或者监听它的abort事件,收到就停止后续工作并reject。这样外面的超时逻辑只管调用abort(),任务自己负责收尾。
容易答错的地方
- 以为超时后原任务就不执行了
race只决定返回哪个结果,不会影响传进去的 Promise。原任务里的请求、写库、打日志都会照常发生。如果原任务是提交表单,超时提示「失败」之后数据其实可能已经写进去了,重试前要考虑幂等。- 用
Promise.race只能判断成功先到 race对成功和失败一视同仁。原任务在 50 毫秒时失败了,race就立刻以这个错误拒绝,不会再等超时。这正是超时封装想要的行为,但有人会误以为它会「等到有一个成功为止」,那是Promise.any的语义。- 忘了区分超时错误和业务错误
catch里拿到的可能是超时,也可能是原任务自己的错误。给超时错误一个固定的名字或错误类型,调用方才能给出不同的提示,比如超时提示重试,参数错误提示修改输入。
面试官还会怎么问?
Promise.race 传空数组会怎样?
返回的 Promise 永远是 pending,因为没有任何一项能敲定它。封装超时函数时一般不会遇到,但写通用的 race 工具时要注意。
超时后想自动重试,怎么写?
在 catch 里判断是超时错误,就再调用一次 withTimeout,并带上剩余重试次数,次数用完再抛出。每次重试都要重新创建原任务,因为旧的 Promise 已经敲定,不会重新执行。
Promise.race 和 Promise.any 有什么区别?
race 取第一个敲定的,不管成功失败;any 取第一个成功的,只有全部失败时才以 AggregateError 拒绝。做超时用 race,做「多个 CDN 谁先成功用谁」用 any。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。