读《你不知道的JavaScript 中》-异步【3】Promise
在读《你不知道的JavaScript 中》-异步【2】回调中就已经明确提过,回调有一个缺陷是控制反转导致的信任问题。具体的场景就是在回调函数里面直接调用第三方,接着期待函数能够调用回调,实现第三方提供的功能,这种简单的回调所表达的意思是:这(回调)是将来要做的事,在当前步骤完成后才能发生的。
由于对第三方极度依赖,所以相互之间的信任关系就极为重要,当第三方呈现主导地位,有很大的控制权,那之后就很可能导致控制反转,让我们自身要实现的功能被第三方左右甚至篡改。要解决控制反转的问题,最好的途径就是自己重新拿回控制权,所以我们不要把自己不要直接把自己接下来要做的事情传给第三方,而是让第三方告诉我们,这个任务要什么时候才能结束啊?那样我们就能在任务结束的时候自己决定下一步要做什么。我自己想了一个例子来解释这个过程:
小明跟小红借了100块钱,如果没在规定日期之前还上的话,就要额外追加钱,假如使用回调的话,就是这个情形:小明跟小红借完钱之后就说,等到你让我还钱那天,我就把攒的手办出售了,赚的钱用来还给你(任务结束时要执行的事情),那小红听完就动坏心思了。没过几天,小红让小明还钱,小明就按照承诺去卖手办了,结果所有人都跟他砍价,都知道小明急用钱会便宜卖了手办的,原来这是小红跟大家说的,让大家趁机砍价这样小明就很有可能还不上钱,最后小明卖光了手办也凑不齐钱,小红的诡计就得逞了。假如小明为了消除控制反转的问题,只问小红:你让我哪天还你钱,小红说十天后,小明应允后什么都没说(只问了第三方任务结束的时间)。十天后,小明卖了一部分手办凑齐了钱,还了小红的钱。
这种控制反转再反转回自身的做法,就减轻了对第三方的依赖程度,称为Promise。
1.现在值与将来值
当写代码要得到一个数学运算的值时,已经对参与数学运算的变量做了一个假设:它们是一个具体的现在值,如:
var x, y = 2;
console.log(x + y);
运算 x + y 是假定 x 和 y 都已经设定了(已决议),但可能有时 x 和 y 并没有准备好值,设想通过一种方式表达:把 x 和 y 加起来,但如果它们中的任意一个还没有准备好值,就等待二者都准备好,一旦二者都把值准备好了就马上执行加运算。初步使用 回调来实现:
function add(getX,getY,cb) {
var x, y; getX( function(xVal){ x = xVal; // 两个都准备好了? if (y != undefined) { cb( x + y ); // 发送和 } } ); getY( function(yVal){ y = yVal; // 两个都准备好了? if (x != undefined) { cb( x + y ); // 发送和 } } ); } // fetchX() 和fetchY()是同步或者异步函数 add( fetchX, fetchY, function(sum){ console.log( sum ); } );
可以看到,getX() 和 getY() 为了获取现在值和未来值,将二者都变成了未来值,所有的操作都是异步的。这个基于回调的方法有很多不足,就比如没有对获值错误的处理。
接下来通过 Promise 来表达这个例子:
function add(xPromise,yPromise) { // Promise.all([ .. ])接受一个promise数组并返回一个新的promise, // 这个新promise等待数组中的所有promise完成 return Promise.all( [xPromise, yPromise] ) // 这个promise决议之后,我们取得收到的X和Y值并加在一起 .then( function(values){ // values是来自于之前决议的promisei的消息数组 return values[0] + values[1]; } ); } // fetchX()和fetchY()返回相应值的promise,可能已经就绪, // 也可能以后就绪 add( fetchX(), fetchY() ) // 我们得到一个这两个数组的和的promise // 现在链式调用 then(..)来等待返回promise的决议 .then( function(sum){ console.log( sum ); // 这更简单! },// 拒绝处理函数 function(err) { console.error( err ); // 烦! } );
代码中有两层 Promise
fetchX() 和 fetchY() 是直接调用的,它们的返回值被传给 add(),这些 promise 代表的底层值的可用时间可能是现在或将来,但 promise 保证了行为的一致性,我们可以不依赖时间的方式追踪值 X 和 Y。
第二层是通过 Promise.add([...]) 创建并返回的 promise,通过调用 then(...) 来等待这个 promise。
通过这个例子可以粗略看出,promise 封装了依赖于时间的状态——等待底层值的完成或拒绝,所以 promise 本身与时间无关,因此 promise 按照可预测的方式组成,而不用关心时序或底层的结果。另外,一旦 promise做出决议,就会永远保持这个状态,变成不变值,可以重复查看。
由于 promise 决议后是外部不可变值,所以可以把这个值传递给第三方,并且能确信它不会被修改,特别是针对于多方查看同一个 promise 决议的情况,一方不可能影响另一方对 promise 决议的观察结果,这个 promise 设计中最基础最重要也最强大的一个概念,这就让 promise 成为了一种封装和组合未来值的易于复用的机制。
看到了3.2 节 具有then方法的鸭子类型,上面写着:如何确定某个值是不是真正的 Promise是很重要的,虽然 Promise 是通过 new Promise(...) 语法来创建的,但它却不可以用 p instanceof Promise 来检查,原因有很多,其中最主要的是: Promise 值可能是从其他浏览器窗口(iframe)接收到的,这个浏览器窗口的 Promise 可能和当前窗口的不同,因此这样的检查无法识别 Promise 实例。至于如何检测 Promise 是否是真正的 Promise,暂且按下不表。
3.3 Promise信任问题
之前利用回调来实现异步时,把回调传入工具时可能会以下问题:
调用回调过早;
调用回调过晚(甚至不被调用)
调用回调次数过多或过少
未能传递所需的环境和参数
吞掉了可能出现的错误和异常
解决调用过早:就算是 Promise 立马就完成了,也不会被同步观察到的,就是说,对 Promise 调用 then(...) 时,即使 Promise 已经决议,提供给 then(...) 的回调也会被异步调用。所以 Promise 会自动阻止 Zalgo 出现。
解决调用过晚:Promise 的 then(...) 注册的观察回调会被自动调度,这些被调度的回调在下一个异步事件点上一定会被触发。一个 Promise 决议后,这个 Promise 上所有的通过 then(...) 注册的回调都会在下一个异步时机点上被依次立即调用(有点类似 BFS),这些回调中的任意一个都无法影响或延误对其他回调的调用。举个例子:
p.then( function(){ p.then( function(){ console.log( "C" ); } ); console.log( "A" ); } ); p.then( function(){ console.log( "B" ); } ); // 输出结果为A B C 我的理解是按照层级来运行的,第一层是A B,第二层是C
解决回调未调用:Promise 无论完成还是失败都会向你通知它的决议,如果 Promise 本身永不被决议,那它提供地解决方案是:一种竞态的高级抽象机制,给出了以下的示范样例,这种竞态机制目前就我理解而言,是希望能用 setTimeout来控制住 Promise的决议时间,但其实本身 setTimeout 就并不准时,感觉会有更好的写法:
// 用于超时一个Promise的工具 function timeoutPromise(delay) { return new Promise( function(resolve,reject){ setTimeout( function(){ reject( "Timeout!" ); }, delay ); } ); } // 设置foo()超时 Promise.race( [ foo(), // 试着开始foo() timeoutPromise( 3000 ) // 给它3秒钟 ] ) .then( function(){ // foo(..)及时完成! }, Promise | 193 function(err){ // 或者foo()被拒绝,或者只是没能按时完成 // 查看err来了解是哪种情况 } );
解决调用次数过少或过多:每个 Promise 正常被调用的次数应该为1,所以调用过少就相当于未被调用,这种情况上述已经阐述过了;调用过多的情况不会出现,因为 Promise 的定义方式使得它只能被决议一次,试图多次调用 resolve() 或者 reject() 的话,那 Promise 只会接受第一次决议并忽略后续调用,除非你是把同一个回调注册了不止一次(比如写了p.then(f); p.then(f)),这时它的调用次数就会和注册次数一样。
解决未能传递参数/环境值:Promise 最多有一个决议值(完成或者拒绝),如果没有任何值显式表决议,那么这个值就是 undefind。如果使用了多个参数调用 resolve() 或 reject(),第一个参数之后的所有参数都会被忽略,如果真的要传递多个值,就把它们封装在对象或数组中。
解决吞掉错误异常的问题:如果拒绝一个 Promise 并给出出错消息,那么这个值就会被传给reject()。Promse 甚至把 JavaScript 异常也变成了异步行为。
3.4 链式流
可以把多个 Promise 连接到一起用以表示一系列异步,能这么做的原因在于 Promise 的两个固有行为特性:
1.每次对 Promise 调用 then(...),它都会创建并返回一个新的 Promise (跟链表类似)
2.不管从 then() 调用的完成回调(即第一个函数)返回的值是什么,都会被自动设置为被链接 Promise() 的完成
比如下面这个例子:
var p = Promise.resolve( 21 ); p .then( function(v){ console.log( v ); // 21 // 用值42完成连接的promise return v * 2; } ) // 这里是链接的promise .then( function(v){ console.log( v ); // 42 } );
使 Promise 序列能够在每一步有异步能力的关键是:当传递给 Promise.resolve() 的是一个 Promise 或 thenable 而不是最终值时的运行方式。Promise.resolve() 会直接包裹成一个 Promise 或者展开接收到的 thenable 值,并在持续展开 thenable 的同时递归地前进。从完成(或拒绝)处理函数返回 thenable 或者 Promise 的时候也会发生同样的展开,比如下面这个例子:
let p = Promise.resolve(21); p.then( function(v) { console.log(v); //21 //创建一个promise并将其返回 return new Promise( function(resolve, reject) { resolve(v * 2);//用42填充 } ); } ).then( function(v) { console.log(42); //42 } )
虽然把 42 封装到了返回的 Promise 中,但它仍然后被展开并最终成为链接的 promise 决议,因此第二个 then() 得到的仍然是 42,如果向封装的 promise 引入异步,一切都会正常工作。
var p = Promise.resolve( 21 ); p.then( function(v){ console.log( v ); // 21 // 创建一个promise并返回 return new Promise( function(resolve,reject){ // 引入异步! setTimeout( function(){ // 用值42填充 resolve( v * 2 ); }, 100 ); } ); } ) .then( function(v){ // 在前一步中的100ms延迟之后运行 console.log( v ); // 42 } );
构建的 Promise 链不仅是一个表达多步异步序列的流程控制,还是一个消息通道(从一个步骤到下一个步骤传递消息),如果这条 Promise 链中的某个步骤出错了,由于错误和异常是基于每个 Promise 的,这意味着可能在链的任意位置捕捉到这样的错误,一旦错误被捕捉了,相当于这条链恢复了正常运作。
// 步骤1: request( "http://some.url.1/" ) // 步骤2: .then( function(response1){ foo.bar(); // undefined,出错! // 永远不会到达这里 return request( "http://some.url.2/?v=" + response1 ); } ) // 步骤3: .then( function fulfilled(response2){ // 永远不会到达这里 }, // 捕捉错误的拒绝处理函数 function rejected(err){ console.log( err ); // 来自foo.bar()的错误TypeError return 42; } ) // 步骤4: .then( function(msg){ console.log( msg ); // 42 } );
第2步出错后,第3步的拒绝处理函数会捕捉到这个错误,拒绝处理函数的返回值(例子中为42),如果含有的话,就会用来完成交给下一个步骤(第4步)的 Promise,这样错误就被捕捉了,Promise 链就回到了完成状态。
当从完成处理函数返回一个 promise 时,它会被展开并有可能延迟下一个步骤,从拒绝处理函数返回 promise 也是如此,因此如果在第3步返回的不是42而是一个promise的话,这个 promise 可能会延迟第4步。调用 then() 时的完成处理函数或拒绝处理函数如果抛出异常,都会导致 promise 链中的下一个 promise 因为这个异常而被立即拒绝。
那么假如我不传拒绝处理函数的话,要是出错了,promise就不处理了么?不是的,假如调用 promise 的 then(),并且只传入了一个完成处理函数,那么一个默认的拒绝处理函数就会顶替上来,如下例:
var p = new Promise( function(resolve,reject){ reject( "Oops" ); } ); var p2 = p.then( function fulfilled(){ // 永远不会达到这里 } // 假定的拒绝处理函数,如果省略或者传入任何非函数值 // function(err) { // throw err; //
但是附带的默认拒绝处理函数只是把错误重新抛出,这最终会使得整条 promise 链用同样的错误理由拒绝。从本质上来说,这使得错误可以继续沿着 promise 链传播下去,直至遇到显式定义的拒绝处理函数。
那么,假如只传错误处理函数而没传完成处理函数呢?一样的,还是会有作为替代者的一个默认处理函数,如下:
var p = Promise.resolve( 42 ); p.then( // 假设的完成处理函数,如果省略或者传入任何非函数值 // function(v) { // return v; // } null, function rejected(err){ // 永远不会到达这里 } );
默认的完成处理函数只是把接收到的任何传入值传递给下一个 promise 而已。像上面这个模式:then(null, function(err){...})——只处理了拒绝下的情况,但又要把完成值传递下去,其实有个缩写形式的API可以代替,称为 catch(function(err){...}),先按下不表。
总结一下让链式流程控制可行的 Promise 固有特性:
1.调用 Promise 的 then() 会自动创建一个新的 Promise 从调用返回。
2.在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的可链接 Promise 就执行相应的决议。
3.在完成或拒绝处理函数内部,如果返回一个 Promise,那么它会被展开,这个 Promsie 的决议值就会成为当前 then() 返回的下一个链接 Promise 的决议值。
3.5 错误处理
常用的错误处理是try...catch结构,但它只能处理同步代码,无法用于异步代码模式。比如下面这个例子:
function foo() { setTimeout( function(){ baz.bar(); }, 100 ); } try { Promise | 207 foo(); // 后面从 `baz.bar()` 抛出全局错误 } catch (err) { // 永远不会到达这里 }
在回调中,已经出现了一些模式化的错误处理方式,比如 error-first 风格:
function foo(cb) { setTimeout( function(){ try { var x = baz.bar(); cb( null, x ); // 成功! } catch (err) { cb( err ); } }, 100 ); } foo( function(err,val){ if (err) { console.error( err ); } else { console.log( val ); // 输出值 } } );
只有在 baz.bar() 调用会同步地立即成功或失败的情况下,如果 baz.bar() 本身有异步完成函数,其中的任何异步错误都无法捕捉到。
3.6 Promise模式
Promise链的顺序模式(this-then-this-then-that流程控制),基于 Promise 构建的异步模式抽象还很多变体,模式都是为了简化异步流程控制,使得代码更容易追踪和维护。原生 ES6 Promise 实现中支持两个模式,可以用它们构建其他模式的基本块。
3.6.1 Promise.all([...])
在异步序列 Promise 链中,任意时刻都只能有一个异步任务正在执行,如果想要同时执行两个或更多步骤(“并行执行”),就可以用 Promise 的一个 API——all([...])。
如果想要同时发送两个 Ajax 请求,等它们不管以什么顺序全部执行完成后,再发送第三个 Ajax 请求,可以用如下写法:
// Promise-aware ajax function request(url) { return new Promise( function(resolve,reject){ // ajax(..)回调应该是我们这个promise的resolve(..)函数 ajax( url, resolve ); } ); } let p1 = request('http://url1'), p2 = request('http://url2'); Promise.all([p1, p2]) .then( function(msg) { // p1 和 p2 完成,并把它们的消息传给下一个 Promise return request('http://url3?v=' + msg.join(',')); }) .then( function(msg) { console.log(msg); })
Promise.all([...]) 参数是一个数组,通常由 Promise 实例组成,从 Promise.all([...]) 调用返回的 promise 会收到一个完成消息,这是由所有参数传入的 Promise 的完成消息组成的数组,与指定的顺序一致,但与完成顺序无关。
严格来说,传给 Promise.all([...]) 的数组值可以是 Promise,thenable,甚至是立即值,因为之前也讲过,如果不是 Promise 的话,也会通过 Promise.resovle() 来过滤,以确保要等待的是一个真正的 Promise,所以立即值会被规范化为这个值构建的 Promise,如果数组是空的,主 Promise 就会立即完成。
从 Promise.all([...]) 返回的主 Promise 会在所有的成员 Promise 都完成后才会完成,如果有任意一个成员 Promise 被拒绝的话,主 Promise.all([...]) 就会立即被拒绝,并丢弃来自其余所有 promise 的全部结果。所以要为每个 promise 关联一个拒绝处理函数,特别是从 Promise.all([...]) 返回的那个 Promise。
3.6.2 Promise.race([...])
如果只需要响应“第一个跨过终点线的 Promise”,抛弃其他的 Promise,那么就使用 Promise.race([...]) 模式,这在 Promise 中被称为竞态。
Promise.race([...]) 接收的是单个数组参数,这个数组由一个或多个 Promise、thenable、立即值组成,但是在这种模式下还使用立即值是没有意义的,就好比它一开始就站到了终点线那样的毫无意义。
一旦有任何一个 Promise 决议是完成,Promise.race([...]) 就会完成;一旦有任何一个 Promise 决议是拒绝,Promise.race([...]) 就会拒绝。
Promise.race([...]) 的参数数组至少需要一个元素,所以如果传入的是空数组,主race的 Promise 永远不会决议,而不是立即决议,记住:永远不要传递空数组!
再利用下 Promise.all([...]) 的那个例子,不过这次的 p1 和 p2 是竞争关系:
// Promise-aware ajax function request(url) { return new Promise( function(resolve,reject){ // ajax(..)回调应该是我们这个promise的resolve(..)函数 ajax( url, resolve ); } ); } let p1 = request('http://url1'), p2 = request('http://url2'); Promise.all([p1, p2]) .then( function(msg) { // p1 和 p2 完成,并把它们的消息传给下一个 Promise return request('http://url3?v=' + msg; }) .then( function(msg) { console.log(msg); })
因为最终只有一个 Promise 能取胜,所以完成值是单个消息,不用像 Promise.all([...])那样处理返回的数组。
3.7 Promise API 概述
3.7.1 new Promise(...)构造器
在 new Promise 时需要接受两个函数回调,用来支持 promise 的决议,通常称这两个函数为 resovle() 和 reject(),模板如下:
var p = new Promise( function(resolve, reject) { //resolve()用于决议(可能完成也可以拒绝) / 完成这个 promise //reject()用于拒绝这个 promise }) ;
reject() 是单纯拒绝这个 promise,但 resolve() 既可能完成 promise,也可能会拒绝,要根据传入的参数来决定。再次强调resolve() 接受参数的问题:如果传入的是一个非 Promise、非 thenable的立即值,那这个 promise 就会用这个值完成;如果的是一个真正的 Promise 或者 thenable 值,这个值就会被递归展开,并且要构造的 promise 将取用它的最终决议值或者状态。
3.7.2 Promise.resolve() 和 Promise.reject()
创建一个已被拒绝的 Promise 的方式是 Promise.reject(),以下两个promise 是等价的:
------------------------------------------------------
var p1 = new Promise( function(resolve, reject) {
reject("Oops");
})
var p2 = Promise.reject("Oops");
------------------------------------------------------
Promise.resolve() 常用于创建一个已完成的 Promise,但是它也可以展开 thenable 值,此时 Promise 采用传入的这个 thenable 的最终决议值,可能是完成,也可能是拒绝:
var fulfilledTh = { then: function(cb) { cb( 42 ); } }; var rejectedTh = { then: function(cb,errCb) { errCb( "Oops" ); } }; var p1 = Promise.resolve( fulfilledTh ); var p2 = Promise.resolve( rejectedTh ); // p1是完成的promise // p2是拒绝的promise
3.7.3 then() 和 catch()
每个 Promise 实例都有 then() 和 catch()方法,通过这两个方法可以为这个 Promise 注册完成和拒绝处理函数。Promise 决议之后会立即调用二者之一,但不会两个都调用,而是异步调用。
then() 接受一个或两个参数,其一用于完成回调,其二用于拒绝回调,如果某一个被省略或者是非法值就会替换为默认回调。默认完成回调函数只是把接收到的消息传递下去,而默认拒绝回调函数只是重新抛出出错原因。所以其实如果把 then()的第一个参数置为 null,就相当于 catch()。then() 和 catch() 也会创建并返回一个新的 promise,可以用于实现 Promise链式流程控制。
3.7.4 Promise.all([...]) 和 Promise.race([...])
对于 Promise.all([...]) ,只有传入的所有 promise 都完成,才会得到一个数组(包含传入的所有 promise 完成值),如果有任意 promise 被拒绝,返回的主 promise 会被拒绝并抛弃其他 promise 的结果。
对于 Promise.race([...]),只要第一个promise决议了(不管是完成还是失败),它的决议结果就成为返回的 promise 决议。
注意:如果向 Promise.all([...]) 传入空数组,它会立即完成;如果向 Promise.race([...]) 传入空数组,它会挂住,永远不会决议。
3.8 Promise 局限性
3.8.1 顺序错误处理
Promise 的链接方式导致链中的错误很容易被忽略掉,而且,由于一个 Promise链仅仅是连接到一起的成员 Promise,没有把整个链标识为一个实体,所以没有外部方法可以用于观察可能发生的错误。如果构建了一个没有错误处理的 Promise 链,链中任何地方的错误都会一直在链中传递下去。
3.8.2 单一值
Promise 只能有一个完成值或者一个拒绝理由,在复杂场景中,这个设定就是个局限。一般的做法是构建一个对象或者数组来保持这样的多个信息,虽然可行,但是要在 Promise 链中的每一步都进行数组 / 对象封装和解封,会很笨重。
1.分裂值
2.展开/传递参数(利用了ES6中的解构)
3.8.3 单决议
Promise 一个本质特征是只能被决议一次(完成或者拒绝),一旦决议不可更改。但假如是一种类似于事件流或数据流的模式,就不太适用Promise了。
3.8.4 惯性
由于之前项目大量使用回调,所以其实现存的很多代码都不理解Promise,但多数库都提供了日常需要的支持,也可以自己构建辅助函数,所以解决Promise的这个限制不需要太多代价。
3.8.5 无法取消的Promise
一旦创建了Promise并且注册了完成和拒绝处理函数后,出现任何情况也无法从外部停止Promise。单独的一个Promise并不是一个流程控制机制,但是集合在一起的Promise链就是一个流程控制,将取消Promise定义在这个层次上更合适。所以单独的Promise不应该能被取消,但是取消一个Promise链式合理的。
3.8.6 Promise性能
如果把基于回调的异步任务链和Promise链进行比较,由于Promise进行的动作再多一些,意味着它也会稍慢,但二者难以去权衡比较。Promise的性能局限在于它们没有真正提供可信任性保护支持的列表。