深入理解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
到fulfilled
pending
到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
转换成fulfilled
reject
:用来将状态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) { |