前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Promise.all、race、allSettled、any 四个组合方法怎么选

首页2018-12-05 10:50:20Front-End
PromiseES6JavaScript异步编程

一个页面上要同时拉用户信息、权限列表、消息未读数三个接口,全部回来才能渲染。这时候大部分人会条件反射地写 Promise.all。等到线上跑了两周,消息未读数那个接口偶发超时,整个页面直接白屏,才发现 all 有一条你可能没细想过的规则:只要有一个失败,剩下的结果全都拿不到。

Promise 提供的这几个组合方法,长得都差不多,都是「一堆 Promise 进去,一个 Promise 出来」,区别全在于「什么时候算完成」和「失败了怎么办」这两条规则上。选错了不会报错,只会在某个边界情况下静悄悄地表现得不符合预期。这篇把 all、race、allSettled、any 四个方法的规则摆到一起对比,给一个能直接照着用的选型标准。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Promise.all 的结果顺序为什么和请求完成顺序无关
  • 短路失败带来的三个真实问题,以及绕开的办法
  • Promise.race 的赛跑语义,用它做超时控制的完整写法
  • Promise.allSettled 和 Promise.any 各自补上了哪块空白
  • 四个方法的对照表和选型判断标准

# 一、Promise.all

Promise.all 可以将多个 Promise 实例包装成一个新的 Promise 实例。成功和失败的返回值是不同的:成功的时候返回的是一个结果数组,失败的时候返回的是最先被 reject 的那个失败状态的值。

let p1 = new Promise((resolve, reject) => {
  resolve('成功了')
})

let p2 = new Promise((resolve, reject) => {
  resolve('success')
})

let p3 = Promise.reject('失败')

Promise.all([p1, p2]).then((result) => {
  console.log(result)               // ['成功了', 'success']
}).catch((error) => {
  console.log(error)
})

Promise.all([p1, p3, p2]).then((result) => {
  console.log(result)
}).catch((error) => {
  console.log(error)      // 失败了,打出 '失败'
})
@前端进阶之旅: 代码已经复制到剪贴板

原文这里写的是 Promse.reject,少了一个字母 i,直接就是 Promse is not defined,上面已经改正。

Promise.all 在处理多个异步操作时非常有用,比如说一个页面上需要等两个或多个 ajax 的数据回来以后才正常显示,在此之前只显示 loading 图标。

# 1.1 结果顺序和完成顺序无关

这是 all 最值得单独拿出来说的一条规则。

Promise.all 里的任务列表 [asyncTask(1), asyncTask(2), asyncTask(3)],我们是按照顺序发起的。但根据结果来说,它们是异步的,互相之间并不阻塞,每个任务完成时机是不确定的。尽管如此,所有任务结束之后,它们的结果仍然是按传入顺序映射到结果数组里的,和数组下标一一对应。

这带来了一个很大的好处:在前端开发请求数据的过程中,偶尔会遇到发送多个请求并根据请求顺序获取和使用数据的场景,使用 Promise.all 可以直接解决这个问题,不用自己维护一个「第几个请求回来了」的计数器。

const ids = [2, 3, 5, 7, 11];
const users = await Promise.all(ids.map(id => fetchUser(id)));
// users[0] 一定是 id=2 的那个人,哪怕它最后一个回来
@前端进阶之旅: 代码已经复制到剪贴板

要是没有这条规则,你就得给每个请求带上 id,回来之后再自己排一遍序。这个设计是真的舒服。

# 1.2 短路失败的三个代价

回到开头那个白屏的问题。Promise.all 的失败规则是「有一个 reject,整体立刻 reject」,这一条会带来三个连锁反应。

第一,成功的结果全丢了。 三个接口两个成功一个失败,catch 里只能拿到失败那个的原因,另外两个已经拿回来的数据你一点都摸不到。页面本来可以只降级一小块,结果只能整个白掉。

第二,剩下的请求不会被取消。 all 只是不再等它们了,请求本身该发还是发,该跑还是跑。Promise 天生就没有取消机制,想真的中断得配合 AbortController。

第三,未处理的 rejection 可能报警告。 如果后面还有别的 Promise 也失败了,而 all 返回的那个 Promise 已经 settle 了,这些迟到的失败就没人接。浏览器会触发 unhandledrejection,Node 里更严格,某些版本会直接让进程退出。

绕开的办法有两个。老写法是给每个 Promise 自己挂一个 catch,把失败转成一个标记值,这样 all 眼里全是成功:

const safe = p => p.then(
  value => ({ ok: true, value }),
  reason => ({ ok: false, reason })
);

const results = await Promise.all([a, b, c].map(safe));
// 每一项都能拿到,自己判断 ok
@前端进阶之旅: 代码已经复制到剪贴板

这套写法在 2018 年前后是标准操作,很多项目里都能翻到类似的工具函数。后来标准直接把它内置了,就是下面要讲的 Promise.allSettled。

# 1.3 参数不一定是数组

Promise.all 的参数不一定是数组,任何可迭代对象都行,Set、Map 的 values()、生成器都可以。数组里的成员也不一定非得是 Promise,非 Promise 的值会被 Promise.resolve 包一层,直接当成已完成处理。

Promise.all([1, Promise.resolve(2), 'three'])
  .then(r => console.log(r)); // [1, 2, 'three']
@前端进阶之旅: 代码已经复制到剪贴板

这个特性在写通用工具函数时很好用,调用方传什么进来都不用管,一律扔给 all。

# 二、Promise.race

Promise.race([p1, p2, p3]) 里面哪个结果来得快,就返回哪个结果,不管这个结果本身是成功还是失败。

后半句是重点。很多人以为 race 是「取最快成功的那个」,其实不是,它取的是最快 settle 的那个,失败也算数。

先看一个都成功的例子,返回结果为 3,因为第三个 Promise 的定时器最短:

Promise.race([
  new Promise(function(resolve, reject) {
    setTimeout(() => resolve(1), 1000)
  }),
  new Promise(function(resolve, reject) {
    setTimeout(() => resolve(2), 100)
  }),
  new Promise(function(resolve, reject) {
    setTimeout(() => resolve(3), 10)
  })
]).then(value => {
  console.log(value) // 3
})
@前端进阶之旅: 代码已经复制到剪贴板

再看一个快的那个是失败的例子:

let p1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('success')
  }, 1000)
})

let p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('failed')
  }, 500)
})

Promise.race([p1, p2]).then((result) => {
  console.log(result)
}).catch((error) => {
  console.log(error)  // 打印的是 'failed'
})
@前端进阶之旅: 代码已经复制到剪贴板

p2 在 500ms 时失败,p1 在 1000ms 时才成功。race 在 500ms 就已经 settle 成 rejected 了,后面 p1 的成功没人再关心。

# 2.1 用 race 做超时控制

race 最经典的用途就是给一个没有超时机制的操作加上超时。思路是让真实请求和一个定时炸弹赛跑,谁先到算谁的。

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) => {
    setTimeout(() => reject(new Error(`请求超时 ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]);
}

// 用法
withTimeout(fetch('/api/user'), 3000)
  .then(res => res.json())
  .catch(err => console.log(err.message));
@前端进阶之旅: 代码已经复制到剪贴板

这里有个坑我踩过。上面这个 timeout 里的 setTimeout 不会因为比赛结束就自动清掉,请求 100ms 就回来了,那个 3000ms 的定时器还是会老老实实跑完再触发一次 reject,只是没人理它。单次调用无所谓,如果这个函数在列表里被调几百次,就会有几百个悬着的定时器占着内存。稳妥的写法是在 finally 里 clearTimeout:

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));
}
@前端进阶之旅: 代码已经复制到剪贴板

同样要提醒的是,超时了不等于请求被取消了,后端该收到的还是会收到。真要中断得用 AbortController 配合 fetch 的 signal。

顺带一提,如果 race 传进去的是空数组,返回的 Promise 会永远保持 pending,因为没有任何一个选手会冲线。这个行为不报错,只会让你的 await 卡在那里,排查起来相当费劲。

# 三、后来补上的两个方法

原文写于 2018 年,那时候标准里只有 all 和 race。后面 TC39 又补了两个方法,正好填上了前面提到的两块空白。这两个方法都是 2018 年之后才进入标准的,具体的规范版本以 TC39 提案仓库和 MDN 为准,我这里只讲行为。

# 3.1 Promise.allSettled

allSettled 等所有 Promise 都 settle(不管成功还是失败)才结束,而且永远不会 reject。结果是一个对象数组,每一项要么是 { status: 'fulfilled', value },要么是 { status: 'rejected', reason }。

fe
  • 一、Promise.all
    • 1.1 结果顺序和完成顺序无关
    • 1.2 短路失败的三个代价
    • 1.3 参数不一定是数组
  • 二、Promise.race
    • 2.1 用 race 做超时控制
  • 三、后来补上的两个方法
    • 3.1 Promise.allSettled
    • 3.2 Promise.any
  • 四、四个方法对照与选型
  • 总结
  • 参考

← lodash常用API常用命令之wget使用记录,断点续传与整站镜像 →