深入理解Promise
次访问
从异步编程说起
我们都知道 JavaScript 的代码执行的时候是跑在单线程上的,可以理解为只能按照代码的出现顺序,从上到下一行一行的执行,但是遇到了异步的行为,比如定时器(一定时间之后才去执行),那就需要等同步代码执行完成后的一段时间里再去执行异步代码。
对于同步行为,如下面的代码,我们能够很清楚的知道每一行会发生什么,这是因为后面的指令总是等到前面的指令执行完成后才去执行,所以这里的第二行里的变量 x 在内存里已经是定义过的。
1 | let x = 10; |
但是对于异步代码,我们就不好推断到底什么时候会执行完成了。比如举一个实际的例子,我们去动态加载某个脚本,会这样做:
1 | function loadScript(src) { |
这个脚本加载完成的时候会去执行定义在脚本里的一些函数,比如初始化函数 init,那么我们可以会这样写:
1 | function loadScript(src) { |
但是实际执行后却发现,这样根本不行,因为加载脚本是需要花时间的,是一个异步的行为,浏览器执行 JavaScript 的时候并不会等到脚本加载完成的时候再去调用 init 函数。
以往,对于这种异步编程的做法通常就是通过给函数传递一个回调函数来处理,上面那个例子可以这样做:
1 | function loadScript(src, success, fail) { |
上面这样做能够保证在脚本加载完成的时候,再去执行脚本里的函数。但是多考虑一个问题,如果 success 里又需要加载别的 js 文件呢,那岂不是需要多层嵌套了。是的,这样的多层嵌套会使得代码层次变得更加深入,难以阅读以及后期维护成本非常高,尤其是当里面加上了很多的判断逻辑的时候情况会更加糟糕,这就是所谓的 “回调地狱”,且又因为它的代码形状很像躺着的金字塔,所以有的人也喜欢叫它 “噩运金字塔”。
而为了避免这类 “回调地狱” 问题,目前最好的做法之一就是使用 Promise。
Promise正篇
使用 Promise 可以很好的解决上面提到的 “回调地狱” 问题,直接来看结果:
1 | function loadScript(src) { |
这里通过使用 Promise 实例的 then 和 catch 函数将多层嵌套的代码改成了同步处理流程,看起来效果还是不错的,那什么是 Promise 呢?
Promise 首先是一个对象,它通常用于描述现在开始执行,一段时间后才能获得结果的行为(异步行为),内部保存了该异步行为的结果。然后,它还是一个有状态的对象:
pending:待定fulfilled:兑现,有时候也叫解决(resolved)rejected:拒绝
一个 Promise 只有这 3 种状态,且状态的转换过程有且仅有 2 种:
pending到fulfilledpending到rejected
可以通过如下的 Promise 对象构造器来创建一个 Promise:
1 | let promise = new Promise((resolve, reject) => {}) |
传递给 new Promise 的是 executor 执行器。当 Promise 被创建的时候,executor 会立即同步执行。executor 函数里通常做了 2 件事情:初始化一个异步行为和控制状态的最终转换。
1 | new Promise((resolve, reject) => { |
如上代码所示,setTimeout 函数用来描述一个异步行为,而 resolve 用来改变状态。executor 函数包含 2 个参数,他们都是回调函数,用于控制 Promise 的状态转换:
resolve:用来将状态pending转换成fulfilledreject:用来将状态pending转换成rejected
一个 Promise 的状态一旦被转换过,则无法再变更:
1 | let p = new Promise((resolve, reject) => { |
可以看到执行了 2 次 resolve 函数和 1 次 reject 函数,但是 promise 的最终结果是取的第一次 resolve 的结果,印证了上面的结论。
由 new Promise 构造器返回的 Promise 对象具有如下内部属性:
PromiseState:最初是pending,resolve被调用的时候变为fulfilled,或者reject被调用时会变为rejected;PromiseResult:最初是undefined,resolve(value)被调用时变为value,或者在reject(error)被调用时变为error。
比如上面例子中打印出来的 Promise 对象结果中,fulfilled 是其内部的 PromiseState,而 “第一次 resolve” 是其 PromiseResult。
1 | // Promise {<fulfilled>: "第一次 resolve"} |
Promise实例方法
Promise.prototype.then()
Promise.prototype.then() 将用于为 Promise 实例添加处理程序的函数。它接受 2 个可选的参数:
onResolved:状态由pending转换成fulfilled时执行;onRejected:状态由pending转换成rejected时执行。
它可以写成这样:
1 | function onResolved(res) { |
或者写成更简单的方式:
1 | new Promise((resolve, reject) => { |
因为状态的变化只有 2 种,所以 onResolved 和 onRejected 在执行的时候必定是互斥。
上面介绍到了 then() 的参数是可选的,当只有 onResolved 的时候可以这样写:
1 | new Promise((resolve, reject) => { |
当参数只有 onRejected 的时候,需要把第一个参数设置为 null:
1 | new Promise((resolve, reject) => { |
如果给 then() 函数传递来了非函数参数,则会默认忽略。
Promise.prototype.catch()
Promise.prototype.catch() 用于给 Promise 对象添加拒绝处理程序。只接受一个参数:onRejected 函数。实际上,下面这两种写法是等效的:
1 | function onRejected(err){} |
Promise.prototype.finally()
Promise.prototype.finally() 用于给 Promise 对象添加 onFinally 函数,这个函数主要是做一些清理的工作,只有状态变化的时候才会执行该 onFinally 函数。
1 | function onFinally() { |
因为 onFinally 函数是没有任何参数的,所以在其内部其实并不知道该 Promise 的状态是怎么样的。
链式调用
链式调用里涉及到的知识点很多,我们不妨先看看下面这道题,你能正确输出其打印顺序嘛?
1 | new Promise((resolve, reject) => { |
这里我不给出答案,希望你能动手敲一敲代码,然后思考下为什么?容我讲完这部分知识,相信你能自己理解其中缘由。
从上面这串代码里,我们看到 new Promise 后面接了很多的 .then() 处理程序,这个其实就是 Promise 的链式调用,那它为什么能链式调用呢?
基于onResolved生成一个新的Promise
因为 Promise.prototype.then() 会返回一个新的 Promise,来看下:
1 | let p1 = new Promise((resolve, reject) => { |
可以看到 p1 和 p2 的内部 PromiseResult 是不一样的,说明 p2 是一个新的 Promise 实例。
新产生的 Promise 会基于 onResolved 的返回值进行构建,构建的时候其实是把返回值传递给 Promise.resolve() 生成的新实例,比如上面那串代码里 p1.then(() => 6) 这里的 onResolved 函数返回了一个 6 ,所以新的 Promise 的内部值会是 6。
如果 .then() 没有提供 onResolved 这个处理程序,则 Promise.resolve() 会基于上一个实例 resolve 后的值来初始化一个新的实例:
1 | let p1 = new Promise((resolve, reject) => { |
如果 onResolved 处理程序没有返回值,那么返回的新实例的内部值会是 undefined:
1 | let p1 = new Promise((resolve, reject) => { |
如果在 onResolved 处理程序里抛出异常,则会返回一个新的 rejected 状态的 Promise:
1 | let p1 = new Promise((resolve, reject) => { |
基于onRejected生成一个新的Promise
基于 onRejected 的返回值也会返回一个新的 Promise,而且处理逻辑也是一样的,也是通过把返回值传递给 Promise.resolve() 产生一个新的实例:
1 | let p1 = new Promise((resolve, reject) => { |
这里你会不会有个疑惑?实例 resolve() 的时候,状态由 pending 变成 rejected,从而调用 onRejected 进行处理,但是为什么有时候会返回一个 fulfilled 的新实例呢?试着想一下,如果 onRejected 返回了一个 pending 的或者 rejected 状态的新实例,那后续的链式调用就进行不下去了,看下面例子:
1 | new Promise((resolve, reject) => { |
如果 A 处理函数这里返回了一个 pending 状态的新实例,那么后续所有的链式操作都无法执行;或者返回的是一个 rejected 状态的新实例,那么后续的 B 和 C 也就无法执行了,那居然都不能执行 B 和 C 所在处理程序,那定义来干嘛呢?链式操作就毫无链式可言。又,onRejected 的存在的根本意义无非就是用于捕获 Promise 产生的错误,从而不影响程序的正常执行,所以默认情况下理应返回一个 fulfilled 的新实例。
Promise.prototype.catch() 也会生成一个新的 Promise,其生成规则和 onRejected 是一样的。
finally生成一个新的Promise
没想到吧,Promise.prototype.finally() 也能生成一个 Promise。finally 里的操作是和状态无关的,一般用来做后续代码的处理工作,所以 finally 一般会原样后传父 Promise,无论父级实例是什么状态。
1 | let p1 = new Promise(() => {}) |
上面说的是一般,但是也有特殊情况,比如 finally 里返回了一个非 fulfilled 的 Promise 或者抛出了异常的时候,则会返回对应状态的新实例:
1 | let p1 = new Promise((resolve, reject) => { |
执行顺序
先来看一段简单的代码:
1 | new Promise((resolve, reject) => { |
上面这串代码的输出顺序是:A B D C。从上面章节介绍的知识点我们知道,executor 执行器会在 new Promise 调用的时候立即同步执行的,所以先后打印 A B 是没问题的。当执行 resolve()/reject() 的时候,会将 Promise 对应的处理程序推入微任务队列,稍等这里提到的对应的处理程序具体是指什么?
resolve()对应.then()里的第一个入参,即onResolved函数;reject()对应.then()里的第二个入参,即onRejected函数;或者Promise.prototype.catch()里的回调函数;
所以当执行 resolve(3) 的时候(此时下面定义的这个箭头函数其实就是 onResolved 函数),onResolved 函数将被推入微任务队列,然后打印 D,此时所有同步任务执行完成,浏览器会去检查微任务队列,发现存在一个,所以最后会去调用 onResolved 函数,打印出 C。
1 | let onResolved = res => { |
其实除了 onResolved、onRejected 以及 Promise.prototype.catch() 里的处理程序外,Promise.prototype.finally() 的处理程序 onFinally 也是异步执行的:
1 | new Promise((resolve, reject) => { |
Promise 链式调用的基础就是因为 onResolved、onRejected、catch() 的处理程序以及 onFinally 会产生一个新的 Promise 实例,且又因为他们都是异步执行的,所以在链式调用的时候,对于它们执行顺序会稀里糊涂琢磨不透就是这个原因。
题目一
那下面我们就来看点复杂的例子,先来分析下这章开篇提到的题目:
1 | new Promise((resolve, reject) => { |
为了方便分析,我们把上面的这串代码写得好看一点:
1 | new Promise(executor).then(onResolvedA).then(onResolvedD) |
执行过程:
- 执行
new Promise(),立即同步执行executor函数,调用resolve(),此时会将onResolvedA推入微任务队列 1,截止目前所有同步代码执行完成; - 检查微任务队列,执行
onResolvedA函数,打印 A,执行new Promise(executor),调用resolve()函数,此时将onResolvedB推入微任务队列 2; - 截止目前微任务队列 1 的代码全部执行完成,即
onResolvedA函数执行完成。我们知道onResolved函数会基于返回值生成一个新的Promise,而onResolvedA函数没有显示的返回值,所以其返回值为undefined,那么经过Promise.resolve(undefined)初始化后会生成一个这样的新实例:Promise {<fulfilled>: undefined};由于这个新的实例状态已经变成fulfilled,所以会立即将其处理函数onResolvedD推入微任务队列 3; - 开始执行微任务队列 2 里的内容,打印 B,同上一条原理,由于
onResolvedB函数的返回值为undefined,所以生成了一个resolved的新实例,则会立即将onResolvedC推入微任务队列 4; - 执行微任务队列 3,打印 D;
- 执行微任务队列 4,打印 C;
- 至此全部代码执行完成,最终的打印结果为:A B D C。
题目二
1 | new Promise((resolve, reject) => { |
应该很多人会和我当初一样好奇:为什么打印结果不是 A B C D 呢?
这里涉及到一个知识点:如果给 Promise 实例添加了多个处理函数,当实例状态变化的时候,那么执行的过程就是按照添加时的顺序而执行的。
1 | new Promise((resolve, reject) => { |
对于上面这串代码,其实 finally() 处理程序执行的时候已经不是通过 new Promise() 初始化的实例,而是执行完 onResolvedA 函数的时候生成的新实例,不信我们将上面代码中的函数 onResolvedA 稍微改动下:
1 | new Promise((resolve, reject) => { |
由于 onResolvedA 返回了一个这样的 Promise {<pending>} 新实例,这个新实例的状态没有发生变化,所以不会执行 finally 处理程序 onFinally,所以不会打印 B。这个就说明了,链式调用的时候处理程序的执行是一步一步来的,只要前面的执行完了,生成了新的实例,然后根据新实例的状态变化,才去执行后续的处理程序。
所以拿最开始那道题来说:
1 | new Promise((resolve, reject) => { |
他的执行过程应该是这样的:
- 执行
resolve(1),将处理程序 A 推入微任务队列 1; - 执行
resolve(2),将处理程序 C 推入微任务队列 2; - 同步任务执行完成,执行微任务队列 1 里的内容,打印 A,A 所在函数执行完成后生成了一个
fulfilled的新实例,由于新实例状态变化,所以会立即执行finally()处理程序 B 推入微任务队列 3; - 执行微任务队列 2 的内容,打印 C,C 所在函数执行完成后,同上条原理会将处理程序 D 推入微任务队列 4;
- 执行微任务队列 3 的内容,打印 B;
- 执行微任务队列 4 的内容,打印 D;
- 代码全部执行完成,最终打印:A C B D。
题目就先做到这里,相信你和我一样,对 Promise 的执行过程应该有更深入的理解了。接下来我们将继续学习 Promise 的相关 API。
Promise与错误处理
平时我们写代码遇到错误,都习惯用 try/catch 块来处理,但是对于 Promise 产生的错误,用这个是处理不了的,看下面这段代码:
1 | try { |
从执行结果我们可以看到,报错的信息出现在打印 C 之后,说明抛出错误这个动作是在异步任务中做的,所以 catch 捕获不到该错误就在情理之中了,否则就不会打印 C 了。可见,传统的 try/catch 语句并不能捕获 Promise 产生的错误,而需要使用 onRejected 处理程序:
1 | let p1 = new Promise((resolve, reject) => { |
onRejected 捕获了上面抛出的错误后,使得程序正常执行,最后还生成了一个 fulfilled 的新实例。
除了以上这种直接在 executor 里通过 throw 主动抛出一个错误外,还可以通过以下方式产出需要 onRejected 处理的错误:
1 | new Promise((resolve, reject) => { |
注意,如果只是产生了一个错误,却没有抛出来是不会报错的:
1 | // 不会报错 |
Promise 出现了错误就需要使用 onRejected 处理程序处理,否则程序就会报错,执行不下去了。
Promise API
Promise.resolve()
并非所有的 Promise 的初始状态都是 pending,可以通过 Promise.resolve(value) 来初始化一个状态为 fulfilled,值为 value 的 Promise 实例:
1 | let p = Promise.resolve(3) |
这个操作和下面这种创建一个 fulfilled 的 Promise 在效果上是一样的:
1 | let p = new Promise(resolve => resolve(3)) |
使用这个静态方法,理论上可以把任何一个值转换成 Promise:
1 | setTimeout(console.log, 0, Promise.resolve()) // Promise {<fulfilled>: undefined} |
这个被转换的值甚至可以是一个 Promise 对象,如果是这样,Promise.resolve 会将其原样输出:
1 | let p = Promise.resolve(3) |
Promise.reject()
和 Promise.resolve() 类似,Promise.reject() 会实例化一个 rejected 状态的 Promise,且会抛出一个错误,该错误只能通过拒绝处理程序捕获。
1 | Promise |
对于初始化一个 rejected 状态的实例,以下两种写法都可以达到这个目的:
1 | let p1 = Promise.reject() |
与 Promise.resolve() 不同的是,如果给 Promise.reject() 传递一个 Promise 对象,则这个对象会成为新 Promise 的值:
1 | let p = Promise.reject(3) |
Promise.all()
Promise.all(iterable) 用来将多个 Promise 实例合成一个新实例。参数必须是一个可迭代对象,通常是数组。
1 | Promise.all([ |
可迭代对象里的所有元素都会通过 Promise.resolve() 转成 Promise:
1 | Promise.all([3, 6, 9]) |
所有 Promise 都 resolve 后,Promise.all() 才会生成一个 fulfilled 的新实例。且新实例的内部值是由所有 Promise 解决后的值组成的数组:
1 | let p1 = Promise.all([ |
所有 Promise 中,只要出现一个 pending 状态的实例,那么合成的新实例也是 pending 状态的:
1 | let p1 = Promise.all([ |
所有 Promise 中,只要出现一个 rejected 状态的实例,那么合成的新实例也是 rejected 状态的,且新实例的内部值是第一个拒绝 Promise 的内部值:
1 | let p1 = Promise.all([ |
Promise.race()
Promise.race(iterable) 会返回一个由所有可迭代实例中第一个 fulfilled 或 rejected 的实例包装后的新实例。
1 | let p1 = Promise.race([ |
来将上面这串代码变动下:
1 | function init(){ |
想要知道 Promise.race() 的结果,无非是要知道到底谁才是第一个状态变化的实例,让我们来具体分析下代码执行过程:
- 迭代第一个元素,执行同步代码
resolve(9),由new Promise初始化的实例的状态已经变为了fulfilled,所以第一个状态变化的实例已经出现了吗?其实并没有,因为迭代第一个元素的代码还没执行完成呢,然后会将return 'A'所在函数的这段处理程序推入微任务队列 1; - 迭代第二个元素,执行
reject(6),所以由new Promise初始化的实例的状态已经变为rejected,由于该实例没有处理函数,所以迭代第二个元素的代码已经全部执行完成,此时,第一个状态变化的实例已经产生; - 迭代第三个元素,是一个函数,执行同步代码打印出 3,然后用
Promise.resolve将函数返回值 3 转成一个Promise {<fulfilled>: 3}的新实例,这是第二个状态发生变化的实例; - 此时所有迭代对象遍历完成,即同步代码执行完成,开始执行微任务队列 1 的内容,打印
res,其值是 9,然后处理程序返回了 ‘A’,此时根据之前提到的知识点,这里会新生成一个Promise {<fulfilled>: 'A'}的实例,这是第三个状态发生变化的实例。此时,第一个迭代元素的代码已经全部执行完成,所以第一个迭代元素最终生成的实例是第三次状态发生变化的这个; - 此时
p1已经产生,它是Promise {<rejected>: 6},所以会将它的处理程序console.log(err)所在函数推入微任务队列 2; - 执行微任务队列 2 的内容,打印
err,其值是 6; - 所有微任务执行完成,开始执行
setTimeout里的宏任务,打印p1,至此全部代码执行完成。
Promise.allSettled()
Promise.allSettled(iterable) 当所有的实例都已经 settled,即状态变化过了,那么将返回一个新实例,该新实例的内部值是由所有实例的值和状态组合成的数组,数组的每项是由每个实例的状态和内部值组成的对象。
1 | function init(){ |
只要所有实例中包含一个 pending 状态的实例,那么 Promise.allSettled() 的结果为返回一个这样 Promise {<pending>} 的实例。
Promise.allSettled() 是 ES2020 中新增的方法,所以有一些浏览器可能还暂时不支持。
对于不支持的浏览器,可以写 polyfill:
1 | if(!Promise.allSettled) { |