首页 阅读DAY15 JavaScript高级程序设计 11章上 期约与异步函数
文章
取消

阅读DAY15 JavaScript高级程序设计 11章上 期约与异步函数

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

单线程是异步产生的原因,事件循环是异步实现的方式。

全文概览:

重要度知识点理由
⭐⭐⭐期约状态机(pending→fulfilled/rejected,不可逆,状态私有)Promise 的基石概念,面试几乎必问”Promise有几种状态”
⭐⭐⭐执行器函数(同步执行、状态不可撤销、resolve/reject 位置固定)理解 Promise 创建过程的基础,手写 Promise 必考
⭐⭐⭐then() 返回新期约 + 返回值经 Promise.resolve() 包装整个 Promise 链式调用的核心规则,面试高频追问”then返回什么”
⭐⭐⭐同步/异步二元性(try/catch 为什么抓不到 Promise.reject()面试经典陷阱题,区分同步错误和异步拒绝的关键
⭐⭐⭐Promise.resolve() 幂等性 + Error 对象被包装为兑现的陷阱面试高频陷阱,Promise.resolve(p) === p 必须知道
⭐⭐⭐Promise.reject() 无幂等性(传入期约→期约成为拒绝理由)与 resolve 对比的经典考点,二者的差异必须清楚
⭐⭐⭐拒绝期约与错误处理(四种等价写法、reject 统一用 Error 对象)工程实践 + 面试常问,“为什么要用 Error 而不是字符串”
⭐⭐⭐onRejected = 异步版 try/catch(捕获后返回兑现期约,链继续)理解 catch 后链不断裂的关键,面试追问高频
⭐⭐⭐期约连锁(异步连锁 vs 同步连锁,解决回调地狱)Promise 的核心价值,面试必问”Promise 如何解决回调地狱”
⭐⭐⭐Promise.all()(全部兑现才兑现,一个拒绝即拒绝,兑现值数组)实际开发高频(并发请求),面试必考,可能追问失败处理
⭐⭐⭐Promise.race()(第一个落定镜像,超时竞速场景)实际开发常用(超时控制),面试常问,注意空数组→永不落定
⭐⭐⭐微任务队列 vs 消息队列(.then 优先于 setTimeout,一个直接进微队列,一个先进延时队列再进微队列)事件循环核心考点,大厂几乎必考执行顺序题
⭐⭐兑现值(value)与拒绝理由(reason)基础概念,状态转换时携带的数据
⭐⭐catch()then(null, onRejected) 的语法糖基础对应关系,了解即可
⭐⭐finally()(原样透传父期约、两种例外:pending/throw)清理场景常用,面试偶尔问”finally 返回值会影响下个 then 吗”
⭐⭐非重入性(处理程序仅排期不立即执行)理解异步模型的关键,可出输出顺序题
⭐⭐邻近处理程序执行顺序(按注册顺序依次执行)基础调度规则,可能结合其他考点出题
⭐⭐传递兑现值和拒绝理由(resolve(value)→onFulfilled(v))基本的值传递机制
⭐⭐Promise.allSettled()(等待全部落定,返回状态对象数组)不要求全部成功的并发场景,实际开发有用
⭐⭐Promise.any()(第一个兑现,全部拒绝→AggregateError)ES2021 新增,了解接口和行为,面试低频但可能问
⭐⭐串行期约合成(reduce 折叠 + compose 工厂函数)进阶用法,中高级面试可能问”如何串行执行多个异步任务”
⭐⭐避免未处理的期约(unhandledrejection / rejectionhandled工程质量相关,大厂面试可能问”如何全局捕获 Promise 错误”
⭐⭐then() 中链式值”丢失”问题 + async/await 解决方案理解为什么需要 async/await 的动机,面试追问可能涉及
Promises/A+ 规范历史(CommonJS→分叉→ES6)知道背景就行,几乎不考
Thenable 接口(有 then 方法的对象)概念了解,async/await 章节会再展开
期约取消(CancelToken 令牌模式)ES 规范不支持,第三方库才有,面试极低频
期约进度追踪(TrackablePromise 扩展)ES 规范不支持,面试几乎不考
console.asyncLog()(setTimeout 包裹 console.log)调试技巧,非知识点
回调地狱的历史(回调→Promise→async/await 迭代)背景知识,了解演进脉络即可

期约和async/await是JavaScript最重要的两个特性,为开发者处理异步操作提供了优雅又高效的方式。

相比回调函数,期约提供更强大的抽象,让开发者能够编写更清晰、更好维护的代码。async/await用看起来同步的语法实现了异步逻辑,这种自然的语法让使用期约变得更加简单,也让异步代码更容易推理。

本章示例将大量使用异步日志函数console.asyncLog()来输出期约实例的状态。这个函数并不是浏览器的原生函数,而是可以像这样定义的:

1
console.asyncLog = (...args) => setTimeout(console.log, 0, ...args)

这个函数输出的内容看起来虽然像是同步输出的,但实际上是异步打印的。这样可以让期约等返回的值达到其最终状态。

此外,浏览器控制台的输出经常能打印出JavaScript运行中无法获取的对象信息(比如期约的状态)。这个特性在示例中广泛使用,以便辅助我们理解相关概念。

异步编程:

同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在JavaScript这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。

异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。

重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。

同步与异步:

同步:指令顺序执行,每步完成后立即可读取结果。

1
2
let x = 3;
x = x + 4; // 同步,执行完立即拿到 x = 7

底层流程:栈内存分配空间 → 执行加法 → 写回内存。所有指令在单线程中按序完成,任意位置都能推断程序状态。

异步:操作由外部事件触发,当前代码不等待,也不知道结果何时可用。

1
2
let x = 3;
setTimeout(() => x = x + 4, 1000); // 异步,1秒后才改 x,但代码不会等

底层加法指令跟同步没区别,但触发方式变了,由系统计时器产生中断,回调入队等待执行。

何时出队这件事对运行时来说是黑盒,无法预知。

核心差异在于同步代码每一步的状态可推断;异步代码在排定回调后,无法预知系统状态何时改变要让后续代码使用异步结果,必须设计通知机制,JavaScript 在实现这套机制的过程中迭代了多次(回调 → Promise → async/await)。

以往的异步编程模式:

早期 JavaScript 只支持回调函数表示异步完成。串联多个异步操作需要深度嵌套,俗称回调地狱。

1
2
3
4
function double(value) {
  setTimeout(() => console.log(value * 2), 1000);
}
double(3); // 6(约1000ms后)

这段代码的关键不在结果,而在理解它为什么是异步的:

  1. setTimeout 定义一个回调,指定时间后调度执行
  2. 到时间后,运行时把回调推入消息队列等待执行
  3. 回调何时出队被执行,对 JS 代码完全不可见
  4. double()setTimeout 成功调度后立即退出,不会等回调执行

异步返回值:

假设setTimeout操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?

广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用(作为回调参数的)异步返回值的代码。

1
2
3
4
5
6
function double(value, callback) {
  setTimeout(() => callback(value * 2), 1000);
}

double(3, (x) => console.log(`I was given: ${x}`));
// I was given: 6(大约1000毫秒之后)

这里的setTimeout调用告诉JavaScript运行时在1000毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。

失败处理:

异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number') {
        throw 'Must provide number as first argument';
      }
      success(2 * value);
    } catch (e) {
      failure(e);
    }
  }, 1000);
}

const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);

double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);

// Success: 6(大约1000毫秒之后)
// Failure: Must provide number as first argument(大约1000毫秒之后)

这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。

嵌套异步回调:

如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number') {
        throw 'Must provide number as first argument';
      }
      success(2 * value);
    } catch (e) {
      failure(e);
    }
  }, 1000);
}

const successCallback = (x) => {
  double(x, (y) => console.log(`Success: ${y}`));
};
const failureCallback = (e) => console.log(`Failure: ${e}`);

double(3, successCallback, failureCallback);

// Success: 12(大约1000毫秒之后)

显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓实至名归。嵌套回调的代码维护起来就是噩梦。

关键在于异步操作没有函数返回值,只能通过推入消息队列里的回调函数传递结果,double(x, …); 里 x 立刻返回undefined,本身无法返回任何东西。

期约:

期约是对尚不存在结果的一个替身。期约(promise)这个名字最早是由Daniel Friedman和David Wise在他们于1976年发表的论文“The Impact of Applicative Programming on Multiprocessing”中提出来的。

但直到十几年以后,Barbara Liskov和Liuba Shrira在1988年发表了论文“Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”,这个概念才真正确立下来。

同一时期的计算机科学家还使用了“终局”(eventual)、“期许”(future)、“延迟”(delay)和“迟付”(deferred)等术语指代同样的概念。所有这些概念描述的都是一种异步程序执行的机制。

Promises/A+ 规范:

早期的期约机制在jQuery和Dojo中是以Deferred API的形式出现的。

到了2010年,CommonJS项目实现的Promises/A规范日益流行起来。Q和Bluebird等第三方JavaScript期约库也越来越得到社区认可,虽然这些库的实现多少都有些不同。

为弥合既有实现之间的差异,2012年Promises/A+ 组织分叉(fork)了CommonJS的Promises/A建议,并以相同的名称制定了Promises/A+ 规范。这个规范最终成为了ECMAScript 6规范实现的范本。

ECMAScript 6增加了对Promises/A+ 规范的完善支持,即Promise类型。一经推出,Promise就大受欢迎,成为了主导性的异步编程机制。所有现代浏览器都支持ES6期约,很多其他浏览器API(如fetch()和Battery Status API)也以期约为基础。

期约基础:

Promise可以通过new操作符来实例化。

创建新期约时需要传入执行器(executor)函数作为参数(后面马上会介绍),下面的例子使用了一个空函数对象来应付一下解释器:

1
2
let p = new Promise(() => {});
console.asyncLog(p);  // Promise <pending>

之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出SyntaxError。

期约状态机:

回调地狱的根源是异步操作没有返回值,只能靠嵌套回调传递结果。期约(Promise)用状态机把”结果还没来”和”结果来了”形式化,是解决回调地狱的基础。

期约是一个有状态的对象,具有三种状态,状态在转换一次之后就不可逆:

1
2
3
pending(待定)
 ├──→ fulfilled(兑现/解决)不可逆
 └──→ rejected(拒绝) 不可逆
  • pending:初始状态,尚未落定(settled)
  • fulfilled:落定为成功,有时候也称为”解决”(resolved)
  • rejected:落定为失败

三个关键约束:

  1. 不可逆:pending → fulfilled 或 pending → rejected 是单向的,落定后状态永远不再改变。
  2. 不一定落定:期约可能永远停在 pending,组织合理的代码无论期约解决、拒绝还是永远待定,都应有恰当行为。
  3. 状态私有:不能直接通过 JavaScript 检测,也不能从外部修改。这是有意设计的,为避免同步代码根据读取到的状态同步处理期约对象。期约的设计目的就是将异步行为封装起来,隔离外部的同步代码。

回调模式中,必须在初始化异步操作时就定义好回调,因为异步返回值只在短时间内存在。期约把对于结果的等待变成了一个状态,但这个状态对开发者不可见,只能通过 .then()/.catch() 在落定时被动接收,不能主动去查结果是否已经产出。这种限制正是为了防止开发者退回同步思维。

兑现的值、拒绝理由及期约用例:

期约有两大用途,区别在于是否关心异步操作的返回数据:

用途一:只关心完成与否

一些情况下,这个状态机就是期约可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。比如发 HTTP 请求,状态码 200~299 → 兑现,否则 → 拒绝。知道期约成功了或失败了就已经足够。

用途二:还要拿到返回数据

比如,假设期约向服务器发送一个HTTP请求并预定会返回JSON。如果请求返回范围在200~299的状态码,则足以让期约的状态变为兑现。此时期约内部就可以收到一个JSON字符串。

类似地,如果请求返回的状态码不在200~299这个范围内,那么就会把期约状态切换为拒绝。此时拒绝的理由可能是一个Error对象,包含着HTTP状态码及相关错误消息。

为支持这两种用例,期约落定时会携带额外信息:

状态内部属性含义默认值
fulfilled(value)兑现时返回的数据undefined
rejected理由(reason)拒绝时的原因undefined
  • 值和理由都是不可修改的引用,可以是原始值或对象
  • 二者可选,默认 undefined
  • 落定后执行的异步代码始终会收到这个值或理由

期约状态是私有的,不能主动去查。但状态转换时携带的值/理由,会通过 .then(value) / .catch(reason) 由队列主动传回。

通过执行器函数控制期约状态:

期约状态是私有的,只能在内部操作,具体在执行器函数(executor)中完成。

执行器函数两项职责:初始化异步行为及控制状态转换

1
2
3
4
5
6
let p1 = new Promise((resolve, reject) => resolve());
console.asyncLog(p1); // Promise <fulfilled>

let p2 = new Promise((resolve, reject) => reject());
console.asyncLog(p2); // Promise <rejected>
// Uncaught error (in promise)
  • 调用 resolve() → 切换为兑现
  • 调用 reject() → 切换为拒绝并抛出错误

执行器是同步执行的,这是因为执行器函数是期约的初始化程序:

1
2
3
4
new Promise(() => console.log('executor'));
console.asyncLog('promise initialized');
// executor ← 先执行
// promise initialized

如果想让状态延迟转换,需要自己加异步,例如添加setTimeout可以推迟切换状态:

1
2
3
4
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000));

// 在console.asyncLog打印期约实例的时候,还不会执行超时回调(即resolve())
console.asyncLog(p);  // Promise <pending>,要1秒后才落定

无论resolve和reject中的哪个被调用,状态转换不可撤销:

1
2
3
4
5
let p = new Promise((resolve, reject) => {
  resolve();
  reject(); // 静默失败,状态已经是 fulfilled,改不回来了
});
console.asyncLog(p); // Promise <fulfilled>

定时退出:防止永远待定

上节说过期约不一定脱离 pending,实际中可以用 setTimeout 保证一定有拒绝期约的回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
let p = new Promise((resolve, reject) => {
  setTimeout(reject, 10000);  // 10秒后调用reject()
  // 执行函数的逻辑
});

console.asyncLog(p);      // Promise <pending>
setTimeout(console.log, 11000, p);  // 11秒后再检查状态

// (After 10 seconds) Uncaught error,函数内部的setTimeout执行,reject调用
// 但代码里没有 .catch() 或 .then() 的第二个参数来接住这个拒绝
// 浏览器发现一个期约被拒绝了却没人处理,就会自动在控制台报一个 Uncaught (in promise) 警告

// (After 11 seconds) Promise <rejected>,外部setTimeout执行

因为状态只能改变一次,超时拒绝可以放心设置:如果执行器在超时前已经落定,超时回调再调 reject() 也会静默失败,不会覆盖。

执行器函数是唯一能操作期约状态的地方。但只能推一次,推了就无法返回。这也呼应了上节状态不可逆的设计:一旦落定,无论是谁、什么时候再尝试改变,都无效。

执行器函数绝大多数时候用箭头函数,因为执行器只是个一次性初始化逻辑,不需要自己的 this,箭头函数最简洁,但语法上任何函数都可行。

另外第一个参数的位置固定是完成,第二个固定是拒绝,即使改变参数命名也是如此,resolve / reject 并不是关键字,但一般不建议修改参数命名。

Promise.resolve():

要注意的是,Promise.resolve()Promise.reject()都不是纯粹的语法糖,Promise.resolve() 的幂等性是执行器写法没有的特殊行为;Promise.reject() 更接近语法糖,但官方规范也没有把它定义为严格意义的语法糖,它是一个独立的静态工厂方法。

通过执行器函数调用 resolve() 可以让期约落定,但期约并非必须从 pending 开始再手动转换。Promise.resolve() 静态方法可以直接实例化一个已兑现的期约,下面两种写法等价:

1
2
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();

值的指定:

传给 Promise.resolve() 的第一个参数即为兑现的(value),多余参数忽略:

1
2
3
Promise.resolve(); // Promise <fulfilled>: undefined
Promise.resolve(3); // Promise <fulfilled>: 3
Promise.resolve(4, 5, 6); // Promise <fulfilled>: 4(5、6 忽略)

也就是说,这个静态方法可以把任何值都转换为一个已兑现的期约。

幂等性:传入期约原样返回

如果传入的参数本身就是一个期约,Promise.resolve() 的行为类似于空包装——直接返回同一个对象,不做任何处理:

1
2
3
let p = Promise.resolve(7);
console.log(p === Promise.resolve(p)); // true
console.log(p === Promise.resolve(Promise.resolve(p))); // true

因此 Promise.resolve() 是一个幂等方法。这个幂等性会保留传入期约的状态,使期约还是 pending,也不会被强制落定:

1
2
3
let p = new Promise(() => {}); // Promise <pending>
console.log(Promise.resolve(p)); // 依然是 Promise <pending>
console.log(p === Promise.resolve(p)); // true

错误对象也会被包装为兑现:

正因为 Promise.resolve() 能包装任何非期约值,包括错误对象,所以可能产生不符合预期的行为:一个 Error 被包装成了兑现的期约,而不是拒绝:

1
2
let p = Promise.resolve(new Error('foo'));
console.log(p); // Promise <fulfilled>: Error: foo ← 兑现了

Promise.reject():

与Promise.resolve()类似,Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过try/catch捕获,只能通过拒绝处理程序捕获)。下面的两个期约实例实际上是一样的:

1
2
let p1 = new Promise((resolve, reject) => reject());
let p2 = Promise.reject();

这个拒绝的期约的理由就是传给Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序:

1
2
3
4
let p = Promise.reject(3);
console.asyncLog(p); // Promise <rejected>: 3

p.then(undefined, (e) => console.asyncLog(e)); // 3

关键在于,Promise.reject()并没有照搬Promise.resolve()的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由

1
2
console.asyncLog(Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <fulfilled>

reject() 能传任何值,不限于 Error 对象:

1
2
3
4
Promise.reject('foo'); // 字符串 ✅ 能跑
Promise.reject(404); // 数字 ✅ 能跑
Promise.reject({ code: 1 }); // 对象 ✅ 能跑
Promise.reject(Error('foo')); // Error 对象 ✅ 能跑且推荐

但规范推荐用 Error 对象,因为只有 Error 保留堆栈追踪:

1
2
3
4
5
6
let p1 = Promise.reject('出错了');
p1.catch(e => console.log(e.stack)); // undefined ❌ 没有堆栈

let p2 = Promise.reject(Error('出错了'));
p2.catch(e => console.log(e.stack)); // Error: 出错了
// at <anonymous>:1:17 ✅

没有堆栈追踪就不知道错误从哪抛的,调试时会很痛苦。reject具备能传任何值的能力,但传 Error是最佳实践。

同步/异步执行的二元性:

Promise的设计很大程度上会导致一种完全不同于JavaScript的计算模式。下面的例子完美地展示了这一点,其中包含了两种模式下抛出错误的情形:

1
2
3
4
5
6
7
8
9
10
11
12
try {
  throw new Error('foo');
} catch(e) {
  console.log(e); // Error: foo
}

try {
  Promise.reject(new Error('bar'));
} catch(e) {
  console.log(e);
}
// Uncaught (in promise) Error: bar

第一个 try/catch 抛出并捕获了错误,第二个抛出了错误却没捕获到。乍一看违反直觉,Promise.reject() 明明是同步调用的,拒绝的期约实例也是同步创建的,错误确实也抛出了,为什么 catch 抓不到?

原因在于:拒绝期约的错误没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。

try/catch 只能捕获同步线程中的错误,而期约的拒绝错误走的是异步通道,等它从消息队列出来时,catch 块已经结束了。代码中确实是同步创建了一个拒绝的期约实例,但错误的传播方式是异步的,同步代码没有捕获到,是因为它没有通过异步模式捕获错误。

这就是期约真正的异步特性,即二元性

  • 同步对象:期约实例在同步执行模式中创建和使用
  • 异步执行模式的媒介:期约的状态变更和错误传播走异步通道

代码一旦开始以异步模式执行,唯一与之交互的方式就是使用异步结构。更具体地说,就是期约的方法.then() / .catch())。

期约的实例方法:

期约实例的方法是连接外部同步代码与内部异步代码的桥梁。这些方法可以访问异步操作返回的数据、处理期约成功和失败的结果、连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

同样值得特别点明的是,前文所述的promise里的执行器方法和静态方法都属于异步数据生产一端,而then()方法属于异步数据生产完毕后的消费一端。

1
2
3
4
5
6
7
8
9
生产端(决定结果) 消费端(处理结果)
┌─────────────────┐ ┌─────────────────┐
│ 执行器 / 静态方法 │ ──→ │ then / catch │
│ │ │ finally │
│ resolve(value) │ │ onFulfilled(v) │
│ reject(reason) │ │ onRejected(e) │
└─────────────────┘ └─────────────────┘
 ↓ ↓
 控制"什么时候出结果" 控制"拿到结果后做什么"

这两个阶段的耦合方式只有值传递,生产端通过 resolve/reject 把值送出去,消费端通过 then/catch 的参数接进来。除此之外互不干扰。

此外,虽然已经提及过,还是要再次强调的是,为保证异步性,promise的执行器也是不能主动返回结果到同步端的,执行器的返回值会被完全忽略。

1
2
3
4
5
6
let p = new Promise((resolve, reject) => {
 resolve('foo');
 return 'bar'; // ← 被忽略,p 的兑现值仍然是 'foo'
});

console.log(p); // Promise <fulfilled>: 'foo',不是 'bar'

如果想要拿到期约结果,就必须要调用 then/catchthen会返回一个新的期约。

实现Thenable接口:

在ECMAScript暴露的异步结构中,任何对象都有一个then()方法。这个方法被认为实现了Thenable接口。下面的例子展示了实现这一接口的最简单的类:

1
2
3
class MyThenable {
  then() {}
}

ECMAScript的Promise类型实现了Thenable接口。这个简化的接口跟TypeScript或其他包中的接口或类型定义不同,它们都设定了Thenable接口更具体的形式。

本章后面再介绍异步函数时还会再谈到Thenable接口的用途和目的。

使用Promise.prototype.then():

then() 是为期约实例添加处理程序的主要方法,接收两个可选参数:onFulfilledonRejected,分别在期约兑现和拒绝时执行。期约只能落定一次,所以这两个处理程序互斥

1
2
3
4
5
6
7
8
9
function onFulfilled(id) { console.log(id, 'fulfilled'); }
function onRejected(id) { console.log(id, 'rejected'); }

let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));

p1.then(() => onFulfilled('p1'), () => onRejected('p1'));
p2.then(() => onFulfilled('p2'), () => onRejected('p2'));
//(3秒后)p1 fulfilled / p2 rejected

参数是可选的:

传给 then() 的非函数参数会被静默忽略。如果只想提供 onRejected,onFulfilled 位置传 nullundefined

1
2
3
4
5
// 非函数被忽略,不推荐
p1.then('gobbeltygook');

// 只传 onRejected 的规范写法
p2.then(null, () => onRejected('p2'));

then() 返回新的期约:

then() 返回一个新的期约实例,不等于原期约:

1
2
3
let p1 = new Promise(() => {});
let p2 = p1.then();
console.log(p1 === p2); // false

这个新期约基于 onFulfilled 处理程序的返回值构建,返回值会经过 Promise.resolve() 包装(这件事是隐式完成的,所以我们很多时候直接简写掉Promise.resolve(),但如果我们需要返回pending的promise,我们就需要显式返回了,此问题在下面讨论的期约连锁的同步连锁与异步连锁中会出现)。

这一规则决定了整个链式调用的行为,下面逐条展开。

onFulfilled 返回值与新期约:

不传处理程序:原值透传。

1
2
3
let p1 = Promise.resolve('foo');
let p2 = p1.then();
// Promise <fulfilled>: foo

无显式返回(等价于返回 undefined):

1
2
3
4
let p3 = p1.then(() => undefined);
let p4 = p1.then(() => {});
let p5 = p1.then(() => Promise.resolve());
// 全部 → Promise <fulfilled>: undefined

有显式返回值Promise.resolve() 包装该值。

1
2
3
let p6 = p1.then(() => 'bar');// 两句等价,本句为下一句的简写
let p7 = p1.then(() => Promise.resolve('bar'));// 这里很明显能看到其实返回值就是一个新的promise对象
// 全部 → Promise <fulfilled>: bar

返回期约Promise.resolve() 的幂等性生效,保留原期约。

1
2
3
4
let p8 = p1.then(() => new Promise(() => {}));
let p9 = p1.then(() => Promise.reject());
// p8 → Promise <pending>
// p9 → Promise <rejected>: undefined(Uncaught 报错)

抛出异常:返回拒绝的期约。如果想要将onFulfilled默认的resolve改写成reject,需要手动抛出错误。

1
2
let p10 = p1.then(() => { throw 'baz'; });
// Promise <rejected>: baz(Uncaught 报错)

返回 Error 对象:不触发拒绝,Error 被包装为兑现的期约。

1
2
let p11 = p1.then(() => Error('qux'));
// Promise <fulfilled>: Error: qux ← 兑现了!

这与前面 Promise.resolve(new Error()) 的陷阱一样,返回错误对象和抛出错误是两回事。throw 走拒绝通道,return Error 走兑现通道。

虽然上面例子里的onFulfilled的箭头函数大部分都是无参的,这样会返回一个新的约定值给予新期约;但也可以赋值,用一个任意命名的参数承接即可,这样承接的参数会接收到旧期约的约定值,并进行处理返回给新契约的约定值。

这样会隐式创建新期约,但也可以显式地创建新期约,在返回值里面直接创建即可,这样一般是为了创建pending的期约或者reject期约。

因为有catch的存在,一般then的写法是不带onRejected方法的,仅传入onFulfilled处理程序,值得注意的是,这个处理程序会被立即加入微队列待处理

onRejected 返回值:

onRejected方法几乎是和onFullfilled方法对称的,主要习惯差异是onFulfilled 通常叫 v(value),onRejected 通常叫 eerr(error),因为拒绝理由几乎总是 Error 对象。

onRejected 处理程序的返回值同样经过 Promise.resolve() 包装。乍看违反直觉——拒绝处理程序不该返回兑现的期约吧?但想一想:onRejected 的任务就是捕获异步错误,捕获后不抛异常是符合预期的行为,应该返回一个兑现期约表示”错误已处理”。

把上面的 Promise.resolve('foo') 换成 Promise.reject('foo'),onFulfilled 换成 onRejected,结果完全对称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let p1 = Promise.reject('foo');

// 不传处理程序 → 原值透传
p1.then(); // Promise <rejected>: foo(Uncaught 报错)

// 无显式返回 → 兑现的 undefined
p1.then(null, () => undefined); // Promise <fulfilled>: undefined
p1.then(null, () => {}); // Promise <fulfilled>: undefined
p1.then(null, () => Promise.resolve()); // Promise <fulfilled>: undefined

// 有显式返回 → Promise.resolve() 包装
p1.then(null, () => 'bar'); // Promise <fulfilled>: bar
p1.then(null, () => Promise.resolve('bar')); // Promise <fulfilled>: bar

// 返回期约 → 幂等性保留
p1.then(null, () => new Promise(() => {})); // Promise <pending>
p1.then(null, () => Promise.reject()); // Promise <rejected>: undefined

// 抛出异常 → 拒绝
p1.then(null, () => { throw 'baz'; }); // Promise <rejected>: baz

// 返回 Error → 兑现(不是拒绝)
p1.then(null, () => Error('qux')); // Promise <fulfilled>: Error: qux

两个处理程序的返回值都走 Promise.resolve() 包装。这意味着无论上一个期约是兑现还是拒绝,只要处理程序正常返回(没抛异常),下一个期约就是兑现的,链式调用因此可以持续向下传递,而不会因为中途处理了拒绝就断裂。

如果认真观察的话,很容易发现,then会返回一个新的promise对象,那么调用then方法的旧promise对象里的数据如果没有被正确处理,是可能有一种所谓的丢失的情况的:

1
2
let p1 = Promise.resolve('foo');
let p2 = p1.then(() => 'bar'); // 原值被忽略,返回值变成新值,'foo'被丢弃

更常见的场景会是这样:

1
2
3
4
fetch(url)
  .then(r => r.json()) // r 丢了
  .then(data => process(data)) // data 丢了
  .then(result => render(result))

不过原期约的值并没有真的消失,可以同一个期约上注册多个处理程序,每次都能拿到原始值:

1
2
3
4
5
6
let p = Promise.resolve('foo');

p.then(v => console.log('第一次:', v)); // foo
p.then(v => console.log('第二次:', v)); // foo
p.then(v => v.toUpperCase()); // FOO(新期约)
p.then(v => console.log('第四次:', v)); // foo,原值还在

这种拿不到是因为链式写法里选择了只走一条链,不是期约本身丢了值。

核心矛盾其实是链式调用每步只能传一个值,中间状态无法保留。这不是 Promise 的 bug,这是 then 链的固有限制,它被设计成流水线,每个工位只管自己的输入输出。

解决方式有两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方式1:用闭包手动保留(丑但能跑)
let r, data;
fetch(url)
  .then(res => { r = res; return res.json(); })
  .then(d => { data = d; return process(d); })
  .then(result => render(result, r, data)); // 都能用

// 方式2:async/await——天然保留所有中间值
async function handle() {
  let r = await fetch(url);
  let data = await r.json();
  let result = await process(data);
  render(result, r, data); // 全在作用域里,一个都没丢
}

async/await 本质上就是解决这种所谓的丢失问题,它让每个 await 的结果都留在变量里,而不是只存在于回调参数中。

使用Promise.prototype.catch():

Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected处理程序。

事实上,这个方法就是一个语法糖,调用它相当于调用Promise.prototype.then(null, onRejected)。

下面的代码展示了这两种同样的情况:

1
2
3
4
5
6
7
8
let p = Promise.reject();
let onRejected = function(e) {
  console.asyncLog('rejected');
};

// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected);  // rejected
p.catch(onRejected);       // rejected

Promise.prototype.catch()返回一个新的期约实例:

1
2
3
4
5
let p1 = new Promise(() => {});
let p2 = p1.catch();
console.asyncLog(p1);         // Promise <pending>
console.asyncLog(p2);         // Promise <pending>
console.asyncLog(p1 === p2);  // false

在返回新期约实例方面,Promise.prototype.catch()的行为与Promise.prototype.then()的onRejected处理程序是一样的。

一般来说,catch是在链式then调用的最后再使用,会捕捉到链中错误的抛出,但是不能知道具体是哪一个环节的then出了错误,如果想对每一个then都进行独特的错误抛出处理,还是需要在每个then里面实现onRejected处理程序。

使用Promise.prototype.finally():

上节 then() 的两个处理程序分别对应兑现和拒绝,但有时两种状态都要执行同一段逻辑(比如清理操作)。finally() 添加的 onFinally 处理程序在期约兑现或拒绝时都会执行,但无法知道是哪种状态,因此主要用于添加清理代码。

1
2
3
4
5
6
let p1 = Promise.resolve();
let p2 = Promise.reject();
let onFinally = function() { console.log('Finally!') }

p1.finally(onFinally); // Finally!
p2.finally(onFinally); // Finally!

返回新期约:

then() 一样,finally() 返回一个新的期约实例,不等于原期约:

1
2
3
let p1 = new Promise(() => {});
let p2 = p1.finally();
console.log(p1 === p2); // false

核心区别:大多数情况下原样透传父期约

then()Promise.resolve() 包装处理程序的返回值来构建新期约,而 finally() 不同——onFinally 是状态无关的,所以大多数情况下它表现为父期约的传递者,返回值被忽略,父期约的状态和值/理由原样后传:

1
2
3
4
5
6
7
8
9
10
11
let p1 = Promise.resolve('foo');

let p2 = p1.finally();
let p3 = p1.finally(() => undefined);
let p4 = p1.finally(() => {});
let p5 = p1.finally(() => Promise.resolve());
let p6 = p1.finally(() => 'bar');
let p7 = p1.finally(() => Promise.resolve('bar'));
let p8 = p1.finally(() => Error('qux'));

// 全部 → Promise <fulfilled>: foo(值始终是 'foo',不是返回值)

对比上节 then()p1.then(() => 'bar') 返回 Promise <fulfilled>: bar,而 p1.finally(() => 'bar') 返回 Promise <fulfilled>: foofinally() 不关心你返回什么,它只管把父期约传下去。

两种例外:返回待定期约或抛出异常

只有两种情况 finally() 不会原样透传:

1. 返回待定期约:新期约也变成 pending,等它落定后仍然传父期约:

1
2
3
4
5
6
7
8
9
10
let p1 = Promise.resolve('foo');
let p2 = p1.finally(
  () => new Promise((resolve, reject) => setTimeout(() => resolve('bar'), 100))
);

console.log(p2); // Promise <pending>

// 100ms 后内部期约兑现了,但:
setTimeout(() => console.log(p2), 200);
// Promise <fulfilled>: foo ← 传的还是父期约的 'foo',不是 'bar'

2. 抛出异常或返回拒绝期约:新期约变为 rejected,覆盖父期约:

1
2
3
let p9 = p1.finally(() => new Promise(() => {})); // Promise <pending>
let p10 = p1.finally(() => Promise.reject()); // Promise <rejected>: undefined
let p11 = p1.finally(() => { throw 'baz'; }); // Promise <rejected>: baz

非重入期约方法:

期约进入落定状态时,相关处理程序只是被排期(加入消息队列),不会立即执行。跟在添加处理程序之后的同步代码一定先于处理程序执行,即使期约一开始就已经是落定状态。这个特性叫非重入(non-reentrancy),由 JavaScript 运行时保证。

期约已落定,处理程序仍然异步:

1
2
3
4
5
6
7
8
9
10
let p = Promise.resolve();

// 直觉上:p 已兑现,处理程序应该立即执行
p.then(() => console.log('onFulfilled handler'));

console.log('then() returns');

// 实际输出:
// then() returns ← 同步代码先执行
// onFulfilled handler ← 处理程序后执行

在已兑现的期约上调用 then(),onFulfilled 处理程序被推进消息队列,但当前线程的同步代码执行完之前不会出队。所以 then() 后面的同步代码一定先跑。

先添加处理程序,后解决期约,同样非重入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let synchronousResolve;

let p = new Promise((resolve) => {
  synchronousResolve = function() {
    console.log('1: invoking resolve()');
    resolve();
    console.log('2: resolve() returns');
  };
});

p.then(() => console.log('4: then() handler executes'));

synchronousResolve();
console.log('3: synchronousResolve() returns');

// 实际输出:
// 1: invoking resolve()
// 2: resolve() returns
// 3: synchronousResolve() returns
// 4: then() handler executes

即使先注册了处理程序,再同步调用 resolve(),处理程序仍然不会挤进同步线程,resolve() 只是把处理程序排期,然后继续执行后面的同步代码,处理程序等当前线程跑完才出队。

非重入适用于所有期约处理程序:onFulfilled / onRejected、catch() 处理程序、finally() 处理程序。期约是同步对象,但它的回调永远走异步通道,无论期约状态如何,处理程序都不会打断当前同步执行流。

邻近处理程序的执行顺序:

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论then()、catch()还是finally()添加的处理程序都是如此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let p1 = Promise.resolve();
let p2 = Promise.reject();

p1.then(() => console.asyncLog(1));
p1.then(() => console.asyncLog(2));
// 1
// 2

p2.then(null, () => console.asyncLog(3));
p2.then(null, () => console.asyncLog(4));
// 3
// 4

p2.catch(() => console.asyncLog(5));
p2.catch(() => console.asyncLog(6));
// 5
// 6

p1.finally(() => console.asyncLog(7));
p1.finally(() => console.asyncLog(8));
// 7
// 8

传递兑现的值和拒绝理由:

落定后,期约将兑现值或拒绝理由传给对应的处理程序。典型场景例如第一次请求的 JSON 是第二次请求的参数,兑现值就会传给 onFulfilled 继续处理;失败时 HTTP 状态码传给 onRejected

传递路径:执行器中 resolve(value) / reject(reason) 的第一个参数 → 处理程序 onFulfilled / onRejected 的唯一参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 执行器方式
let p1 = new Promise((resolve, reject) => resolve('foo'));
p1.then((value) => console.log(value)); // foo

let p2 = new Promise((resolve, reject) => reject('bar'));
p2.catch((reason) => console.log(reason)); // bar

// 静态方法方式(同样传递)
let p3 = Promise.resolve('foo');
p3.then((value) => console.log(value)); // foo

let p4 = Promise.reject('bar');
p4.catch((reason) => console.log(reason)); // bar

拒绝期约与拒绝错误处理:

拒绝期约类似 throw(),都表示需要中断或特殊处理的程序状态。以下四种写法结果相同,均以 Error 对象为由被拒绝:

1
2
3
4
5
6
7
8
9
10
11
12
let p1 = new Promise((resolve, reject) => reject(Error('foo')));
let p2 = new Promise((resolve, reject) => { throw Error('foo'); });
let p3 = Promise.resolve().then(() => { throw Error('foo'); });
let p4 = Promise.reject(Error('foo'));

console.asyncLog(p1);  // Promise <rejected>: Error: foo
console.asyncLog(p2);  // Promise <rejected>: Error: foo
console.asyncLog(p3);  // Promise <rejected>: Error: foo
console.asyncLog(p4);  // Promise <rejected>: Error: foo


// 也会抛出4个未捕获错误

拒绝理由统一用 Error 对象,不用 undefined 等原始值,因为 Error 对象携带栈跟踪信息,对调试至关重要。

异步错误的两个关键区别:

 同步 throw期约拒绝
阻塞后续代码✅ 阻止执行❌ 不阻止(错误从消息队列异步抛出)
捕获方式try/catchonRejected 处理程序(.catch()
1
2
3
4
5
6
7
// throw 阻塞后续指令
throw Error('foo');
console.log('bar'); // 不会执行

// 期约拒绝不阻塞同步指令
Promise.reject(Error('foo'));
console.log('bar'); // bar ✓
1
2
3
4
5
// ✅ 正确:异步捕获
Promise.reject(Error('foo')).catch((e) => {});

// ❌ 错误:try/catch 捕不到
try { Promise.reject(Error('foo')); } catch(e) {}

唯一例外:执行器函数内部,resolve/reject 调用之前,仍可用 try/catch 捕获同步错误:

1
2
3
4
let p = new Promise((resolve, reject) => {
  try { throw Error('foo'); } catch(e) {}
  resolve('bar'); // 错误被吞掉,期约正常兑现
});

onRejected = 异步版的 try/catch:

两者语义一致:捕获错误 → 隔离 → 不影响后续逻辑。因此 onRejected 的正确做法是捕获后返回兑现期约,让链式调用继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 同步
try { throw Error('foo'); }
catch(e) { console.log('caught', e); }
console.log('continue'); // continue ✓

// 异步——结构完全对应
new Promise((resolve, reject) => {
  reject(Error('bar'));
}).catch((e) => {
  console.log('caught', e);
}).then(() => {
  console.log('continue'); // continue ✓
});

期约连锁与期约合成:

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁和期约合成。

前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

而回调地狱到了期约连锁这一块才能被说是真正解决了,因为then返回新的期约对象,所以可以一直对着新对象接着then下去,而不用嵌套了。

期约连锁:

then()/catch()/finally() 都返回新期约,新期约又有自己的实例方法,即期约连锁。

同步连锁(没什么用):

1
2
3
4
5
6
7
8
9
10
11
12
let p = new Promise((resolve, reject) => {
  console.log('first');
  resolve();
});
p.then(() => console.log('second'))
 .then(() => console.log('third'))
 .then(() => console.log('fourth'));

// first
// second
// third
// fourth

四个同步任务顺序执行,跟直接调四个函数没区别,不能体现期约连锁的价值。

异步连锁(核心用法):

要真正执行异步任务,可以改写前面的例子,让每个执行器函数都返回一个期约实例。这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。比如,可以像下面这样让每个期约在一定时间后解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let p1 = new Promise((resolve, reject) => {
  console.log('p1 executor');
  setTimeout(resolve, 1000);
});

p1.then(() => new Promise((resolve, reject) => {
    console.log('p2 executor');
    setTimeout(resolve, 1000);
  }))
  .then(() => new Promise((resolve, reject) => {
    console.log('p3 executor');
    setTimeout(resolve, 1000);
  }))
  .then(() => new Promise((resolve, reject) => {
    console.log('p4 executor');
    setTimeout(resolve, 1000);
  }));

// p1 executor(1秒后)
// p2 executor(2秒后)
// p3 executor(3秒后)
// p4 executor(4秒后)

提取工厂函数会更清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function delayedResolve(str) {
  return new Promise((resolve, reject) => {
    console.log(str);
    setTimeout(resolve, 1000);
  });
}

delayedResolve('p1 executor')
  .then(() => delayedResolve('p2 executor'))
  .then(() => delayedResolve('p3 executor'))
  .then(() => delayedResolve('p4 executor'))

// p1 executor(1秒后)
// p2 executor(2秒后)
// p3 executor(3秒后)
// p4 executor(4秒后)

这就是回调地狱的解法,同样的逻辑,如果使用回调写法:

1
2
3
4
5
6
7
delayedExecute('p1', () => {
  delayedExecute('p2', () => {
    delayedExecute('p3', () => {
      delayedExecute('p4');
    });
  });
});

而期约连锁把嵌套的三角形拉成了一条直线,很大程度提高了可读性。

catch/then/finally 可以任意混用:

三个方法都返回期约,可以任意串联。

1
2
3
4
5
6
7
8
new Promise((resolve, reject) => {
  console.log('initial promise rejects');
  reject();
})
.catch(() => console.log('reject handler'))
.then(() => console.log('resolve handler'))
.finally(() => console.log('finally handler'));
// reject handler → resolve handler → finally handler

使用Promise.all():

Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。比如,从多个API同时获取数据,或者同时执行多个数据库查询,需要等待所有请求完成再统一处理结果。这个静态方法接收一个可迭代对象,返回一个新期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let p1 = Promise.all([
  Promise.resolve(),
  Promise.resolve()
]);

// 可迭代对象中的元素会通过Promise.resolve()转换为期约
let p2 = Promise.all([3, 4]);

// 空的可迭代对象等价于Promise.resolve()
let p3 = Promise.all([]);

// 无效的语法
let p4 = Promise.all();
// TypeError: cannot read Symbol.iterator of undefined

合成的期约只会在每个包含的期约都兑现之后才解决:

1
2
3
4
5
6
7
8
9
let p = Promise.all([
  Promise.resolve(),
  new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
console.asyncLog(p); // Promise <pending>

p.then(() => console.asyncLog('all() fulfilled!'));

// all() fulfilled!(大约1秒后)

如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 永远待定
let p1 = Promise.all([new Promise(() => {})]);
console.asyncLog(p1); // Promise <pending>

// 一次拒绝会导致最终期约拒绝
let p2 = Promise.all([
  Promise.resolve(),
  Promise.reject(),
  Promise.resolve()
]);
console.asyncLog(p2); // Promise <rejected>

// Uncaught (in promise) undefined

如果所有期约都成功解决,则合成期约的兑现值就是所有包含期约兑现值的数组,按照迭代器顺序:

1
2
3
4
5
6
7
let p = Promise.all([
  Promise.resolve(3),
  Promise.resolve(),
  Promise.resolve(4)
]);

p.then((values) => console.asyncLog(values)); // [3, undefined, 4]

如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作,如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 虽然只有第一个期约的拒绝理由会进入
// 拒绝处理程序,第二个期约的拒绝也
// 会被静默处理,不会有错误被漏掉
let p = Promise.all([
  Promise.reject(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);

p.catch((reason) =>console.asyncLog(reason)); // 3

// 没有未处理的错误

使用Promise.allSettled():

Promise.allSettled()静态方法可以等待一个期约集合中的所有期约落定,无论解决还是拒绝。这个方法适合需要处理多个异步操作的结果,但不要求所有异步操作都成功的场景。

与Promise.all()类似,这个方法接受一个可迭代对象,返回一个新的期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let p1 = Promise.allSettled([
  Promise.resolve(),
  Promise.reject()
]);

// 可迭代对象中的元素会使用Promise.resolve()转换为期约
let p2 = Promise.allSettled([3, 4]);

// 空的可迭代对象等价于调用Promise.resolve()
let p3 = Promise.allSettled([]);

// 无效语法
let p4 = Promise.allSettled();
// TypeError: cannot read Symbol.iterator of undefined

合成期约会在所有包含期约都落定后解决,可能有兑现的,也有拒绝的:

1
2
3
4
5
6
7
8
9
let p = Promise.allSettled([
  Promise.resolve(),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
console.asyncLog(p); // Promise <pending>

p.then(() => console.asyncLog('allSettled() resolved!'));

// allSettled() resolved!(约1000毫秒之后)

在所有期约落定之后,合成期约的兑现值将是一个对象的数组,包含可迭代对象中每个期约的输出。每个对象都有一个status属性,值为 ‘fulfilled’ 或 ‘rejected’。根据前一个属性的值,还会有一个value或reason属性:

1
2
3
4
5
6
7
8
9
10
11
12
let p = Promise.allSettled([
  Promise.resolve(3),
  Promise.reject(4),
  Promise.resolve(5)
]);

p.then((results) => console.asyncLog(results));
// [
//   { status: 'fulfilled', value: 3 },
//   { status: 'rejected', reason: 4 },
//   { status: 'fulfilled', value: 5 }
// ]

这让开发者无须添加额外的错误处理程序,就能轻松处理每个期约的结果。使用每个结果对象的status属性就能确定如何处理每个期约的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.allSettled([
  fetchDataFromAPI1(),
  fetchDataFromAPI2(),
  fetchDataFromAPI3()
]).then((results) => {
  results.map((result, i) => {
    if (result.status === 'fulfilled') {
      console.asyncLog(`API ${i} data:`, result.value);
    } else {
      console.asyncLog(`API ${i} error:`, result.reason);
    }
  });
});

使用Promise.race():

Promise.race()静态方法返回一个包装期约,是一组集合中最先兑现或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let p1 = Promise.race([
  Promise.resolve(),
  Promise.resolve()
]);

// 可迭代对象中的元素会通过Promise.resolve()转换为期约
let p2 = Promise.race([3, 4]);

// 空的可迭代对象等价于new Promise(() => {})
let p3 = Promise.race([]);

// 无效的语法
let p4 = Promise.race();
// TypeError: cannot read Symbol.iterator of undefined

Promise.race()不会对兑现或拒绝的期约区别对待。无论兑现还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其兑现的值或拒绝理由并返回新期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 解决先发生,超时后的拒绝被忽略
let p1 = Promise.race([
  Promise.resolve(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
console.asyncLog(p1); // Promise <fulfilled>: 3

// 拒绝先发生,超时后的解决被忽略
let p2 = Promise.race([
  Promise.reject(4),
  new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
console.asyncLog(p2); // Promise <rejected>: 4

// 迭代顺序决定了落定顺序
let p3 = Promise.race([
  Promise.resolve(5),
  Promise.resolve(6),
  Promise.resolve(7)
]);
console.asyncLog(p3); // Promise <fulfilled>: 5

如果有一个期约拒绝,只要它是第一个落定的,就会成为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。

不过,这并不影响所有包含期约正常的拒绝操作。与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作,如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 虽然只有第一个期约的拒绝理由会进入
// 拒绝处理程序,第二个期约的拒绝也
// 会被静默处理,不会有错误被漏掉
let p = Promise.race([
  Promise.reject(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);

p.catch((reason) => console.asyncLog(reason)); // 3

// 没有未处理的错误

使用Promise.any():

Promise.any()静态方法用于等待期约集合中第一个兑现的期约,实际上是这个期约解决后的一种短路操作。这个方法适合从多个数据来源中选一个,或者希望在多个竞争性任务中选择响应最快的那个。

与Promise.all()和Promise.allSettled()类似,这个静态方法也接受一个可迭代对象并返回一个新期约。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let p1 = Promise.any([
  Promise.resolve(),
  Promise.reject()
]);

// 可迭代对象中的元素会使用Promise.resolve()转换为期约
let p2 = Promise.any([3, 4]);

// 空的可迭代对象等价于调用Promise.resolve()
let p3 = Promise.any([]);
// Uncaught (in promise) AggregateError: All promises were rejected

// 无效语法
let p4 = Promise.any();
// TypeError: cannot read Symbol.iterator of undefined

返回的合成期约将在任意一个包含期约兑现时解决:

1
2
3
4
5
6
7
8
9
let p = Promise.any([
  new Promise((resolve, reject) => setTimeout(resolve, 1000, 'first')),
  new Promise((resolve, reject) => setTimeout(resolve, 2000, 'second'))
]);
console.asyncLog(p); // Promise <pending>

p.then((value) => console.asyncLog('any() resolved:', value));

// any() resolved: first(约1000毫秒之后)

如果所有包含期约都被拒绝,那么合成期约也会被拒绝。拒绝理由是包含所有期约拒绝理由的AggregateError实例:

1
2
3
4
5
6
7
let p = Promise.any([
  Promise.reject("error1"),
  Promise.reject("error2")
]);
console.asyncLog(p); // Promise <rejected>

// Uncaught (in promise) AggregateError: All promises were rejected

如果需要,可以捕获AggregateError并处理拒绝理由:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.any([
  fetchDataFromAPI1(),
  fetchDataFromAPI2(),
  fetchDataFromAPI3()
]).then((data) => {
  console.asyncLog('Fastest data:', data);
}).catch((error) => {
  if (error instanceof AggregateError) {
    console.asyncLog('All promises rejected. Errors:', error.errors);
  } else {
    console.asyncLog('Unknown error:', error);
  }
});
  • all:齐心协力 → 一个失败全局失败
  • allSettled:无论成败 → 全部记录下来再说
  • race:谁先到谁赢 → 不管成败
  • any:谁先成功谁赢 → 全部失败才算输

串行期约合成:

期约连锁不仅可以处理异步操作,还能把前一个期约的返回值传递给下一个处理程序,像工厂流水线一样,值从一端流到另一端,最终产出结果。

这本质上就是函数合成的思路。先看同步版本:

1
2
3
4
5
6
7
function addTwo(x) { return x + 2; }
function addThree(x) { return x + 3; }
function addFive(x) { return x + 5; }

// 手工嵌套调用
const addTen = (x) => addFive(addThree(addTwo(x)));
console.log(addTen(7)); // 17

三个函数基于同一个值依次执行,最终返回一个结果。如果每一步都是异步的,嵌套回调丑陋且难以维护,可以改用期约连锁:

1
2
3
4
5
6
7
const addTen = (x) =>
  Promise.resolve(x)
    .then(addTwo)
    .then(addThree)
    .then(addFive);

addTen(8).then(console.log); // 18

.then() 的处理函数自动接收上一步的返回值,吐出新值给下一步。值就这样在期约之间流淌,无需嵌套。

用 reduce 可以写出更简洁的形式,每次手写 .then().then().then() 繁琐,可以用 Array.prototype.reduce() 把链条折叠成一行:

1
2
3
4
5
const addTen = [addTwo, addThree, addFive]
  .reduce(
    (promise, fn) => promise.then(fn), // 把函数接上期约链
    Promise.resolve(x) // 初始值:把 x 包进期约
  );

reduce 的累积值不是数字,而是一个期约,每次把新的处理函数接上去,形成更长的链。

也可以抽象出 compose 工厂函数,既然”把函数依次作用于一个值”这个模式是通用的,就可以提炼成纯函数:

1
2
3
4
5
6
function compose(...fns) {
  return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
}

const addTen = compose(addTwo, addThree, addFive);
addTen(8).then(console.log); // 18

compose 接收任意多个函数,返回一个新函数。调用时自动把参数包进期约,再依次通过每个处理函数,把同步函数合成的思路无缝迁移到异步场景。

期约与微任务队列:

在这个内容开始前,应该先补充事件循环的部分知识,例如JS是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。每一个页面运行一套自己的js运行时,对应的主线程只能有一个,且一定不能暂停。事件循环这一整套机制就是浏览器渲染主线程的工作方式。

在这样的基础上,如果我们要保证这唯一的渲染主线程不中断,又想不中断的同时处理一些信息,就需要其他线程的参与。setTimeout 会插入到一个实际上由操作系统负责的计时线程中,由操作系统承担时间的一致。这个线程比浏览器的渲染主线程还可能复杂很多,不过这个线程的细节与这里的内容就无关了。

除了这种有其他功能参与的特殊线程参与渲染主线程的协作之外,一个页面也具有自己的消息队列,按目前的标准,可以分为微队列和其他消息队列,微队列的优先级最高。

为什么期约比 setTimeout 先执行:

当期约落定后,.then() 的回调不会立即执行,而是进入微任务队列(是立即进入),等同步代码全部跑完才轮到微队列和消息队列的处理。而setTimeout会进入延时队列,而微队列的微任务一定比其他消息队列的宏任务先执行。

对比如下:

1
2
3
4
5
console.log('1');
setTimeout(() => console.log('4'), 0); // → 消息队列(宏任务)
Promise.resolve('3').then(console.log); // → 微任务队列
console.log('2');
// 输出顺序:1 → 2 → 3 → 4

关键差异:

  • 宏任务(setTimeout、setInterval、I/O):等整块同步代码跑完,再等所有微任务清空,才执行下一轮
  • 微任务(.then()、.catch()、.finally()、queueMicrotask): 同步代码一结束立刻执行,且全部清空才进入下一轮宏任务

按上面代码,走完整个事件循环:

顺序操作结果
1同步:console.log('1')打印 1
2setTimeout 回调进入消息队列等待中
3Promise.resolve() 立即兑现,.then() 进入微任务队列等待中
4同步:console.log('2')打印 2
5同步代码跑完,JS 引擎清空微任务队列.then() 执行,打印 3
6微任务队列空,事件循环进入下一轮,执行消息队列setTimeout 执行,打印 4

关键点:

  • 微任务队列优先级高于消息队列,同步代码结束,先清微任务,再清宏任务
  • .then() 回调不一定立即执行,它只是被排期(scheduled),真正执行要等微任务队列轮到它
  • 微任务队列可以包含多个回调,会全部执行完才进入下一轮宏任务
区分宏任务 (Macrotask)微任务 (Microtask)
是什么一件完整的事(JS函数、渲染、事件)依附于某个期约/任务的收尾工作
谁来产生setTimeoutsetInterval、I/O、用户事件、渲染.then().catch().finally()queueMicrotask()MutationObserver
进入哪个队列除微任务外的其余消息队列(Message Queue)微任务队列(Microtask Queue)
何时执行等当前所有同步代码 + 整个微任务队列清空后,才轮到自己同步代码一结束,立即全部清空
典型场景定时器、用户点击回调、网络请求回调期约的 .then() 回调

在以前的规范中,消息队列只分为微队列和宏队列,伴随浏览器的任务日益复杂,这种模式已经不足以解决问题了。在当前规范中,消息队列包含了微队列和其他针对不同任务而设计的若干队列,同类任务必须放置在同一队列,不同类任务可以放在同一队列。这些队列之间具有不同的优先级,而微队列的优先级最高。

要注意的是,一轮宏任务的执行包含了本轮同步代码的执行和微队列的处理,对于消息队列的处理是不包含在任意一轮中的,即在微队列清空之后,下一轮宏任务开始之前。

更准确地说,本质是事件循环在清空微任务(如果微任务产生了新的微任务,也同样要处理完新的微任务才算清空微队列,如then调用then)之后,会主动去各个 Task Queue 里按优先级取任务。事件循环从 Task Queue 中按优先级取出下一个可执行的任务,才决定下一轮宏任务是否发生、以及发生哪一个。

每个宏任务(无论来自同步代码还是 setTimeout)都拥有自己独立的一轮,setTimeout 的注册属于当前轮,每轮宏任务(同步代码)执行完后,立即清空微任务队列;之后事件循环按优先级从各个 Task Queue 里取下一个任务,取到的那一个才作为”下一轮宏任务”执行。

setTimeout 的回调被放进 Timer Queue,注册发生在当前轮,但执行要等事件循环依次处理完更高优先级的队列之后,才会被取出执行,而此时它已经是新的一轮了。

避免未处理的期约:

期约拒绝的行为并不直观,不处理拒绝,不一定立刻报错,而是会悄悄滑到顶级,由浏览器进行相关处理。

对此有两个核心特性必须掌握:

  1. 未处理的期约拒绝,会在顶级抛出 unhandledrejection 错误
  2. 无论在代码哪个位置,只要添加了 onRejected 处理程序,就能接收拒绝

怎么发现未处理的拒绝?

可以直接 Promise.reject() 而不接 .catch(),错误会从页面级被抛出:

1
2
Promise.reject('foo');
// Uncaught (in promise) foo

想在代码里主动检测这个错误,只有一种方式,监听 unhandledrejection 事件:

1
2
3
4
5
6
7
window.addEventListener('unhandledrejection', () => {
  console.log('UNHANDLED');
});

Promise.reject('foo');
// 输出:UNHANDLED
// 控制台同时显示:Uncaught (in promise) foo

这个事件在微任务队列执行完毕后、还没有 onRejected 处理程序时触发。

方式一:显式添加 onRejected 处理程序

在期约链中任何位置加上 .catch().then(null, onRejected),就能阻止错误冒泡:

1
2
3
4
5
6
window.addEventListener('unhandledrejection', () => {
  console.log('UNHANDLED');
});

Promise.reject().catch(() => {});            // 兜住了,控制台无输出
Promise.reject().then(null, () => {});      // 等价,控制台无输出

注意:处理程序可以是空的,只要存在,浏览器就认为开发者已经得知,不会触发 unhandledrejection

方式二:用 Promise.allSettled() 隐式处理

Promise.allSettled() 会等待所有期约落定(无论兑现还是拒绝),所以用它包装的拒绝不会被漏掉:

1
2
3
4
5
window.addEventListener('unhandledrejection', () => {
  console.log('UNHANDLED');
});

Promise.allSettled([Promise.reject()]); // 控制台无输出

因为所有拒绝都变成了”已落定”状态,不再是”未处理”。

延迟处理:用 rejectionhandled 捕获后补的 catch

有时候拒绝在期约刚落定时还没处理程序,后来才加上。rejectionhandled 事件可以检测到这种”迟到”的处理:

1
2
3
4
5
6
7
8
9
10
11
window.addEventListener('rejectionhandled', () => {
  console.log('HANDLED LATE');
});

const p = Promise.reject();

setTimeout(() => {
  p.catch(() => {}); // 1秒后才补上处理程序
}, 1000);

// 输出:HANDLED LATE(1秒后)

rejectionhandled 在原本该触发 unhandledrejection、但后来补上了处理程序时触发。

处理拒绝的三种方式总结:

方式写法特点
.catch()p.catch(() => {})显式,最常用
.then(null, onRejected)p.then(null, fn)显式,.then() 的第二个参数
Promise.allSettled()Promise.allSettled([p])隐式,拒绝不漏,但等待全部落定

最佳实践:要尽可能在代码中避免未处理的拒绝。错误冒泡到顶级让用户看到,是典型的代码坏味道。养成在每个期约链末端加 .catch() 的习惯。

补充:

前文提及的三个机制要一起用才能覆盖完全的错误处理场景。

1
2
3
4
5
6
7
8
9
Promise.reject('foo')

 ↓ 微任务队列处理时,发现没有 onRejected
 ↓
[1] unhandledrejection 事件触发 ← 第1个事实:未处理的拒绝会冒泡到顶级
 ↓
 p.catch(() => {}) ← 事后补上 onRejected
 ↓
[2] rejectionhandled 事件触发 ← 延迟处理被捕获
机制什么时候触发作用
unhandledrejection拒绝发生 + 微任务队列处理时没有任何 onRejected检测漏网之鱼
onRejected 处理程序任何时候 attach 都行主要处理手段
rejectionhandledrejection 先触发了 unhandledrejection,然后才 attach onRejected捕获事后补救的情况

期约扩展:

ECMAScript期约实现是很可靠的,但也有不足之处。

比如,很多第三方期约库实现了而ECMAScript规范却未涉及的两个特性:期约取消和期约进度追踪

这块不是特别重要,书上有因此记录进来,仅作为补充。

期约取消:

我们经常会遇到期约正在处理过程中,程序不再需要其结果的情形。这时候如果能够取消期约就好了。某些第三方库,比如Bluebird,就提供了这个特性。

实际上,TC39委员会也曾准备添加这个特性,但相关提案最终被撤回了。结果,ECMAScript期约被认为是“激进的”:只要期约的逻辑开始执行,就没有办法阻止它执行到完成。

实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。

这可以用到Kevin Smith提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。

下面是CancelToken类的一个基本实例:

1
2
3
4
5
6
7
class CancelToken {
  constructor(cancelFn) {
    this.promise = new Promise((resolve, reject) => {
      cancelFn(resolve);
    });
  }
}

这个类包装了一个期约,把解决方法暴露给了cancelFn参数。这样,外部代码就可以向构造函数中传入一个函数,从而控制什么情况下可以取消期约。这里期约是令牌类的公共成员,因此可以给它添加处理程序以取消期约。

这个类大概可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<button id="start">Start</button>
<button id="cancel">Cancel</button>

<script>
class CancelToken {
  constructor(cancelFn) {
    this.promise = new Promise((resolve, reject) => {
      cancelFn(() => {
        console.asyncLog("delay cancelled");
        resolve();
      });
    });
  }
}

const startButton = document.querySelector('#start');
const cancelButton = document.querySelector('#cancel');

function cancellableDelayedResolve(delay) {
 console.asyncLog("set delay");

  return new Promise((resolve, reject) => {
    const id = setTimeout((() => {
      console.asyncLog("delayed resolve");
      resolve();
    }), delay);

    const cancelToken = new CancelToken((cancelCallback) =>
      cancelButton.addEventListener("click", cancelCallback));

    cancelToken.promise.then(() => clearTimeout(id));
  });
}

startButton.addEventListener("click", () => cancellableDelayedResolve(1000));
</script>

每次单击“Start”按钮都会开始计时,并实例化一个新的CancelToken的实例。此时,“Cancel”按钮一旦被点击,就会触发令牌实例中的期约解决。而解决之后,单击“Start”按钮设置的超时也会被取消。

期约进度追踪:

执行中的期约可能会有不少离散的“阶段”,在最终解决之前必须依次经过。某些情况下,监控期约的执行进度会很有用。ECMAScript 6期约并不支持进度追踪,但是可以通过扩展来实现。

一种实现方式是扩展Promise类,为它添加notify()方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TrackablePromise extends Promise {
  constructor(executor) {
    const notifyHandlers = [];

      super((resolve, reject) => {
      return executor(resolve, reject, (status) => {
        notifyHandlers.map((handler) => handler(status));
      });
    });

    this.notifyHandlers = notifyHandlers;
  }

  notify(notifyHandler) {
    this.notifyHandlers.push(notifyHandler);
    return this;
  }
}

这样,TrackablePromise就可以在执行函数中使用notify()函数了。可以像下面这样使用这个函数来实例化一个期约:

1
2
3
4
5
6
7
8
9
10
11
12
let p = new TrackablePromise((resolve, reject, notify) => {
  function countdown(x) {
    if (x > 0) {
      notify(`${20 * x}% remaining`);
      setTimeout(() => countdown(x - 1), 1000);
    } else {
      resolve();
    }
  }

  countdown(5);
});

这个期约会连续5次递归地设置1000毫秒的超时。每个超时回调都会调用notify()并传入状态值。假设通知处理程序简单地这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...

let p = new TrackablePromise((resolve, reject, notify) => {
  function countdown(x) {
    if (x > 0) {
      notify(`${20 * x}% remaining`);
      setTimeout(() => countdown(x - 1), 1000);
    } else {
      resolve();
    }
  }

  countdown(5);
});

p.notify((x) => console.asyncLog('progress:', x));

p.then(() => console.asyncLog('completed'));

//(约1秒后)80% remaining
//(约2秒后)60% remaining
//(约3秒后)40% remaining
//(约4秒后)20% remaining
//(约5秒后)completed

notify()函数会返回期约,所以可以连缀调用,连续添加处理程序。多个处理程序会针对收到的每条消息分别执行一遍,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...

p.notify((x) => console.asyncLog('a:', x))
 .notify((x) => console.asyncLog('b:', x));

p.then(() => console.asyncLog('completed'));

//(约1秒后)a: 80% remaining
//(约1秒后)b: 80% remaining
//(约2秒后)a: 60% remaining
//(约2秒后)b: 60% remaining
//(约3秒后)a: 40% remaining
//(约3秒后)b: 40% remaining
//(约4秒后)a: 20% remaining
//(约4秒后)b: 20% remaining
//(约5秒后)completed

总体来看,这还是一个比较粗糙的实现,但应该可以演示出如何使用通知报告进度了。

期约不支持取消和进度追踪,一个主要原因就是这样会导致期约连锁和期约合成过度复杂。比如在一个期约连锁中,如果某个被其他期约依赖的期约被取消了或者发出了通知,那么接下来应该发生什么完全说不清楚:如果取消了Promise.all()中的一个期约,或者期约连锁中前面的期约发送了一个通知,那么接下来应该怎么办才比较合理呢?

本文由作者按照 CC BY 4.0 进行授权

番外 this解析

阅读DAY16 JavaScript高级程序设计 11章下 期约与异步函数