最后更新于
2020-11-14
最近在回过头来看之前整理过的 Promise
相关内容,发现有许多不完善或是遗漏的地方,所以打算抽些时间重新的梳理一下 Promise
相关内容,从什么是 Promise
开始,到它的各种使用方式,最后我们再来手动模拟实现一个 Promise
了解一下它的运行过程(整理完发现内容较多,所以另起篇幅,见 Promise 的实现),下面就让我们先从为什么要使用 Promise
开始看起
为什么要使用 Promise
我们在接触一个新东西的时候,都应该先了解一下我们为什么要使用它,关于 Promise
这个东西,官方的说法是,Promise
是一个对象,它代表了一个异步操作的最终完成或者失败,我们先来看一个示例,示例很简单,就是读取当前目录下的 1.txt
这个文件,然后在控制台输出这个文件内容
1 | var fs = require('fs') |
看起来很简单,然后我们现在再进一步,读取两个文件,然后在控制台分别输出这两个文件内容
1 | var fs = require('fs') |
要是读取更多的文件呢?
1 | var fs = require('fs') |
通过上面这个简单的示例我们可以发现,在传统的异步编程中,如果异步之间存在依赖关系,我们就需要通过层层嵌套回调来满足这种依赖,如果嵌套层数过多,可读性和可维护性都变得很差,产生所谓回调地狱,而 Promise
将回调嵌套改为链式调用,增加了可读性和可维护性,下面我们就来看看如何使用 Promise
来改写上面的示例
Promise
那么什么是 Promise
呢?首先它是一个对象,它和 JavaScript
普通的对象没什么区别,同时它也是一种规范,跟异步操作约定了统一的接口,表示一个异步操作的最终结果,以同步的方式来写代码,执行的操作是异步的,但又保证程序执行的顺序是同步的,可以总结为以下这些特点
- 只有三种状态,未完成,完成(
fulfilled
)和失败(rejected
) - 状态可以由未完成转换成完成,或者未完成转换成失败
- 状态转换只发生一次
- 状态转换完成后就是外部『不可变』的值,我们可以安全地把这个值传递给第三方,并确信它不会被有意无意的修改(特别是对于多方查看同一个
Promise
状态转换的情况)
下面我们就来看看如何使用 Promise
来改写我们之前的读取文件的示例
1 | var fs = require('fs') |
我们将其封装成了一个 readFile()
的函数,在内部我们返回了一个新的 Promise
,这样在调用了之后就可以使用 then
方法来接收它的成功和失败的回调,是不是看上去清爽很多,这也是 Promise
最为基本的使用方式了,下面我们就来稍微的深入一些,来看看 Promise
提供的各种方法以及如何进行错误处理的
Promise.resolve()
一般情况下我们都会使用 new Promise()
来创建 Promise
对象,但是除此之外我们也可以使用其他方法,比如静态方法 Promise.resolve(value)
可以认为是 new Promise()
方法的快捷方式,比如 Promise.resolve(42)
可以认为是以下代码的语法糖
1 | new Promise(function (resolve) { |
在这段代码中的 resolve(42)
会让这个 Promise
对象立即进入确定(即 resolved
)状态,并将 42
传递给后面 then
里所指定的 onFulfilled
函数,方法 Promise.resolve(value)
的返回值也是一个 Promise
对象,所以我们可以像下面那样接着对其返回值进行 .then
调用
1 | Promise.resolve(42).then(function (value) { |
而 Promise.resolve
方法另一个作用就是将 Thenable 对象转换为 Promise
对象,而所谓的 Thenable
对象,简单来说就是一个非常类似 Promise
的东西,就像类数组一样,Thenable
指的是一个具有 .then
方法的对象,最简单的例子就是 $.ajax()
,因为它的返回值是 jqXHR Object 对象,这个对象具有 .then
方法,在这种情况下我们就可以使用 Promise.resolve
来将其转换为一个 Promise
对象
1 | // 返回 Promise 对象 |
但是这里有一个需要注意的地方,jqXHR Object 对象虽然继承了来自 Deferred Object 的方法和属性,但是 Deferred Object
并没有遵循 Promises/A+ 或 ES6 Promises 标准,所以即使看上去这个对象转换成了一个 Promise
对象,但是会出现缺失部分信息的问题,这个问题的根源在于 jQuery
的 Deferred Object
的 then
方法机制与 Promise
不同,所以我们应该注意,即使一个对象具有 .then
方法,也不一定就能作为 ES6 Promises
对象使用
针对于以上内容,我们简单的总结一下就是,可以认为 Promise.resolve
方法的作用就是将传递给它的参数填充(Fulfilled
)到 Promise
对象后并返回这个 Promise
对象,此外 Promise
的很多处理内部也是使用了 Promise.resolve
算法将值转换为 Promise
对象后再进行处理的
Promise.reject()
Promise.reject(error)
是和 Promise.resolve(value)
类似的静态方法,是 new Promise()
方法的快捷方式,比如 Promise.reject(new Error('出错了'))
就是下面代码的语法糖形式
1 | new Promise(function (resolve, reject) { |
这段代码的功能是调用该 Promise
对象通过 then
指定的 onRejected
函数,并将错误(Error
)对象传递给这个 onRejected
函数
1 | Promise.reject(new Error('出错了')).catch(function (error) { |
它和 Promise.resolve(value)
的不同之处在于 Promise
内调用的函数是 reject
而不是 resolve
,一般使用较少,不过在编写测试或是问题排查的情况下还是可以用得上的
then()
我们在前面的章节里大致已经了解了 Promise
基本的实例方法 then
和 catch
的使用方式,想必我们也都已经知道了 .then().catch()
这种链式方法的写法了,其实在 Promise
里可以将任意个方法连在一起作为一个执行链
1 | aPromise.then(function taskA(value) { |
如果把在 then
中注册的每个回调函数称为 Task
的话,那么我们就可以通过 Promise
执行链方式来编写能以 Task A ==> Task B
这种流程进行处理的逻辑了,我们先来看看下面这个示例
1 | function taskA() { |
上述代码的执行流程,如果用一张图来描述一下的话,像下面的图那样
可以发现,虽然我们没有为 then
方法指定第二个参数(onRejected
),但是我们会发现 Task A
和 Task B
都有指向 onRejected
的线出来,这些线的意思是在 Task A
或 Task B
的处理中,在下面的情况下就会调用 onRejected
方法
- 发生异常的时候
- 返回了一个
Rejected
状态的Promise
对象
我们在 Promise
中的处理习惯上都会采用 try-catch
的风格,当发生异常的时候,会被 catch
捕获并被由在此函数注册的回调函数进行错误处理,但是大多数情况下我们对于异常处理策略是通过返回一个 Rejected
状态的 Promise
对象来实现的,这种方法不通过使用 throw
就能在 Promise
执行链中对 onRejected
进行调用,但是针对上面的示例还有一点需要注意的,那就是由于在 onRejected
和 Final Task
后面没有 catch
处理了,因此在这两个 Task
中如果出现异常的话将不会被捕获
下面我们再来看一个具体的关于 Task A ==> onRejected
的例子
1 | function taskA() { |
运行流程如下所示
运行以后可以发现,Task B
是不会被调用的,在本例中我们在 Task A
中使用了 throw
方法故意制造了一个异常,但在实际中想主动进行 onRejected
调用的时候,应该返回一个 Rejected
状态的 Promise
对象,关于这种两种方式的异同,我们会在下面进行介绍
我们仔细观察之前的示例可以发现,其实我们中间的 Task
都是相互独立的,只是被简单调用而已,但是这时候如果 Task A
想给 Task B
传递一个参数我们该怎么办呢?答案非常简单,那就是在 Task A
中 return
一个返回值,这样就会在 Task B
执行的时候传递给它
1 | function doubleUp(value) { |
运行流程如下所示
每个方法中 return
的值不仅只局限于字符串或者数值类型,也可以是对象或者 Promise
对象等复杂类型,return
的值会由 Promise.resolve()
进行相应的包装处理,因此不管回调函数中会返回一个什么样的值,最终 then
的结果都是返回一个新创建的 Promise
对象,也就是说 then
不仅仅是注册一个回调函数那么简单,它还会将回调函数的返回值进行变换,创建并返回一个新的 Promise
对象
从代码来看的话,可能会以为 .then().catch()
的方式像是针对最初的 Promise
对象进行了一连串的执行链调用,然而实际上不管是 then
还是 catch
都会返回了一个新的 Promise
对象,下面我们就来看看如何确认这两个方法返回的到底是不是新的 Promise
对象
1 | var aPromise = new Promise(function (resolve) { |
===
是严格相等比较运算符,我们可以看出这三个对象都是互不相同的,这也就证明了 then
和 catch
都返回了和调用者不同的 Promise
对象,也就是如下图所示
如果我们知道了 then
方法每次都会创建并返回一个新的 Promise
对象的话,那么我们就应该不难理解下面代码中对 then
的使用方式上的差别了
1 | // 第一种情况,对同一个 Promise 对象同时调用 `then` 方法 |
通过对比,我们可以发现两种方式的结果是不一样的
- 第一种方式当中并没有使用
Promise
的执行链形式,这在Promise
中是应该极力避免的写法,这种写法中的then
调用几乎是在同时开始执行的,而且传给每个then
方法的value
值都是 100 - 第二种写法则采用了执行链的方式将多个
then
方法调用串连在了一起,各函数也会严格按照resolve => then => then => then
的顺序执行,并且传给每个then
方法的value
的值都是前一个Promise
对象通过return
返回的值
但是我们需要注意下面这种使用方式
1 | function badAsyncCall() { |
这种写法有很多问题,首先在 .then()
中产生的异常不会被外部捕获,此外也不能得到 then
的返回值(即使它有返回值),这是因为每次 .then()
的调用都会返回一个新创建的 Promise
对象,因此我们需要像上述方式二那样,采用链式调用的方式,修改后的代码如下所示
1 | function anAsyncCall() { |
有了上面的知识点铺垫以后,我们就可以来解决我们在实际使用场景当中所遇到的问题,比如在接口返回的数据量非常大的时候,并且如果集中在其中某一个接口来处理的话过于庞大,这时我们可以考虑在多个 then
方法中依次访问处理逻辑并执行
1 | // 后端返回的数据 |
catch()
我们在上面的 then()
章节当中已经简单地使用了 catch()
方法,实际上 catch()
只是 promise.then(undefined, onRejected)
方法的一个别名而已,也就是说这个方法用来注册当 Promise
对象状态变为 Rejected
时的回调函数,但是也有一些我们需要注意的地方,如下
1 | var promise = Promise.reject(new Error(`message`)) |
如上代码在 IE8
及以下版本则会出现 identifier not found
的语法错误,这是因为 IE8
及以下版本都是基于 ECMAScript 3
来实现的,因此不能将 catch
作为属性来使用,也就不能编写类似 promise.catch()
的代码,在这种情况下我们就可以采用 中括号标记法
1 | var promise = Promise.reject(new Error(`message`)) |
或者我们不单纯的使用 catch
,而是使用 then
也是可以避免这个问题的
1 | var promise = Promise.reject(new Error(`message`)) |
由于 catch
标识符可能会导致问题出现,因此一些类库也采用了 caught
作为函数名,而函数要完成的工作是一样的,而且很多压缩工具自带了将 promise.catch
转换为 promise['catch']
的功能,所以可能不经意之间也能帮我们解决这个问题
then() 和 catch()
在之前的章节当中,我们提到过 .catch
也可以理解为 promise.then(undefined, onRejected)
,所以在本节当中我们就来具体的看一看 .then
和 catch
有什么异同,先来看下面这个示例
1 | function throwError(value) { |
在上面的代码中,badMain
是一个不太好的实现方式(但也不是说它有多坏),goodMain
则是一个能非常好的进行错误处理的版本,为什么说 badMain
不好呢?因为虽然我们在 .then
的第二个参数中指定了用来错误处理的函数,但实际上它却不能捕获第一个参数 onFulfilled
指定的函数(本例为 throwError
)里面出现的错误,也就是说这时候即使 throwError
抛出了异常,onRejected
指定的函数也不会被调用
与此相对的是,goodMain
的代码则遵循了 throwError ==> onRejected
的调用流程,这时候 throwError
中出现异常的话,在会被执行链中的下一个方法,即 .catch
所捕获,进行相应的错误处理,.then
方法中的 onRejected
参数所指定的回调函数,实际上针对的是其 Promise
对象或者之前的 Promise
对象,而不是针对 .then
方法里面指定的第一个参数,即 onFulfilled
所指向的对象,这也是 then
和 catch
表现不同的原因
这种情况下 then
是针对 Promise.resolve(42)
的处理,在 onFulfilled
中发生异常,在同一个 then
方法中指定的 onRejected
也不能捕获该异常,在这个 then
中发生的异常,只有在该执行链后面出现的 catch
方法才能捕获,当然,由于 .catch
方法是 .then
的别名,我们使用 .then
也能完成同样的工作,只不过使用 .catch
的话意图更明确,更容易理解
1 | Promise.resolve(42).then(throwError).then(null, onRejected) |
Promise
的构造函数,以及被 then
调用执行的函数基本上都可以认为是在 try-catch
代码块中执行的,所以在这些代码中即使使用 throw
,程序本身也不会因为异常而终止,但是如果在 Promise
中使用 throw
语句的话,会被 try-catch
住,最终 Promise
对象也变为 Rejected
状态
1 | var promise = new Promise(function (resolve, reject) { |
以上代码虽然可以正常运行,但是如果想把 Promise
对象状态设置为 Rejected
状态的话,使用 reject
方法则更显得合理,所以上面的代码可以改写为下面这样
1 | var promise = new Promise(function (resolve, reject) { |
其实我们也可以这么来考虑,在出错的时候我们并没有调用 throw
方法,而是使用了 reject
,那么给 reject
方法传递一个 Error
类型的对象也就很好理解了,在 Promise
构造函数中,有一个用来指定 reject
方法的参数,建议使用这个参数而不是依靠 throw
将 Promise
对象的状态设置为 Rejected
状态
那么如果像下面那样想在 then
中进行 reject
的话该怎么办呢?
1 | var promise = Promise.resolve() |
上面的超时处理,需要在 then
中进行 reject
方法调用,但是传递给当前的回调函数的参数只有前面的 Promise
对象,在这种情况下该怎么办呢?
在这里我们再次回忆下 then
的工作原理,在 then
中注册的回调函数可以通过 return
返回一个值,这个返回值会传给后面的 then
或 catch
中的回调函数,而且 return
的返回值类型不光是简单的字面值,还可以是复杂的对象类型,比如 Promise
对象等
这时候,如果返回的是 Promise
对象的话,那么根据这个 Promise
对象的状态,在下一个 then
中注册的回调函数中的 onFulfilled
和 onRejected
的哪一个会被调用也是能确定的
1 | var promise = Promise.resolve() |
比如上面这个示例,后面的 then
调用哪个回调函数是由 Promise
对象的状态来决定的,也就是说这个 retPromise
对象状态为 Rejected
的时候,会调用后面 then
中的 onRejected
方法,这样就实现了即使在 then
中不使用 throw
也能进行 reject
处理了
1 | var onRejected = console.error.bind(console) |
使用 Promise.reject
的话还能再将代码进行简化
1 | var onRejected = console.error.bind(console) |
Promise.all()
Promise.all
实际上是一个 Promise
,接收一个 Promise
数组(或一个可迭代的对象)做为参数,然后当其中所有的 Promise
都变为 resolved
状态,或其中一个变为 rejected
状态,便会执行回调函数,来看下面代码
1 | Promise.all([promise1, promise2, promise3]) |
你可以看到,我们将一个数组传递给了 Promise.all
,并且当三个 Promise
都转为 resolved
状态时,Promise.all
完成并在控制台输出,再来看看下面这个示例,经过给定时间会执行 resolve
1 | const timeOut = (t) => { |
在上面的示例中,Promise.all
在 2000ms
之后 resolved
,并且在控制台上输出结果数组,但是我们可以发现,输出的 Promise
的顺序是固定的,也就是说每个 Promise
的结果(resolve
或 reject
时传递的参数值)和传递给 Promise.all
的 Promise
数组的顺序是一致的
以上就是 Promise.all
的基本用法,下面我们来看一些在实际项目中的应用,比如同步多个异步请求,在实际的项目中,页面通常需要将多个异步请求发送到后台,然后等到后台结果返回后,再开始渲染页面,有时候我们可能会这样进行处理
1 | function getAList() { |
上面的代码确实有效,但是有两个缺陷
- 每次我们从服务端请求数据时,我们都需要编写一个单独的函数来处理数据,这将导致代码冗余,并且不便于将来的升级和扩展
- 每个请求花费的时间不同,导致函数会异步渲染三次页面,会使用户感觉页面卡顿
现在我们可以使用 Promise.all
来优化我们的代码
1 | function getAList() { |
这样代码看上去就清爽了不少,在所有请求完成后,我们在统一处理数据,但是如果有异常的话,该如何处理呢?在上面的示例中,我们可以按照下面的方式来进行异常处理
1 | Promise.all([p1, p2]).then(res => { |
众所周知,Promise.all
的机制是,只要做为参数的 Promise
数组中的任何一个 Promise
抛出异常时,无论其他 Promise
成功或失败,整个 Promise.all
函数都会进入 catch
方法,但实际上,我们经常希望即使一个或多个 Promise
抛出异常,我们仍希望 Promise.all
继续正常执行,例如在上面的例子中,即使在 getAList()
中发生异常,只要在 getBList()
或 getCList()
中没有发生异常,我们仍然希望该程序继续执行,为了满足这个需求,我们可以使用一个技巧来增强 Promise.all
的功能
1 | Promise.all([p1.catch(error => error), p2.catch(error => error)]).then(res => { |
这样一来,即使一个 Promise
发生异常,也不会中断 Promise.all
中其它 Promise
的执行,应用到前面的示例,结果是这样的
1 | function getAList() { |
Promise.race()
Promise.race
的参数与 Promise.all
相同,可以是一个 Promise
数组或一个可迭代的对象,Promise.race()
方法返回一个 Promise
对象,一旦迭代器中的某个 Promise
为 fulfilled
或 rejected
状态,就会返回结果或者错误信息,我们来看下面这个定时功能的示例
当我们从后端服务器异步请求资源时,通常会限制时间,如果在指定时间内未接收到任何数据,则将引发异常,所幸 Promise.race
可以帮我们解决这个问题
1 | function requestImg() { |
Promises.finally()
在上面我们介绍了 Promise.all()
和 Promise.race()
,下面我们再来考虑另外一种情况,那就是如何让一个函数无论 Promise
对象成功和失败都能被调用呢?
在这种情况下,就要用到 Promises.finally()
这个方法了,Promises.finally()
方法返回一个 Promise
,在 Promise
执行结束时,无论结果是 fulfilled
或者是 rejected
,在执行 then()
和 catch()
后,都会执行 finally
指定的回调函数,这为指定执行完 Promise
后,无论结果是 fulfilled
还是 rejected
都需要执行的代码提供了一种方式,避免同样的语句需要在 then()
和 catch()
中各写一次的情况
1 | Promise.resolve('success').then(result => { |
经典示例
在上面我们介绍了 Promise
的基本概念和一些 API
的用法,下面我们就来通过一些实际案例加深一下对于 Promise
的理解
红绿灯问题
一个经典的题目,黄灯一秒亮一次,绿灯两秒亮一次,红灯三秒亮一次,如何让三个灯不断交替重复亮灯?三个亮灯函数已经存在
1 | function red() { |
这种情况,我们可以考虑使用 Promise
来实现
1 | var light = function (time, cb) { |
Promise 和 setTimeout
最后我们再来看看平常可能会经常遇到的一类问题,那就是 Promise
和 setTimeout
的执行先后顺序的问题,先来看下面这个示例
1 | setTimeout(function () { |
执行结果依次为 2
和 1
,至于为什么会这样,简单来说就是 Promise
的任务会在当前事件循环末尾中执行,而 setTimeout
中的任务是在下一次事件循环执行,所以 Promise
的执行顺序是高于 setTimeout
的,至于原因,则是因为在 ES6
当中,有一个新的概念建立在『事件循环队列』之上,叫做『任务队列』
简单的理解就是,它是挂在事件循环队列的每个 Tick
之后的一个队列,在事件循环的每个 Tick
中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 Tick
的任务队列末尾添加一个项目(任务)
一个任务可能引起更多任务被添加到同一个队列末尾,所以理论上说,任务循环可能无限循环(一个任务总是添加另一个任务,以此类推)进而导致程序的无限循环,无法转移到下一个事件循环 Tick
,从概念上看,这和代码中的无限循环(类似 while(true)
)的体验几乎是一样的
有了以上的了解以后,我们再来看看下面这个稍微复杂点的的综合案例
1 | // 一 |
- 最基本的,输出
0 ~ 4
setTimeout
会延迟执行,那么执行到console.log
的时候,其实i
已经变成5
了,所以结果为5
个5
(每一秒输出一个5
)- 三当中使用了闭包,而四当中将
var
变成了let
,结果同样是0
到4
- 去掉
function()
中的i
,内部就没有对i
保持引用,结果还是5
个5
- 如果修改成六这样,立即执行函数会立即执行,所以会立即输出
0 ~ 4
而不会延迟 - 最后两个有些复杂,但是原理是类似的,我们来简单剖析一下
都被改写成了 Promise
,但是首先需要明确的是,Promise
的任务会在当前事件循环末尾中执行,而 setTimeout
中的任务是在下一次事件循环执行,首先是一个 setTimeout
,所以其中的任务是会在下一次事件循环中才会执行,因此开始肯定不会输出 1
,然后是一个 Promise
,里面的函数是会立即执行的,所以首先输出 2
和 3
这里需要注意的是,Promise
的 then
应当会放到当前 Tick
的最后,但是还是在当前 Tick
中(而不是下一次事件循环),所以会先输出 5
然后才会输出 4
,最后轮到下一个 Tick
才会输出 1
,所以结果为 2 3 5 4 1
,至于最后一个,和第七个的原理是一样的,结果为 2 3 5 4 1 6
关于
Promise
和setTimeout
两者间具体的差异可以参考之前整理过的 JavaScript 并发模型 来了解更多