期约

更新时间: 2021-06-09 10:52:06

# 期约基础

ECMAScript6 新增的引用类型Promise,可以通过new操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数,下面例子使用了一个空函数对象来应付一下解释器:

let p = new Promise(() => {})
setTimeout(console.log,0,p) // Promise<pending>
1
2

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

# 1.期约状态机

在把一个期约实例传给console.log()时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定(pending)状态。如前所述,期约是一个有状态的对象,可能处于如下3种状态之一:

  • 待定(pending)
  • 兑现(fulfilled,有时候也称为“解决”,resolved)
  • 拒绝(rejected)

待定(pending)是期约的最初始状态。在待定状态下,期约可以落定为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落为哪种状态都是不可逆的。只要从待定转换为兑现或者拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该有恰当的行为。

重要的是,期约的状态是私有的,不能直接通过JavaScript检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部JavaScript代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。

# 2.通过执行函数控制期约状态

由于期约状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为resolve()reject()。调用resolve()会把状态切换成兑现,调用reject()会把状态切换为拒绝。另外,调用reject()也会抛出错误。

let p1 = new Promise((resolve,reject) => resolve())
setTimeout(console.log,0,p1) //Promise<resolved>

let p1 = new Promise((resolve,reject) => reject())
setTimeout(console.log,0,p1) //Promise<rejected>
//Uncaught error(in promise)
1
2
3
4
5
6

在前面的例子中,并没有什么异步操作,因为在初始化期约时,执行器函数已经改变了每个期约的状态。这里的关键在于,执行器函数是同步执行的。这是因为执行器函数是期约的初始化程序。通过下面的例子可以看出上面代码的执行顺序:

new Promise(() => setTimeout(console.log,0,'executor'))
setTimeout(console.log,0,'promise initialized')

// executor
// promise initialized
1
2
3
4
5

添加setTimeout可以推迟切换状态:

let p = new Promise((resolve,reject) => setTimeout(resolve,1000))
//在console.log打印期约实例的时候,还不会执行超时回调(即resolve())
setTimeout(console.log,0,p) //Promise<pending>
1
2
3

无论resolve()reject()中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败:

let p = new Promise((resolve,reject) => {
  resolve()
  reject() //没有效果
})
setTimeout(console.log,0,p) // Promise<resolved>
1
2
3
4
5

为避免期约卡在待定状态,可以添加一个定时退出功能。比如,可以通过setTimeout设置一个10秒钟后无论如何都会拒绝期约的回调:

let p = new Promise((resolve,reject) => {
  setTimeout(reject,10000) //10秒后调用reject()
  //执行函数的逻辑
})

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

//(After 10 seconds) Uncaught error
//(After 11 seconds) Promise<rejected>
1
2
3
4
5
6
7
8
9
10

因为期约状态只能改变一次,所以这里的超时拒绝逻辑中可以放心地设置让期约处于待定状态的最长时间。如果执行器中的代码在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失效。

# 3.Promise.resolve()

期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。下面两个期约实例实际上是一样的:

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

这个解决期约的值对应着传给Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:

setTimeout(console.log,0,Promise.resolve())
//Promise<resolved>:undefined

setTimeout(console.log,0,Promise.resolve(3))
//Promise<resolved>:3

//多余的参数会忽略
setTimeout(console.log,0,Promise.resolve(4,5,6))
//Promise<resolved>:4
1
2
3
4
5
6
7
8
9

对这个静态方法而言,如果传入的参数本身就是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法,如下所示:

let p = Promise.resolve(7)

setTimeout(console.log,0,p === Promise.resolve(p))
//true

setTimeout(console.log,0,p === Promise.resolve(Promise.resolve(p)))
//true
1
2
3
4
5
6
7

这个幂等性会保留传入期约的状态:

let p = new Promise(() => {})

setTimeout(console.log,0,p) // Promise<pending>
setTimeout(console.log,0,Promise.resolve(p)) //Promise<pending>

setTimeout(console.log,0,p === Promise.resolve(p)) //true
1
2
3
4
5
6

注意,这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为:

let p = Promise.resolve(new Error('foo'))

setTimeout(console.log,0,p) 
//Promise<resolved>:Error:foo
1
2
3
4

# 4.Promise.reject()

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

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

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

let p = Promise.reject(3)
setTimeout(console.log,0,p) // Promise<rejected>:3

p.then(null,(e) => setTimeout(console.log,0,e)) // 3
1
2
3
4

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

setTimeout(console.log,0,Promise.reject(Promise.resolve()))
//Promise<rejected>:Promise<resolved>
1
2

# 5.同步/异步执行的二元性

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

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
1
2
3
4
5
6
7
8
9
10
11
12

第一个try/cath抛出并捕获了错误,第二个try/catch抛出错误却没有捕获到。乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约的实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。

在前面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构,更具体说就是期约的方法。

# 期约的实例方法

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

# 1.Thenable接口

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

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

# 2.Promise.prototype.then()

Promise.prototype.then()是为期约实例添加处理程序的主要方法。这个then()方法接收最多两个参数:onResolved处理程序和onRejected处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

function onResolved(id){
  setTimeout(console.log,0,id,'resolved')
}
function onRejected(id){
  setTimeout(console.log,0,id,'rejected')
}

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

p1.then(() => onResolved('p1'),
        () => onRejected('p1'))
p2.then(() => onResolved('p2'),
        () => onRejected('p2'))
        
//3秒后
//p1 resolved
//p2 rejected        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

因为期约只能转换为最终状态1次,所以这两个操作一定是互斥的。

如前所述,两个处理程序参数都是可选的。而且,传给then()的任何非函数类型的参数都会被静默忽略。如果想只提供onRejected参数,那就要在onResolved参数的位置上传入undefined。这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。

function onResolved(id){
  setTimeout(console.log,0,id,'resolved')
}
function onRejected(id){
  setTimeout(console.log,0,id,'rejected')
}

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

//非函数处理程序会被静默忽略,不推荐
p1.then('globbekfdsafe')

//不传onResolved处理程序的规范写法
p1.then(null,() => onRejected('p2'))

// p2 rejected (3秒后)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

let p1 = new Promise(() => {})
let p2 = p1.then()
setTimeout(console.log,0,p1) //Promise<pending>
setTimeout(console.log,0,p2) //Promise<pending>
setTimeout(console.log,0,p1 === p2) //false
1
2
3
4
5

这个新期约实例基于onResolved处理程序的返回值构建。换句话说,该处理程序的返回值会通过Promise.resolve()包装来生成新期约。如果没有提供这个处理程序,则Promise.resolve()就会包装上一个期约解决之后的值。如果没有显示的返回语句,则Promise.resolve()会包装默认的返回值undefined

let p1 = Promise.resolve('foo')

//若调用then()时不传处理程序,则原样向后传
let p2 = p1.then()

setTimeout(console.log,0,p2) // Promise<resolved>:foo

//这些都一样
let p3 = p1.then(() => undefined)
let p4 = p1.then(() => {})
let p5 = p1.then(() => Promise.resolve())

setTimeout(console.log,0,p3) //Promise<resolved>:undefined
setTimeout(console.log,0,p4) //Promise<resolved>:undefined
setTimeout(console.log,0,p5) //Promise<resolved>:undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

如果有显式的返回值,则Promise.resolve()会包装这个值:

//这些都一样
let p6 = p1.then(() => 'bar')
let p7 = p1.then(() => Promise.resolve('bar'))

setTimeout(console.log,0,p6) // Promise<resolved>:bar
setTimeout(console.log,0,p7) // Promise<resolved>:bar

//Promise.resolve()保留返回的期约
let p8 = p1.then(() => new Promise(() => {}))
let p9 = p1.then(() => Promise.reject())
// Uncautght (in promise):undefined

setTimeout(console.log,0,p8) // Promise<pending>
setTimeout(console.log,0,p9) //Promise<rejected>:undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14

抛出异常会返回拒绝的期约:

let p10 = p1.then(() => {throw 'baz'})
//Uncaught (in promise) baz

setTimeout(console.log,0,p10) //Promise<rejected> baz
1
2
3
4

注意,返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中:

let p11 = p1.then(() =>  Error('qux'))
setTimeout(console.log,0,p11) // Promise<rejected>:Error : qux
1
2

onRejected处理程序也与之类似:onRejected处理程序返回的值也会被Promise.resolve()包装。乍一看这可能有点违反直觉,但是想一想,onRejected处理程序的任务不就是捕获异步错误吗?因此,拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。

下面的代码片段展示了用Promise.reject()替代之前例子中的Promise.resolve()之后的结果:

let p1 = Promise.reject('foo')

// 调用then()时不传处理程序则原样向后传
let p2 = p1.then()
//Uncaught (in promise) foo
setTimeout(console.log,0,p2) //Promise<rejected>: foo

//这些都一样
let p3 = p1.then(null,() => undefined)
let p4 = p1.then(null,() => {})
let p5 = p1.then(null,() => Promise.resolve())

setTimeout(console.log,0,p3) // Promise<resolved>:undefined
setTimeout(console.log,0,p4) // Promise<resolved>:undefined
setTimeout(console.log,0,p5) // Promise<resolved>:undefined

//这些都一样
let p6 = p1.then(null,() => 'bar')
let p7 = p1.then(null,() => Promise.resolve('bar'))

setTimeout(console.log,0,p6) // Promise<resolved>:bar
setTimeout(console.log,0,p7) // Promise<resolved>:bar

//Promise.resolve()保留返回的期约
let p8 = p1.then(null,() => new Promise(() => {}))
let p9 = p1.then(null,() => Promise.reject())
//Uncaught (in Promise) :undefined

setTimeout(console.log,0,p8) // Promise<pending>
setTimeout(console.log,0,p9) // Promise<rejected>:undefined

let p10 = p1.then(null,() => {throw 'baz'})
//Uncaught (in promise) baz

setTimeout(console.log,0,p10) //Promise<rejected>:baz

let p11 = p1.then(null,() => Error('qux'))
setTimeout(console.log,0,p11) //Promise<resolved>:Error:qux
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
37
38

# 3.Promise.prototype.catch()

Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null,onrejected)

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

let p = Promise.reject()
let onRejected = function(e) {
  setTimeout(console.log,0,'rejected')
}

//这两种添加拒绝处理程序的方式是一样的:
p.then(null,onRejected); //rejected
p.catch(onRejected)
1
2
3
4
5
6
7
8

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

  let p1 = new Promise(() => {})
  let p2 = p1.catch()
  setTimeout(console.log,0,p1) //Promise<pending>
  setTimeout(console.log,0,p2) //Promise<pending>
  setTimeout(console.log,0,p1 === p2) //false
1
2
3
4
5

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

# 4.Promise.prototype.finally()

Promise.prototype.finally方法用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免onResolvedonRejected处理程序序中出现冗余代码。但onFinally处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

let p1 = Promise.resolve()
let p2 = Promise.reject()
let onFinnaly = function(){
  setTimeout(console.log,0,'Finally!')
}

p1.finally(onFinally) //Finally
p2.finally(onFinally) //Finally
1
2
3
4
5
6
7
8

Promise.prototye.finally()方法返回一个新的期约实例:

let p1 = new Promise(() => {})
let p2 = p1.finally()
setTimeout(console.log,0,p1) // Promise<pending>
setTimeout(console.log,0,p2) // Promise<pending>
setTimeout(console.log,0,p1 === p2) // false
1
2
3
4
5

这个新期约实例不同于then()catch()方法返回的实例。因为onFinnally被设计为一个状态无关的方法,所以大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。

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'))

setTimeout(console.log, 0, p2) //Promise<resolved>:foo
setTimeout(console.log, 0, p3) //Promise<resolved>:foo
setTimeout(console.log, 0, p4) //Promise<resolved>:foo
setTimeout(console.log, 0, p5) //Promise<resolved>:foo
setTimeout(console.log, 0, p6) //Promise<resolved>:foo
setTimeout(console.log, 0, p7) //Promise<resolved>:foo
setTimeout(console.log, 0, p8) //Promise<resolved>:foo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果返回的是一个待定的期约,或者onFinally处理程序抛出了错误(显示抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝),如下所示:

//Promise.resolve()保留返回的期约
let p9 = p1.finally(() => new Promise(() => {}))
let p10 = p1.finally(() => Promise.reject())
// Uncaught (in promise):undefined

setTimeout(console.log, 0, p9) //Promise<pending>
setTimeout(console.log, 0, p10) //Promise<rejected>: undefined

let p11 = p1.finally(() => {throw 'baz';})
//Uncaught (in promise) baz

setTimeout(console.log,0,p11) //Promise<rejected>: baz
1
2
3
4
5
6
7
8
9
10
11
12

返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约:

let p1 = Promise.resolve('foo')

//忽略解决的值
let p2 = p1.finally(() => new Promise((resolve,reject) => setTimeout(() => resolve('bar'),100)))

setTimeout(console.log,0,p2) //Promise<pending>

setTimeout(() => setTimeout(console.log,0,p2),200)

//200毫秒后
//Promise<resolved>:foo
1
2
3
4
5
6
7
8
9
10
11

# 5.非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样。这个特性由javascript运行时保证,被称为“非重入”(non-reentrancy)特性。

//创建解决的期约
let p = Promise.resolve()

//添加解决处理程序
//直觉上,这个处理程序会等期约一解决就执行
p.then(() => console.log('onResolved handler'))

//同步输出,证明then()已经返回
console.log('then() returns')

//实际的输出
//then() returns
//onResolved handler
1
2
3
4
5
6
7
8
9
10
11
12
13

在这个例子中,在一个解决期约上调用then()会把onResolved处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在then()后面的同步代码一定先于处理程序执行。

先添加处理程序后解决期约也是一样的。如果添加处理程序后,同步代码才改变期约状态,那么处理程序仍然会基于该状态变化表现出非重入特性。

下面的例子展示了即使先添加了onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行:

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在这个例子中,即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。

非重入适用于onResolved/onRejected处理程序、catch()处理程序和finally()处理程序。下面的例子演示了这些处理程序都只能异步执行:

let p1 =  Promise.resolve()
p1.then(() => console.log('p1.then() onResolved'))
console.log('p1.then() returns')

let p2 = Promise.reject()
p2.then(null, () => console.log('p2.then() onRejected'))
console.log('p2.then() returns')

let p3 = Promise.reject()
p3.catch(() => console.log('p3.catch() onRejected'))
console.log('p3.catch() returns')

let p4 = Promise.resolve()
p4.finally(() => console.log('p4.finally() onFinally'))
console.log('p4.finally() returns')

//p1.then() returns
//p2.then() returns
//p3.then() returns
//p4.then() returns
//p1.then() onResolved
//p2.then() onRejected
//p3.catch() onRejected
//p4.finally() onFinally
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 6.临近处理程序的执行顺序

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

let p1 = Promise.resolve()
let p2 = Promise.reject()

p1.then(() => setTimeout(console.log, 0, 1))
p1.then(() => setTimeout(console.log, 0, 2))
//1
//2

p2.then(null,() => setTimeout(console.log, 0, 3))
p2.then(null,() => setTimeout(console.log, 0, 4))
//3
//4

p2.catch(() => setTimeout(console.log, 0, 5))
p2.catch(() => setTimeout(console.log, 0, 6))
//5
//6

p1.finally(() => setTimeout(console.log, 0, 7))
p1.finally(() => setTimeout(console.log, 0, 8))
//7
//8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 7.传递解决值和拒绝理由

到了落定状态后,期约会提供其解决值(如果兑现)或者其拒绝理由(如果拒绝)给相关状态的处理程序。

拿到返回值后,就可以进一步对这个值进行操作。比如,第一次网络请求返回的JSON是发送第二次请求的必须数据,那么第一次请求返回的值就应该传给onResolved处理程序继续处理。当然,失败的网络请求也应该把HTTP状态码传给onRejected处理程序。

在执行函数中,解决的值和拒绝的理由分别作为resolve()reject()的第一个参数往后传的。然后,这些值又会传给他们各自的处理程序,作为onResolvedonRejected处理程序的唯一阐述。

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
1
2
3
4
5

Promise.resolve()Promise.reject()在被调用时就会接收解决值和拒绝理由。同样地,他们返回的期约也会像执行器一样把这些值传给onResolvedonRejected处理程序:

let p1 = Promise.resolve('foo')
p1.then((value) =>  console.log(value)) //foo

let p2 = Promise.reject('bar')
p2.catch((reason) => console.log(reason)) //bar
1
2
3
4
5

# 8.拒绝期约与拒绝错误处理

拒绝期约类似于throw()表达式,因为他们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:

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(Errpr('foo'))

setTimeout(console.log, 0, p1)//Promise<rejected>: Error: foo
setTimeout(console.log, 0, p2)//Promise<rejected>: Error: foo
setTimeout(console.log, 0, p3)//Promise<rejected>: Error: foo
setTimeout(console.log, 0, p4)//Promise<rejected>: Error: foo

//也会抛出4个未捕获错误
1
2
3
4
5
6
7
8
9
10
11

期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。例如,前面例子中抛出的4个错误的栈追踪信息如下:

Uncaught (in promise) Error: foo
  at Promise(this.html:5)
  at new Promise (<anonymous>)
  at test.html:5
Uncaught (in promise) Error: foo
  at Promise(this.html:6)
  at new Promise (<anonymous>)
  at test.html:6
Uncaught (in promise) Error: foo
  at test.html:8
Uncaught (in promise) Error: foo
  at Promise.resolve.then(test.html:7)
1
2
3
4
5
6
7
8
9
10
11
12

所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。

注意错误的顺序:Promise.resolve().then()的错误最后才发现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。

这个例子同样揭示了异步错误有意思的副作用。正常情况下,在通过throw()关键字抛出错误时,JavaScript运行时的错误机制会停止执行抛出错误之后的任何指令:

trow Error('foo')
console.log('bar') //这一步不会执行

//Uncaught Error: foo
1
2
3
4

但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令:

Promise.reject(Error('foo'))
console.log('bar')
//bar

//Uncaught (in promise) Error: foo
1
2
3
4
5

如本章前面的Promise.reject()示例所示,异步错误只能通过异步的onRejected处理程序捕获:

//正确
Promise.reject(Error('foo')).catch((e) => {})

//不正确
try{
  Promise.reject(Error('foo'))
}catch(e){}
1
2
3
4
5
6
7

这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可用使用try/catch在执行函数中捕获错误:

let p = new Promise((resolve,reject) => {
  try {
    throw Error('foo')
  }catch(e){

  }
  reslove('bar')
})

setTimeout(console.log, 0, p) //Promise<resolved>:bar
1
2
3
4
5
6
7
8
9
10

then()catch()onRejected处理程序在语义上相当于try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。下面例子中对比了同步错误处理与异步错误处理:

console.log('begin synchronous execution')
try {
  throw Error('foo')
} catch(e) {
  console.log('caught error', e)
}
console.log('continue synchronous execution')
//begin synchronous execution
//caught error Error:foo
//continue synchronous execution

new Promise((resolve,reject) => {
  console.log('begin asunchronous execution')
  reject(Error('bar'))
}).catch((e) => {
  console.log('caught error',e)
}).then(() => {
  console.log('continue asynchronous execution)
})

//begin asunchronous execution
// caught error Error:bar
// continue asynchronous execution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 期约连锁与期约合成

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

# 1.期约连锁

把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()catch()finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。比如:

let p = new Promise((resolve,reject) => {
  console.log('first)
  resolve()
})
p.then(() => console.log('second'))
 .then(() => console.log('thrid')
 .then(() => console.log('fourth'))

//first
//second
//third
//fourth 
1
2
3
4
5
6
7
8
9
10
11
12

这个实现了最终执行一连串同步任务。正是因为如此,这种方式执行的任务没有那么有用,毕竟分别使用4个同步函数也可以做到:

(() => console.log('first'))()
(() => console.log('second'))()
(() => console.log('thrid'))()
(() => console.log('fourth'))()
1
2
3
4

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

let p1 = new Promise((resolve,reject) => {
  console.log('p1 excutor')
  setTimeout(resolve,1000)
})

p1.then(() => new Promise((resolve,reject) => {
    console.log('p2 excutor');
    setTimeout(resolve,1000)
  }))
  .then(() => new Promise((resolve,reject) => {
    console.log('p3 excutor')
    setTimeout(resolve,1000)
  }))
  .then(() => new Promise((resolve,reject) => {
    console.log('p4 excutor')
    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
17
18
19
20
21
22

把生成期约的代码提取到一个工厂函数中,就可以写成这样:

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
8
9
10
11
12
13
14
15
16

每个后续的处理程序都会等待前一个期约解决,然后实例化一个新期约并返回它。这种结构可以简洁地将异步任务串行化,解决之前依赖回调的问题。

因为then()catch()finally()都返回期约,所以串联这些方法也很直观。下面的例子同事使用这三个实例方法:

let p = new Promise((resolve,reject) => {
  console.log('initial promise rejects')
  reject()
})

p.catch(() => console.log('reject handler'))
 .then(() => console.log('resolve handler'))
 .finally(() => console.log('finally handler'))

//initial promise rejects
//reject handler
//resolve handler
//finally handler
1
2
3
4
5
6
7
8
9
10
11
12
13

# 2.期约图

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。

下面的例子展示了一种期约有向图,也就是二叉树:

//     A
//   /   \
//  B     C
// / \   / \
//D   E F   G

let A = new Promise((resolve,reject)=> {
  console.log('A')
  resolve()
})

let B = A.then(() => console.log('B'))
let C = A.then(() => console.log('C'))

B.then(() => console.log('D'))
B.then(() => console.log('E'))
C.then(() => console.log('F'))
C.then(() => console.log('G'))

//A
//B
//C
//D
//E
//F
//G
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

注意,日志的输出语句是对二叉树的层序遍历。如前所述,期约的处理程序是按照他们添加的顺序执行的。由于期约的处理程序是添加到消息队列,然后才逐个执行,因此构成了层序遍历。

树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约(通过下面介绍的Promise.all()Promise.race()),所以有向非循环图是体现期约连锁可能性的最准确表达。

# 3.Promise.all()和Promise.race()

Promise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()Promise.race()。而合成后期约的行为取决于内部期约的行为。

  • Promise.all() Promise.all()静态方法创建的期约会在一组期约全部解决之后在解决。这个静态方法接收一个可迭代对象,返回一个新期约:
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
10
11
12
13
14

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

let p = Promise.all([
  Promise.resolve(),
  new Promise((resolve,reject) => setTimeout(resolve,1000))
])
setTimeout(console.log,0,p) // Promise<pending>

p.then(() => setTimeout(console.log,0,'all() resolved!'))

//all() resolved! (大约1秒后)
1
2
3
4
5
6
7
8
9

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

//永远待定
let p1 = Promise.all([new Promise(() => {})])
setTimeout(console.log,0,p1) //Promise<rejected>

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

//Uncaught (in Promise) undefiend
1
2
3
4
5
6
7
8
9
10
11
12
13

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

let p = Promise.all([
  Promise.resolve(3),
  Promise.resolve(),
  Promise.resolve(4)
])

p.then((values) => setTimeout(console.log, 0, values)) // [3,undefined,4]
1
2
3
4
5
6
7

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

//虽然只有第一个期约的拒绝理由会进入
//拒绝处理程序,第二个期约的拒绝也会被静默处理,不会有错误跑掉
let p = Promise.all([
  Promise.reject(3),
  new Promise((resolve,reject) => setTimeout(reject,1000))
])

p.catch((reason) => setTimeout(console.log, 0, reason)) //3

//没有未处理的错误
1
2
3
4
5
6
7
8
9
10
  • Promise.race() Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约:
let p1 = Promise.race([
  Promise.resolve(),
  Promise.resolve()
])

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

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

//无效语法
let p4 = Promise.race()

//TypeError:cannot read Symbol.interator of undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

//解决先发生,超时后的拒绝被忽略
let p1 = Promise.race([
  Promise.resolve(3),
  new Promise([rsolve,reject] => setTimeout(reject,1000))
])
setTimeout(console.log, 0, p1) //Promise<resolved>:3

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

//迭代顺序决定了落定顺序
let p3 = Promise.race([
  Promise.resolve(5),
  Promice.resolve(6),
  Promise.resolve(7)
])
setTimeout(console.log, 0, p3) //Promise<resolved>:5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所包含期约正常的拒绝操作。与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作:

//虽然只有第一个期约的拒绝理由会进入
//拒绝处理程序,第二个期约的拒绝也会被静默处理,不会有错误跑掉
let p = Promise.race([
  Promise.reject(3),
  new Promise((resolve,reject) => setTimeout(reject,1000))
])

p.catch((reason) => setTimeout(console.log,0,reason)) //3

//没有未处理的错误
1
2
3
4
5
6
7
8
9
10

# 4.串行期约合成

到目前为止,我们讨论期约连锁一直围绕期约的串行执行,忽略了期约的另一个主要特性:异步产生值并将其传给处理程序。基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。这很像函数合成,即将多个函数合成为一个函数:

function addTwo(x){return x+2}
function addThree(x){return x+3}
function addFive(x){return x+5}

function addTen(x){
  return addFive(addTwo(addThree(x)))
}

console.log(addTen(7)) //17
1
2
3
4
5
6
7
8
9

在这个例子中,有三个函数基于一个值合成为一个函数。类似地,期约也可以像这样合成起来,渐渐地消费一个值,并返回一个结果:

function addTwo(x){return x+2}
function addThree(x){return x+3}
function addFive(x){return x+5}

function addTen(x){
  return Promise.resolve(x)
        .then(addTwo)
        .then(addThree)
        .then(addFive)     
}

addTen(8).then(console.log) //18
1
2
3
4
5
6
7
8
9
10
11
12

使用Array.prototype.reduce()可以写成更简洁的形式:

function addTwo(x){return x+2}
function addThree(x){return x+3}
function addFive(x){return x+5}

function addTen(x) {
  return [addTwo,addThree,addFive]
          .reduce((promise,fn) => promise.then(fn),Promise.resolve(x))
}
addTen(8).then(console.log) //18
1
2
3
4
5
6
7
8
9

这种模式可以提炼出一个通用函数,可以把任意多个函数左沩处理程序合成一个连续传值的期约连锁。这个通用的合成函数可以这样实现:

function addTwo(x){return x+2}
function addThree(x){return x+3}
function addFive(x){return x+5}

function compose(...fns){
  return (x) => fns.reduce((promise,fn) => promise.then(fn),Promise.resolve(x))
}

let addTen = compose(addTwo,addThree,addFive)

addTen(8).then(console.log) //18
1
2
3
4
5
6
7
8
9
10
11

# 期约扩展

ES6期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库中具备而ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。

# 1.期约取消

我们经常会遇到期约正在处理的过程中,程序却不再需要其结果的情形。

实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。

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

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

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

这个类大概这样使用:

<button id="start">Start</button>
<button id="cancel">Cancel</button>

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

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

  function cancelableDelayedResolve(delay) {
    setTimeout(console.log, 0, "set delay")

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

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

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

  startButton.addEventListener("click",() => cancellableDelayedResolve(1000))
</script>
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

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

# 2.期约进度通知

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

一种实现方式是扩展Pormise类,为它添加notify()方法:

class TrackablePromise extends Promise {
  constructor(executor) {
    const notifyHandlers = []

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

    this.notifyHandlers = notityHandlers
  }

  notify(notifyHandler) {
    this.notifyHandlers.push(notifyHandler)
    return this
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

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

  countdown(5)
})
1
2
3
4
5
6
7
8
9
10
11
12

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

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

  countdown(5)
})

p.notify((x) => setTimeout(console.log, 0, 'progress', x))

p.then(() => setTimeout(console.log, 0, 'completed'))

// (约1秒后) 80% remaining
// (约2秒后) 60% remaining
// (约3秒后) 40% remaining
// (约4秒后) 20% remaining
// (约5秒后) completed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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

...
p.notify((x) => setTimeout(console.log, 0, 'a:',x))
  .nority((x) => setTimeout(console.log, 0, 'b:',x))

p.then(() => setTimeout(console.log, 0, '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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

注意

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