Promise期约
从ES6开始增加了Promise类型,称为了主导性的异步编程机制。
期约Promise是一个有状态的对象,可能处于如下三种状态之一:
- 待定(pending)
- 兑现(fulfilled,或被称为“解决”,resolved)
- 拒绝(rejected)
pending是期约的最初始状态。在这个状态下,promise可以进行落定(settled)为fufilled或rejected,他们分别代表了成功和失败的状态。无论落定为哪种状态都是不可逆的,转换为resolve或reject后,期约的状态就不再改变。
promise状态转换为兑现resolve时,会有一个私有的内部值(value),而转换为失败reject时,会有一个私有的内部理由(reason),默认值为undefined。
由于期约的状态是私有的,所以改变其状态只能在内部操作。控制期约状态转换是通过调用它的两个函数参数实现的:resolve()和reject()
为避免期约卡在超时状态,可以添加一个定时退出功能。比如,可以通过setTImeout设置一个10秒钟后无论如何都会拒绝期约的回调:
1 let p = new Promise((resolve,reject)=>{ 2 setTimeout(reject,10000);//10秒后调用reject() 3 });
因为期约的状态码只能改变一次,所以这里的超时拒绝逻辑可以放心写,因为如果在超时执行前进行了状态的改变,因为其特性,超时无法更改状态。
promise.resolve()
期约并非一开始就必须处于待定状态,然后通过resolve()和reject()函数才能转换为落定状态。通过调用Promise.resolve()方法可以实例化一个解决的期约。
1 let p1 = new Promise((resolve,reject)=>resolve()); 2 let p2 = new Promise.resolve();
以上两个期约的实例实际上是一样的。
传入promise.resolve()方法的参数对应着value的值
setTimeout(console.log,0,Promise.resolve(3)); //Promise <resolved>:3
promise.resolve()如果传入的参数本身是一个期约,那它的行为就类似一个空包装。因此,promise.resolve()可以说是一个幂等方法,幂等性会保留传入期约的状态。
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 全等 证明理论正确
ps:幂等性的概念如下
幂等性 Idempotent
在数学中的幂等性:f(x)=f(f(x)) ,拥有相同的输出
Promise.reject()
与promise.resolve()类似,会实例化一个拒绝的期约并抛出一个异步错误(只能通过拒绝处理程序捕获)。
以下两个实例实际上是一样的
let p1 = new Promise(resolve,reject)=>reject()); let p2 = Promise.reject();
拒绝的期约的理由(reason)就是传给Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序
Promise.prototype.then()
then()方法是为实例期约添加处理程序的主要方法,最多可接收两个参数:onResolved和onRejected。这两个参数是代表成功和拒绝状态时处理的程序,这两个参数可以同时选择,期约会分别在成功和拒绝状态执行相应的代码
1 function onResolved(id){ 2 setTimeout(console.log,0,id,'resolved'); 3 } 4 function onRejected(id){ 5 setTimeout(console.log,0,id,'rejected'); 6 } 7 let p1 = new Promise((resolve,reject)=>setTimeout(resolve,3000)); 8 let p2 = new Promise((resolve,reject)=>setTimeout(reject,3000)); 9 p1.then(()=>onResolved('p1'), 10 ()=>onRejected("p1")); 11 p2.then(()=>onResolved('p2'), 12 ()=>onRejected("p2"));
//3000ms after
//p1 resolved
//p2 rejected
如上所述,两个处理程序参数都可选,且因为期约状态只能转换为落定settle状态一次,所以这两个操作一定是互斥的。
此外,传给then()的任何非函数类型的参数都会被静默忽略。如果只想提供onRejected参数,那就要在onResolved参数的位置上传入undefined。这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。
//不传onResolved处理程序的规范写法 p2.then(null,()=>onRejected('p2'))
Promise.prototype.then()方法返回一个新的期约的实例:
let p3 = new Promise(()=>{}); let p4 = p3.then(); setTimeout(console.log,0,p3===p4);//false
这个新期约实例基于onResolved 处理程序的返回值构建。换句话说,该处理程序的返回值会通过Promise.resolve()包装来生成新期约。如果没有提供这个处理程序,则Promise.resolve()会包装上一个期约解决之后的值。如果没有显式的返回语句,则Promise.resolve()会包装默认的返回值undefined.
let p1 = Promise.resolve('foo'); let p2 = p1.then(); //若调用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
如果有显式的返回值,则Promise.resolve()会包装这个值:
1 let p6 = p1.then(()=>'bar'); 2 let p7 = p1.then(()=>Promise.resolve('bar')); 3 4 setTimeout(console.log,0,p6)//Promise <resolved>:bar 5 setTimeout(console.log,0,p7)//Promise <resolved>:bar 6 7 // Promise.resolve()保留返回的期约 8 let p8 = p1.then(()=>new Promise(()=>{})); 9 let p9 = p1.then(()=>Promise.reject()); 10 11 12 setTimeout(console.log,0,p8)//Promise <pending> 13 setTimeout(console.log,0,p9)//Promise <rejected>:undefined
抛出异常会返回拒绝的期约:
1 let p10 = p1.then(()=>{throw 'baz';}); 2 //Uncaught (in promise) baz 3 setTimeout(console.log,0,p10)//Promise <rejected> baz
返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中:
let p11 = p1.then(()=>Error('qux')); setTimeout(console.log,0,p11)//Promise <resolved>:Error qux
onRejected处理程序也与之类似:onRejected处理程序返回的值也会被Promise.resolve()包装。拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。
Promise.prototype.catch()
该方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null,onRejected)
语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
1 let p = Promise.reject(); 2 let onRejected = function(e){ 3 setTimeout(console.log,0,'rejected'); 4 }; 5 6 // 下面两种添加拒绝处理程序的方式是一样的 7 p.then(null,onRejected);//rejected 8 p.catch(onRejected);//rejected
promise.prototype.catch()返回一个新的期约实例:
let p1 = new Promise(()=>{}) let p2 = p1.catch(); setTimeout(console.log,0,p1) setTimeout(console.log,0,p2) setTimeout(console.log,0,p1===p2)
在返回新期约实例方面,Promise.prototype.catch()的行为与Promise.prototype.then()的onRejected处理程序是一样的。
Promise.prototype.finally()
Promise.prototype.finally()方法用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免onResolved和onRejected处理程序中出现冗余代码。但onFinnally处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。
1 let p1 = Promise.resolve(); 2 let p2 = Promise.reject(); 3 let onFinally=function(){ 4 setTimeout(console.log,0,'Finally!') 5 } 6 7 p1.finally(onFinally)//Finally 8 p2.finally(onFinally)//Finally
promise.prototype.finally()方法返回一个新的期约实例:
1 let p1 = new Promise(()=>{}) 2 let p2 = p1.finally(); 3 setTimeout(console.log,0,p1) //Promise <pending> 4 setTimeout(console.log,0,p2) //Promise <pending> 5 setTimeout(console.log,0,p1===p2) //false
这个新期约不同于then()或catch()方式返回的实例。因为onFinally被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。
大部分时候,都会原样后传。但如果返回的是一个待定的期约,或者onFinally处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝),如下所示:
1 let p1 = Promise.resolve('foo') 2 let p2 = p1.finally(()=>new Promise(()=>{})) 3 let p3 = p1.finally(()=>Promise.reject()) 4 5 setTimeout(console.log,0,p2)// promise <pending> 6 setTimeout(console.log,0,p3)// promise <rejected>
返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约
如下:
1 let p1 = Promise.resolve('foo') 2 let p2 = p1.finally(()=>new Promise((resolve,reject)=>setTimeout(()=>resolve('bar'),100))); 3 4 setTimeout(console.log,0,p2) 5 6 setTimeout(()=>setTimeout(console.log,0,p2),200)
该代码会先输出promise<pending>而在200ms后输出promise<resolved>:foo,可以明显的看出由该方法创建的新实例的状态是由前面的实例状态后传的。
非重定入期约的方法
当期约进入落定settle状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前执行。这个特性由JavaScript运行时的保证,被称为“非重入(non-reentrancy)”特性。下面的例子演示了这个特性:
1 let p = Promise.resolve() 2 3 p.then(()=>console.log('onResolved handler')) 4 5 console.log('then()returns')
在这个例子中,在一个解决期约上调用then()会把onResolved处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完全前不会执行。因此,跟在then()后面的同步代码一定会先于处理程序执行。
先添加处理程序后解决期约也是一样的。下面的例子展示了即使先添加了onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行:
1 let syn; 2 let p = new Promise((resolve) => { 3 syn = function () { 4 console.log('1:invoking resolve()') 5 resolve() 6 console.log('2:resolve() returns') 7 } 8 }) 9 p.then(()=>{ 10 console.log('4:then() handler executes') 11 }) 12 syn() 13 console.log('3:syn() returns');
// 1:...
// 2:...
// 3:...
// 4:...
这个例子中,处理程序作为异步任务,会在同步任务执行完毕之后,再从消息队列中 出列执行。
非重入适用于onResolved/onRejected 处理程序、catch()处理程序和finnally()处理程序。
下面的例子能很明显的看出:异步任务总是在同步任务执行完毕后才执行
1 let p1 = Promise.resolve() 2 p1.then(()=>{console.log('p1.then()')}) 3 console.log('p1 over') 4 5 let p2 = Promise.reject() 6 p2.then(null,()=>console.log("p2.then()")) 7 console.log('p2 over') 8 9 let p3 = Promise.reject() 10 p3.catch(()=>console.log("p3.catch()")) 11 console.log('p3 over') 12 13 let p4 = Promise.resolve() 14 p4.finally(()=>console.log('p4.finnally()')) 15 console.log('p4 over') 16 17 //p1 over 18 //p2 over 19 //p3 over 20 //p4 over 21 // p1.then() 22 // p2.then() 23 // p3.catch() 24 // p4.finnally()
邻近处理程序的执行顺序
如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是then()、catch()、finally()都是一样的
1 let p1 = Promise.resolve(); 2 let p2 = Promise.reject(); 3 4 p1.then(()=>setTimeout(console.log,0,1)); 5 p1.then(()=>setTimeout(console.log,0,2)); 6 //1 7 //2 8 9 p2.then(null,()=>setTimeout(console.log,0,3)) 10 p2.then(null,()=>setTimeout(console.log,0,4)) 11 //3 12 //4 13 14 p2.catch(()=>setTimeout(console.log,0,5)) 15 p2.catch(()=>setTimeout(console.log,0,6)) 16 //5 17 //6 18 19 p1.finally(()=>setTimeout(console.log,0,7)) 20 p1.finally(()=>setTimeout(console.log,0,8)) 21 //7 22 //8
传递解决值和拒绝的理由
到了落定settle状态后,期约会提供其解决值value(如果成功 )或拒绝理由 reason(如果失败) 给相关的状态处理程序。获取到返回值后,可以进一步对这个值进行操作。比如,第一次网络请求返回的JSON是发送第二次请求必需的数据,那么第一次请求返回的值就应该传给onResolved 处理程序继续处理。当然,失败的网络请求也应该把HTTP状态码传给onRejected处理程序。
在执行程序中,解决的值和拒绝的理由是分别作为resolve()和reject()的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为onResolved或onRejected的参数。下面的例子展示了传递过程:
1 let p1 = new Promise((resolve,reject)=>resolve('foo')) 2 //这里promise实例p1中调用了resolve方法 并传递了参数'foo'给对应的状态处理程序--作为onResolved的参数 3 p1.then((value)=>{console.log(value)}) 4 //foo 5 6 let p2 = new Promise((resolve,reject)=>reject('baz')) 7 //同上 8 p2.catch((reason)=>console.log(reason)) 9 //baz
当然promise.resolve()和promise.reject()方法也是一样的
拒绝期约与拒绝错误处理
拒绝期约类似于throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:
1 let p1 = new Promise((resolve,reject)=>reject(Error('foo'))); 2 let p2 = new Promise((resolve,reject)=>{throw Error('foo');}); 3 let p3 = Promise.resolve().then(()=>{throw Error('foo');}); 4 let p4 = Promise.reject(Error('foo')); 5 6 setTimeout(console.log,0,p1) //Promise <rejected>:Error :foo 7 setTimeout(console.log,0,p2) //Promise <rejected>:Error :foo 8 setTimeout(console.log,0,p3) //Promise <rejected>:Error :foo 9 setTimeout(console.log,0,p4) //Promise <rejected>:Error :foo
期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对于调试是非常关键的!例如前面案例中第一个抛出的错误的栈追踪信息:
1 Uncaught (in promise) Error: foo 2 at 04.html:112:55 3 at new Promise (<anonymous>) 4 at 04.html:112:18
所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。注意错误的顺序:Promise.resolve().then()的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。
这个例子同样揭示了异步错误一个副作用:正常情况下,在通过throw()关键字抛出错误时,throw()后面的代码不会执行,JavaScript的错误处理机制会在运行完throw()后暂停执行,但由于异步错误是从消息队列中异步抛出的原因,此时不会暂停同步代码的执行,所以后续的代码会继续得到执行。如下:
1 throw Error('foo') 2 console.log('bar') // 这一行不会执行 3 4 Promise.reject(Error('foo')) 5 console.log('continue') //continue成功打印了 说明没能暂停执行同步任务
异步错误只能通过异步的onRejected处理程序捕获:
1 Promise.reject(Error('foo')).catch((e)=>{}); 2 3 //控制台输入一下判断是否捕获正确 4 Promise.reject(Error('foo')).catch((e)=>{console.log(e)});//Error:foo
then()和catch()的onRejected处理程序在语义上相当于try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected处理程序任务应该是在捕获异步错误之后返回一个解决fufilled的期约。下面的例子中对比了同步错误处理与异步错误处理:
1 let a = new Promise((resolve,reject)=>{ 2 console.log('begin') 3 reject(Error('bar')) 4 }).catch((e)=>{ 5 console.log('caught error',e) 6 }).then(()=>{ 7 console.log('continue exe') 8 }) 9 console.log(a)//这里输出一下a 得到 promise<fufilled>证明onRejected处理程序任务在捕获异步错误后 返回了一个fufilled的期约(代表成功捕获异常?)
期约连锁与期约合成
多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。
期约连锁
把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()、catch()、finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。例如:
1 let p = new Promise((resolve, reject) => { 2 console.log('first') 3 resolve() 4 }) 5 p.then(() => console.log('second')) 6 .then(() => console.log('third')) 7 .then(() => console.log('fourth')) 8 9 //first 10 //second 11 //third 12 //fourth
这个实现最终执行了一连串同步任务。所以,其实这种方式执行没有那么有用。。。直接用多个同步函数也可以做到
如果要真正执行异步任务,可以改写前面的例子,让每个执行器都返回一个期约实例。这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。比如,可以像下面这样让每个期约在一定时间后解决:
1 let p = new Promise((resolve, reject) => { 2 console.log('first') 3 setTimeout(resolve, 1000); 4 }); 5 p.then(() => new Promise((resolve, reject) => { 6 console.log('second') 7 setTimeout(resolve, 1000); 8 })) 9 .then(() => new Promise((resolve, reject) => { 10 console.log('third') 11 setTimeout(resolve, 1000); 12 })) 13 .then(() => new Promise((resolve, reject) => { 14 console.log('fourth') 15 setTimeout(resolve,1000) 16 }))
把生成期约的代码提取到一个工厂模式函数中,如下:
1 function delay(str){ 2 return new Promise((resolve,reject)=>{ 3 console.log(str); 4 setTimeout(resolve,1000); 5 }) 6 } 7 delay('p1 exe') 8 .then(()=>delay('p2 exe')) 9 .then(()=>delay('p3 exe')) 10 .then(()=>delay('p4 exe'))
每个后续的处理程序都会等待前一个期约解决,然后实例化一个新期约并返回它。这种结构可以简洁地将一部任务串行化,解决之前依赖回调的难题。假如这种情况下不使用期约,那么前面的代码可能就要这样写了:
1 function delay(str,callback=null){ 2 setTimeout(()=>{ 3 console.log(str) 4 callback && callback(); 5 },1000) 6 } 7 8 delay('p1 callback',()=>{ 9 delay('p2 callback',()=>{ 10 delay('p3 callback',()=>{ 11 delay('p4 callback') 12 }) 13 }) 14 })
这正是期约所要解决的回调地狱问题。
期约图
因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。
下面的例子展示了一种期约有向图,也就是二叉树:
1 let a=new Promise((resolve,reject)=>{ 2 console.log('a'); 3 resolve(); 4 }); 5 6 let b = a.then(()=>console.log('b')) 7 let c = a.then(()=>console.log('c')) 8 9 let d = b.then(()=>console.log('d')) 10 let e = b.then(()=>console.log('e')) 11 let f = c.then(()=>console.log('f')) 12 let g = c.then(()=>console.log('g')) 13 //a 14 //b 15 //c 16 //d 17 //e 18 //f 19 //g
如前所述,期约的处理程序是按照它们的添加的顺序执行的。由于期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。
数只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约(通过Promise.all()和Promise.race()),所以有向非循环图是体现期约连锁可能性的最准确表达。
Promise.all()和Promise.race()
Promise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()和 Promise.race()。而合成后期约的行为取决于内部期约的行为
Promise.all()
Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约:
1 let p1 = Promise.all([ 2 Promise.resolve(), 3 Promise.resolve() 4 ]); 5 6 //可迭代对象中的元素会通过Promise.resolve()转换为期约 7 let p2 = Promise.all([3,4]) 8 9 //空的刻迭代对象等价于Promise.resolve() 10 let p3 = Promise.all([]) 11 12 //无效 报错TypeError: cannot read Symbol.iterator of undefined 13 let p4 = Promise.all()
合成的期约只会在每个包含的期约都解决之后解决:
1 let p = Promise.all([ 2 Promise.resolve(), 3 new Promise((resolve,reject)=>setTimeout(resolve,1000)) 4 ]) 5 setTimeout(console.log,0,p)//Promise <pending> 6 7 p.then(()=>setTimeout(console.log,0,'all() resolved')) 8 //1s all() resolved
如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝:
1 let p1 = Promise.all([new Promise(()=>{})]) 2 setTimeout(console.log,0,p1)//promise <pending> 3 4 let p2 = Promise.all([ 5 Promise.resolve(), 6 Promise.reject(), 7 Promise.resolve 8 ]) 9 setTimeout(console.log,0,p2)//promise <rejected>
如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序排列:
1 let p = Promise.all([ 2 Promise.resolve(0), 3 Promise.resolve(1), 4 Promise.resolve(2) 5 ]) 6 p.then((values)=>setTimeout(console.log,0,values))
如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作,如下所示:
1 let p = Promise.all([ 2 Promise.reject(1), 3 Promise.reject(2), 4 Promise.reject(3) 5 ]) 6 p.catch((reason)=>setTimeout(console.log,0,reason));//1
Promise.race()
Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约:
1 let p1 = Promise.race([ 2 Promise.resolve(), 3 Promise.resolve() 4 ]); 5 6 //可迭代对象中的元素会通过Promise.resolve()转换为期约 7 let p2 = Promise.race([3,4]) 8 console.log(p2) 9 10 //空的刻迭代对象等价于Promise.resolve() 11 let p3 = Promise.race([]) 12 13 //无效 报错TypeError: cannot read Symbol.iterator of undefined 14 let p4 = Promise.race() 15
promise.race()不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,promise.race()就会包装其解决值或拒绝理由并返回新期约:
1 let p1 = Promise.race([ 2 Promise.resolve(3), 3 new Promise((resolve,reject)=>setTimeout(resolve,1000)) 4 ]) 5 setTimeout(console.log,0,p1)// promise<resolved>
如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作,如下所示:
1 let p1 = Promise.race([ 2 Promise.reject(3), 3 new Promise((resolve, reject) => setTimeout(reject, 1000)) 4 ]) 5 p1.catch((reason)=>setTimeout(console.log,0,reason))//3
虽然这里只有第一个期约的拒绝理由会进入拒绝处理程序,但第二个期约的拒绝也会被静默处理掉,不会有错误跑掉。
串行期约合成
期约的异步产生值并将其传给处理程序。基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。这很像函数合成,即将多个函数合为一个函数,比如:
1 function addTwo(x){return x+2} 2 function addThree(x){return x+3} 3 function addFive(x){return x+5} 4 5 function addTen(x){ 6 return addFive(addTwo(addThree(x))) 7 } 8 console.log(addTen(7))//17
在这个例子中,有三个函数基于一个值合成为一个函数。
类似地,期约也可以像这样合成起来,渐进地消费一个值,并返回一个结果:
1 function addTwo(x){return x+2} 2 function addThree(x){return x+3} 3 function addFive(x){return x+5} 4 5 function addTen(x){ 6 return Promise.resolve(x) 7 .then(addTwo) 8 .then(addThree) 9 .then(addFive); 10 } 11 12 addTen(8).then(console.log)//18
使用Array.prototype.reduce()可以写成更简洁的形式:
1 function addTwo(x){return x+2} 2 function addThree(x){return x+3} 3 function addFive(x){return x+5} 4 function addTen(x){ 5 return [addTwo,addThree,addFive] 6 .reduce((promise,fn)=>promise.then(fn),Promise.resolve(x)) 7 } 8 9 addTen(8).then(console.log)//18
这种模式可以提炼出一个通用函数,可以把任意多个函数作为处理程序合成一个连续传值的期约连锁。这个通用的合成函数可以这样实现:
1 function addTwo(x){return x+2} 2 function addThree(x){return x+3} 3 function addFive(x){return x+5} 4 5 function compose(...fns){ 6 return(x)=>fns.reduce((promise,fn)=>promise.then(fn),Promise.resolve(x)) 7 } 8 let addTen = compose(addTwo,addThree,addFive) 9 addTen(8).then(console.log)//18
期约扩展
ES6期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库实现中具备而ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。
期约取消
我们经常会遇到期约正在处理过程中,程序却不再需要其结果的情形。这时候如果能够取消期约就好了。某些第三方库,比如Bluebird,就提供了这个特性。实际上,TC39委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果,ES6期约被认为是“激进的”:只要期约的逻辑开始执行,就没有办法阻止它执行到完成。
实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到KevinSmith提到的“取消令牌(cancel token)”。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。
下面是CancelToken类的一个基本实例:
1 class CancelToken{ 2 constructor(cancelFn){ 3 this.promise = new Promise((resolve,reject)=>{ 4 cancelFn(resolve); 5 }) 6 } 7 }
这个类包装了一个期约,把解决方法暴露给了cancelFn参数。这样,外部代码就可以向构造函数中传入一个函数,从而控制什么情况下可以取消期约。这里期约是令牌类的公共成员,因此可以给它添加处理程序以取消期约。