本文作者就是我,简书的microkof。如果您觉得本文对您的工作有意义,产生了不可估量的价值,那么请您不吝打赏我,谢谢!
名词约定
Promises的概念是由CommonJS小组的成员在Promises/A规范中提出来的。一般来讲,有以下的名词约定:
promise(首字母小写)对象指的是Promise实例对象
Promise首字母大写且单数形式,表示Promise构造函数
Promises首字母大写且复数形式,用于指代Promises规范
Promises/A规范和ES6 Promises规范
Promise规范有几次升级,目前来讲,Promise/A是最新的民间规范,ES6 Promises是最新的官方规范,只需知道ES6 Promises规范是你应该遵守的标准就行了。
为什么有Promises这个东西
- 解决回调金字塔的问题,回调金字塔也叫回调圣诞树,好听吧?还有个别名叫回调地狱,不好听了吧?到底可怕不可怕?非常的可怕。
- 可以同时管理成功回调和失败回调。
名词解释:同步任务和异步任务
- 同步任务:只需JS引擎自身就可以完成的任务,叫同步任务。
- 异步任务:JS引擎自身无法完成,需要外力协助的任务,叫异步任务;还有一种是由JS引擎能够独立完成,但是JS引擎把任务分成了两段,第二段当做回调,也是异步任务。
简单理解JS引擎的执行机制的话,异步任务分为三个执行段,对JS引擎来讲是执行第一和第三个阶段:
- 异步任务本体执行:由JS引擎同步执行
- 异步任务外力执行:由外力执行
- 异步任务回调执行:由JS引擎异步执行
所谓“承诺”
Promise这个单词的意思是“承诺”,就是我们日常说的“我肯定帮你买早饭”、“你如果交了钱我肯定给你一杯咖啡”。
在程序世界,举例可以说:“我承诺给你完成这些代码执行”。new一个Promise实例,就是JS引擎对你做了一个承诺。
既然是承诺,就肯定有成功的时候有失败的时候,比如我帮你买早餐,结果今天煎饼果子没出摊,或者是我走到半路上,煎饼果子的塑料袋破裂,煎饼果子滑落到了地上,这就是失败。就连“我们承诺绝不首先动用核武器”都有坚持不下去的时候,所以只要是承诺就有成功和失败,只不过是概率问题。
到程序世界,一个承诺也会有三种状态,就是“未决的”、“成功的”、“失败的”三种状态。也就是pending/resolved/rejected三种状态。
Promise构造函数的超能力
Promises写法的本质就是把异步写法撸成同步写法。要做这么酷炫这么变态的事情,当然需要Promise构造函数有超能力,它的超能力就是传入Promise构造函数的函数参数会第一优先执行,无论这个函数多么的繁复,有多少层回调,有多少秒的计数器,统统都会最优先执行,也就是说,我们只要new了一个Promise(),那么Promise构造函数的函数参数其实是同步代码,但是.then比较特殊,.then会等到promise对象实例有了结果(resolved或者rejected),.then()里面代码才会执行。链条上的每一个.then都会等前面的promise有了结果才会执行,Promise构造函数的这个超能力是Promises系统的威力之源。(当然,这里说的执行优先级,是在理想环境下,所谓理想环境也就是全部执行代码只由new Promise()和它的一系列.then()方法组成。如果方法链之外还有其他代码,那么整体代码执行的先后顺序就复杂化了,涉及到ES最底层的Event Loop,下文有介绍。然而,Promise加它的then方法链已经提供了梳理代码执行顺序的整套方案,如果在方法链之外还写异步代码的话属于不鼓励的写法,应该尽量避免这么做。)
实现了Promise/A规范的浏览器
简单说,IE全不支持,Edge支持,Chrome和Firefox在十几个版本之前就已经接近支持,目前的最新版已经全面支持。
所以,IE8-10可以考虑Promise/A的Polyfill库:
jakearchibald/es6-promise
一个兼容 ES6 Promises 的Polyfill类库。 它基于 RSVP.js 这个兼容 Promises/A+ 的类库, 它只是 RSVP.js 的一个子集,只实现了Promises 规定的 API。
yahoo/ypromise
这是一个独立版本的 YUI 的 Promise Polyfill,具有和 ES6 Promises 的兼容性。 本书的示例代码也都是基于这个 ypromise 的 Polyfill 来在线运行的。
getify/native-promise-only
以作为ES6 Promises的polyfill为目的的类库 它严格按照ES6 Promises的规范设计,没有添加在规范中没有定义的功能。 如果运行环境有原生的Promise支持的话,则优先使用原生的Promise支持。
其他还有很多Polyfill类库,不多说,可以github一下。
基本用法
案例1:现在开始,延迟3秒,执行console.log('第一个回调')
,然后定义一个变量a,值是3,然后再延迟2秒,执行console.log('第二个回调')
,然后执行console.log(a * 2)
。回调圣诞树型写法是:
setTimeout(function() {
console.log('第一个回调');
var a = 3;
setTimeout(function() {
console.log('第二个回调');
console.log(a * 2);
}, 2000);
}, 3000);
执行上面代码,结果是:先延迟3秒,然后浏览器打印了一个第一个回调
字符串,然后又延迟2秒,然后浏览器打印了一个第二个回调
字符串,以及打印了一个6
数字。
然后遵循Promises的写法是:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一个回调');
var a = 3;
if ( true ){
resolve(a);
} else {
reject('bu ok');
}
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二个回调');
console.log(value * 2);
}, 2000);
}, function(error) {
console.log(error);
});
执行结果跟圣诞树写法的结果完全一致。
Promise是一个构造函数,用来生成promise实例。Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。
promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved
(已完成)和Rejected(已失败)。
- resolve函数的作用是,将promise对象的状态从“未完成”变为“成功”(即从Pending变为Resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。
- reject函数的作用是,将promise对象的状态从“未完成”变为“失败”(即从Pending变为Rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
promise对象生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。
then方法可以接受两个回调函数作为参数。第一个回调函数是promise对象的状态变为Resolved时调用,第二个回调函数是promise对象的状态变为Reject时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受promise对象传出的值作为参数。
然后这里你可能会问,if ( true ){}
是什么鬼?因为console.log('第一个对象');var a = 3;
是不可能失败的,这都能失败的话,等于js引擎挂了,也等于页面挂了。所以我只能模拟成功和失败状态,现在你把true
改成false
试试,那么,延迟2秒之后是不是打印了bu ok
?
比较两种写法的区别,首先就可以看出Promises写法的代码多了很多。如果你确定promise对象根本不可能有失败的状态,可以省掉reject函数以及错误回调。那么可以简写成这样:
var promise = new Promise(function(resolve) {
setTimeout(function() {
console.log('第一个回调');
resolve(3);
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二个回调');
console.log(value * 2);
}, 2000);
});
上面代码就是只考虑promise对象“成功”的可能性,不考虑失败的可能性。
去掉promise对象失败的可能性之后,你可能继续会说,“Promises写法的代码还是多!”没错,确实多,但不要只看劣势不看优势,没有优势的东西,是没人会用的。假设回调多起来了,比如至少5个,而且每一步回调都有成功和失败状态,那么Promises的优势才能显现出来。
案例2:在案例1的基础上,再延迟1秒,执行console.log('第三个回调'); console.log(value * 2);
,也就是说,我想在第二步的输出值的基础上再乘以2,也就是想得到12。代码如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一个回调');
resolve(3);
}, 3000);
});
promise.then(function(value) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第二个回调');
console.log(value * 2);
resolve(value * 2);
}, 2000);
});
}).then(function(value) {
setTimeout(function() {
console.log('第三个回调');
console.log(value * 2);
}, 1000);
});
结果没问题:
这里用到了then
的链式调用。你会发现第一个then
返回了一个promise对象。这就跟案例1不一样了,案例一的then
里没有再返回promise对象。必须返回么?看案例3。
案例3:跟案例2相似,只是执行console.log('第二个回调')
这步不用延迟。代码如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一个回调');
resolve(3);
}, 3000);
});
promise.then(function(value) {
console.log('第二个回调');
console.log(value * 2);
return value * 2;
}).then(function(value) {
setTimeout(function() {
console.log('第三个回调');
console.log(value * 2);
}, 1000);
});
跟案例2的代码的区别是什么?第一个then
方法中,没了return promise对象
,取而代之的是return value * 2
,为什么?
因为案例2中,三个then
的回调函数是异步-异步-异步,案例3中,是异步-同步-异步,这区别很大。简单情况下,then
方法中的回调执行代码是同步代码,这样只需要简单return一下参数,就可以把参数传递下去。复杂情况下,是异步-异步-异步这种情况,如果依然简单的在setTimeout
的回调里return一下参数,你会发现,参数根本没有及时传递。代码如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一个回调');
resolve(3);
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二个回调');
console.log(value * 2);
return value * 2;
}, 2000);
}).then(function(value) {
setTimeout(function() {
console.log('第三个回调');
console.log(value * 2);
}, 1000);
});
结果就神奇了:
什么原因?怎么理解?
简单说原因是:
then
的链式执行,理想情况下是基于每个then的前一个then能够返回promise对象。不理想情况下是没有返回promise对象,这种情况下,虽然then的链式执行依然可以执行,但是,每个then
只可能等前一个then
的同步代码完成,不会等前一个then
的异步代码完成。第一个then
的同步代码是一个计时器,开始计时就算完成了,然后第二个then
什么也没得到,其实是得到了一个undefined
,undefined * 2
得到NaN
,所以打印NaN
。
为啥第一个then
的回调用return promise对象,第二个then
就可以等那2秒的延迟呢?用上段文字就很好理解了,而且你还可以回忆一下本文最上方说的promise对象的超能力,当你给then
返回一个非promise对象,then
只接收同步的返回值,反之,当你给then
返回一个promise对象,那么then
就等待promise对象生成,然后等resolve
和reject
传递参数,等多久都能等。
这里要注意一下,我上段文字所说的“等待”,其实并不是等待,而是new一个promise对象的过程被js引擎视为同步任务执行,因此new出了promise对象,并return的过程,其实是同步代码,then其实并不是在等待,而是非常自然的链式执行顺序。
为什么第三个回调比第二个回调先执行了?因为第二个then
得到了undefined
之后,第三个then就开始了,第三个回调延迟的时间短嘛,就1秒,所以比第二个回调先执行了。
为啥“第二个回调”下面输出是6?因为3 * 2得6。
总之,如果想异步-异步-异步-异步......这样一直搞下去,就只能是每一步都给下一步返回一个promise对象。
解答几个问题:
如果
resolve
或reject
语句后面还写了语句,会执行吗?
会。resolve
或reject
负责传参,但不是说传了参就中止执行了。
如果第一个
then
的回调用了promise对象,但是promise对象没写resolve
或reject
方法,第二个then
的回调还会执行么?
答案是不执行,等于第二个then
白写了,因为promise对象永远处于Pending状态。如果后面有第三个then
,依然不会执行。等于链条从第一个promise对象就断了。
如果有
resolve
或reject
方法,但是不设参数,也就是resolve()
或reject()
,那么then
会执行吗?
会。resolve
或reject
传的参数是undefined
。
如果第一个
then
的第一个回调函数没执行,第二个then
的第一个回调函数会执行么?
会。第二个then
的第一个回调,并不是前一个then
的第一个回调的继承。
每个then
的2个回调,只可能有其中一个回调执行。下一个then
的第一个回调,只会看前一个then
的任意回调是否返回成功的promise;下一个then
的第二个回调,只会看前一个then
的任意回调是否返回失败的promise。
如果promise只执行了
reject
方法,但第一个then
没有写对应的error处理回调,第二个then
写了,还能处理么?
能。Promises规定,参数可以无限制的顺着链条传递下去直到被处理掉。
如果流程是异步-同步-同步-同步.....这么走下去,那么搞一串
then
还有意义吗?所有同步合在一起岂不是更容易编写?也容易理解?
答案:如果真的是一串的同步,当然可以合并了。Promises的用武之地在于全异步或者异步-同步互相夹杂的情况。
then
的回调的优先级有多高?
测试一下:
console.log('sync1');
setTimeout(function() {console.log('setTimeout1')}, 0);
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {console.log('setTimeoutPromise')}, 0);
console.log('promise');
resolve();
});
promise.then(function() {
setTimeout(function() {console.log('setTimeoutThen-1')}, 0);
console.log('then-1');
}).then(function() {
setTimeout(function() {console.log('setTimeoutThen-2')}, 0);
console.log('then-2');
});
setTimeout(function() {console.log('setTimeout2')}, 0);
console.log('sync2');
会得到
sync1
promise
sync2
then-1
then-2
setTimeout1
setTimeoutPromise
setTimeout2
setTimeoutThen-1
setTimeoutThen-2
这里就要科普一下ES的Event Loop,Event Loop简单说就是ES为了高效解决异步任务而制定的一套规则,它的基本含义这里不讲,可以自行网上搜索,也可以参考https://segmentfault.com/a/1190000016278115这篇文章,这里只摘抄结论:
ES的引擎里有2个队列:
一个叫宏队列,macrotask,也叫tasks。 一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:
- setTimeout
- setInterval
- setImmediate (Node独有)
- requestAnimationFrame (浏览器独有)
- I/O
- UI rendering (浏览器独有)
另一个叫微队列,microtask,也叫jobs。 另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:
- process.nextTick (Node独有)
- Promise
- Object.observe
- MutationObserver
那么,ES整个的任务队列的执行机制就是:
- 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);
- 全局Script代码执行完毕后,调用栈Stack会清空;
- 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
- 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;
- microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;
- 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行;
- 执行完毕后,调用栈Stack为空;
- 重复第3-7个步骤;
- 重复第3-7个步骤;
......
看到了吧,结论是什么?结论是:微队列全部执行完,才执行宏队列的第一个任务,执行完宏队列的第一个任务之后,又去查看微队列是否有任务,如果有,则全部执行,然后再看宏队列的第一个任务。(宏队列说:卧槽,歧视我?)
结合到现在的例子,new Promise()的内容是同步代码,.then()是异步的,而且是微队列的,优先级高,setTimeout属于宏队列的,优先级低。.then方法的回调里面的同步任务,优先级肯定比new Promise()外面的setTimeout任务的优先级高;而.then方法的回调里面的setTimeout任务,在宏队列里面没有排第一,所以优先级比new Promise()外面的setTimeout任务的优先级低,因为外面的setTimeout任务在宏队列里排第一。
所以,从p对象赋值语句开始,JS引擎的执行顺序是:
new Promise()内的同步任务
-> 外部下方的同步任务
-> .then链里的所有回调里的同步任务
-> 外部的宏队列任务的回调,加上new Promise()内的宏队列任务的回调,按回调时间依次执行,如果时间一致,按照书写顺序定
-> .then链里的所有回调里的回调任务
由此可以看出,如果在then链条之外还写代码的话,优先级会比较混乱,就像本文开头说的,在then链条外面写代码不是一个好主意,应该由new Promise()和then链条统一管理本次需要执行的所有代码,否则优先级不容易把控。
Promise.prototype.catch()
.catch()
是什么?它是.then()
的一个子集,也就是说专用于接收promise对象的reject()
传过来的error参数的。其他没什么特别的。也就是说,.then()
可以有两个回调函数,.catch()
只有一个回调函数。永远尽量在能用.catch()
的场合全用.catch()
。
.catch()
可以链式调用么?
可以。
可以跟
.then()
混合链式么?
可以。
如果上一层的
.then()
没有reject,.catch()
会执行吗?
不会,会被JS引擎跳过。
.catch()
下一层如果是.then()
,会执行吗?
会。
参数怎么传递?
这个then接收的参数是上一个then传递的值,跟上一层的catch无关。也就是说,引擎跳过不执行的代码,该怎么传递就怎么传递。
连写
.catch()
和.then(//只写一个回调)
是并列关系么?
绝对不是,是执行的前后关系。从来没有什么并列关系,只有连续的或者跳跃的前后关系。连写.then(//只写一个回调)
和.catch()
也不是并列关系。
Promise.all()
Promise.all方法用于将多个promise实例,包装成一个新的promise实例。
Promise.all()方法接受一个数组作为参数,p1、p2、p3都是Promise对象的实例,如果不是,就会先调用下面讲到的Promise.resolve方法,将参数转为Promise实例,再进一步处理。(Promise.all()方法的参数可以不是数组,但必须具有Iterator接口,且返回的每个成员都是Promise实例。)
p的状态由p1、p2、p3决定,分成两种情况。
- 只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
注意,如果p1的最后一个then的回调没有return命令,那么p1的返回值就是undefined,即使倒数第二个then的回调有返回值也没有用,只看最后一个then的回调的返回值。
再注意,组成的数组的顺序是按照.all()参数的书写顺序而定,跟谁先返回值无关。 - 只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。
下面是一个具体的例子。
var p1 = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一个回调');
reject(3);
}, 3000);
});
var p2 = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第二个回调');
reject(2);
}, 2000);
});
Promise.all([p1, p2]).catch(function(value) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第三个回调');
resolve(value * 2);
console.log(value * 2);
}, 2000);
});
}).then(function(value) {
setTimeout(function() {
console.log('第四个回调');
console.log(value * 2);
}, 1000);
});
结果是下图。由于p1和p2都rejected,所以catch捕获的参数是p2传过来的,因为p2的延迟比p1短。
如果p1的回调是同步任务,p2是异步任务,毫无疑问,catch捕获的参数会是p1传过来的,但是,通常肯定不这么用,这么写太蠢了。如果p1和p2都是同步任务?更蠢,那么你干嘛不把p1和p2写到一起呢?而且,根本不需要Promises写法。
如果p1和p2都fulfilled,那么value
是一个数组。不代码举例了。
总结:Promise.all()方法的适用场合,是多个异步任务并发执行,在最后一个任务成功完成之后,给出一个回调。比如,并发10个xhr线程,传10个文件,你并不知道哪个文件会先传完,Promise.all()方法能确保在10个文件都传完的那一刻给出完成提示。
如果不用Promise.all()方法,通常做法是设一个计数器初始值为0,每上传成功一个文件就+1,然后判断一次,看看计数器是否等于10,如果等于的话,就给出完成提示。最终相当于判断10次。
Promise.race()
Promise.race方法同样是将多个Promise实例,包装成一个新的Promise实例。
var p = Promise.race([p1,p2,p3]);
上面代码中,只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的Promise实例的返回值,就传递给p的回调函数。
注意,所谓率先改变状态,可能会改成fulfilled,也可能会改成rejected,都可以。
Promise.race方法的参数与Promise.all方法一样,如果不是Promise实例,就会先调用下面讲到的Promise.resolve方法,将参数转为Promise实例,再进一步处理。
Promise.race的使用场合不算常见,比如一款小游戏,三辆赛车比赛,任何一个车先到达终点,比赛就结束,那么可以适用于Promise.race。或者是一个躲开障碍的游戏,任何一个障碍物撞到你,游戏就结束,那么可以适用于Promise.race。
还一个场合是超时判定。比如一个ajax请求,30秒下载不下来就算失败。这样,p1是ajax请求,p2是30秒计数器,谁先完成,p的状态就随谁。那个率先改变的Promise实例的返回值,就传递给p的回调函数。
Promise.resolve()
有时需要将现有对象转为Promise对象,Promise.resolve方法就起到这个作用。
var jsPromise = Promise.resolve($.ajax('/whatever.json'));
上面代码将jQuery生成的deferred对象,转为一个新的Promise对象。
Promise.resolve方法的参数分成四种情况。
(1)参数是一个Promise实例
如果参数是Promise实例,那么Promise.resolve将不做任何修改、原封不动地返回这个实例。
(2)参数是一个thenable对象
thenable对象指的是具有then
方法的对象,比如下面这个对象。
var thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
var jsPromise = Promise.resolve(thenable);
jsPromise.then(function(value) {
console.log(value); // 42
});
(3)参数不是具有then方法的对象,或根本就不是对象
如果参数是一个原始值,或者是一个不具有then方法的对象,则Promise.resolve方法返回一个新的Promise对象,状态为Resolved,实例p向then
的回调函数传的参数就是那个原始值。
var p = Promise.resolve('Hello');
p.then(function (s){
console.log(s) // Hello
});
上面代码生成一个新的Promise对象的实例p。由于字符串Hello不属于异步操作(判断方法是它不是具有then方法的对象),返回Promise实例的状态从一生成就是Resolved,所以回调函数会立即执行。Promise.resolve()方法的参数,会同时传给回调函数。
(4)不带有任何参数
Promise.resolve()方法允许调用时不带参数,直接返回一个Resolved状态的Promise对象。实例p向then
的回调函数传的参数是undefined
。
所以,如果希望得到一个Promise对象,比较方便的方法就是直接调用Promise.resolve()方法。
var p = Promise.resolve();
p.then(function (s){
console.log(1)
});
console.log(2);
上面代码的变量p就是一个Promise对象。
Promise.reject()
Promise.reject()方法也会返回一个新的promise实例,该实例的状态为rejected
。它的参数用法与Promise.resolve()方法完全一致。
最佳实践
现在Promises规范全部介绍完了。然后就是最佳实践。
参考文档:
本文作者就是我,简书的microkof。如果您觉得本文对您的工作有意义,产生了不可估量的价值,那么请您不吝打赏我,谢谢!