我对Promises的理解

本文作者就是我,的microkof。如果您觉得本文对您的工作有意义,产生了不可估量的价值,那么请您不吝打赏我,谢谢!

名词约定

Promises的概念是由CommonJS小组的成员在Promises/A规范中提出来的。一般来讲,有以下的名词约定:

promise(首字母小写)对象指的是Promise实例对象

Promise首字母大写且单数形式,表示Promise构造函数

Promises首字母大写且复数形式,用于指代Promises规范

Promises/A规范和ES6 Promises规范

Promise规范有几次升级,目前来讲,Promise/A是最新的民间规范,ES6 Promises是最新的官方规范,只需知道ES6 Promises规范是你应该遵守的标准就行了。

为什么有Promises这个东西

  1. 解决回调金字塔的问题,回调金字塔也叫回调圣诞树,好听吧?还有个别名叫回调地狱,不好听了吧?到底可怕不可怕?非常的可怕。
  2. 可以同时管理成功回调和失败回调。

名词解释:同步任务和异步任务

  • 同步任务:只需JS引擎自身就可以完成的任务,叫同步任务。
  • 异步任务:JS引擎自身无法完成,需要外力协助的任务,叫异步任务;还有一种是由JS引擎能够独立完成,但是JS引擎把任务分成了两段,第二段当做回调,也是异步任务。

简单理解JS引擎的执行机制的话,异步任务分为三个执行段,对JS引擎来讲是执行第一和第三个阶段:

  1. 异步任务本体执行:由JS引擎同步执行
  2. 异步任务外力执行:由外力执行
  3. 异步任务回调执行:由JS引擎异步执行

所谓“承诺”

Promise这个单词的意思是“承诺”,就是我们日常说的“我肯定帮你买早饭”、“你如果交了钱我肯定给你一杯咖啡”。

在程序世界,举例可以说:“我承诺给你完成这些代码执行”。new一个Promise实例,就是JS引擎对你做了一个承诺。

既然是承诺,就肯定有成功的时候有失败的时候,比如我帮你买早餐,结果今天煎饼果子没出摊,或者是我走到半路上,煎饼果子的塑料袋破裂,煎饼果子滑落到了地上,这就是失败。就连“我们承诺绝不首先动用核武器”都有坚持不下去的时候,所以只要是承诺就有成功和失败,只不过是概率问题。

到程序世界,一个承诺也会有三种状态,就是“未决的”、“成功的”、“失败的”三种状态。也就是pendding/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);
});

结果没问题:

Paste_Image.png

这里用到了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);
});

结果就神奇了:

Paste_Image.png

什么原因?怎么理解?

简单说原因是:

then的链式执行,理想情况下是基于每个then的前一个then能够返回promise对象。不理想情况下是没有返回promise对象,这种情况下,虽然then的链式执行依然可以执行,但是,每个then只可能等前一个then同步代码完成,不会等前一个then异步代码完成。第一个then的同步代码是一个计时器,开始计时就算完成了,然后第二个then什么也没得到,其实是得到了一个undefinedundefined * 2得到NaN,所以打印NaN

为啥第一个then的回调用return promise对象,第二个then就可以等那2秒的延迟呢?用上段文字就很好理解了,而且你还可以回忆一下本文最上方说的promise对象的超能力,当你给then返回一个非promise对象,then只接收同步的返回值,反之,当你给then返回一个promise对象,那么then就等待promise对象生成,然后等resolvereject传递参数,等多久都能等。

这里要注意一下,我上段文字所说的“等待”,其实并不是等待,而是new一个promise对象的过程被js引擎视为同步任务执行,因此new出了promise对象,并return的过程,其实是同步代码,then其实并不是在等待,而是非常自然的链式执行顺序。

为什么第三个回调比第二个回调先执行了?因为第二个then得到了undefined之后,第三个then就开始了,第三个回调延迟的时间短嘛,就1秒,所以比第二个回调先执行了。

为啥“第二个回调”下面输出是6?因为3 * 2得6。

总之,如果想异步-异步-异步-异步......这样一直搞下去,就只能是每一步都给下一步返回一个promise对象。

解答几个问题:

如果resolvereject语句后面还写了语句,会执行吗?

会。resolvereject负责传参,但不是说传了参就中止执行了。

如果第一个then的回调用了promise对象,但是promise对象没写resolvereject方法,第二个then的回调还会执行么?

答案是不执行,等于第二个then白写了,因为promise对象永远处于Pending状态。如果后面有第三个then,依然不会执行。等于链条从第一个promise对象就断了。

如果有resolvereject方法,但是不设参数,也就是resolve()reject(),那么then会执行吗?

会。resolvereject传的参数是undefined

如果第一个then的第一个回调函数没执行,第二个then的第一个回调函数会执行么?

会。

如果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整个的任务队列的执行机制就是:

  1. 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);
  2. 全局Script代码执行完毕后,调用栈Stack会清空;
  3. 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
  4. 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;
  5. microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;
  6. 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行;
  7. 执行完毕后,调用栈Stack为空;
  8. 重复第3-7个步骤;
  9. 重复第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决定,分成两种情况。

  1. 只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。
    注意,如果p1的最后一个then的回调没有return命令,那么p1的返回值就是undefined,即使倒数第二个then的回调有返回值也没有用,只看最后一个then的回调的返回值。
    再注意,组成的数组的顺序是按照.all()参数的书写顺序而定,跟谁先返回值无关。
  2. 只要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短。

Paste_Image.png

如果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规范全部介绍完了。然后就是最佳实践。

参考文档:

promises 很酷,但很多人并没有理解就在用了

打开Promise的正确姿势

本文作者就是我,的microkof。如果您觉得本文对您的工作有意义,产生了不可估量的价值,那么请您不吝打赏我,谢谢!

你可能感兴趣的:(我对Promises的理解)