ES6入门八:Promise异步编程与模拟实现源码
Promise的基本使用入门:
——实例化promise对象与注册回调
——宏任务与微任务的执行顺序
——then方法的链式调用与抛出错误(throw new Error)
——链式调用的返回值与传值
Promise的基本使用进阶:
——then、catch、finally的使用
——all、race的使用
Promise的实现目的
——链式调用解决回调地狱
——异步回调现在与未来任务分离
——信任问题(控制反转):调用过早、调用过晚(不被调用)、调用次数过少过多、未能传递环境和参数、吞掉出现的错误和异常
Promise的实现原理与模拟实现源码
一、Promise的基本使用入门
1.Promise是什么?
Promise是用来实现JS异步管理的解决方案,通过实例化Promise对象来管理具体的JS异步任务。
从Promise管理回调状态的角度来看,Promise又通常被称为状态机,在Promise拥有三种状态:pending、fulfilled、rejected。
用Promise解决JS异步执行的逻辑来理解,可以说Promise是一个未来的事件,也就是说Promise管理的任务并不是在JS的同步线程上立即执行,而是会等待同步线程的程序执行完以后才会执行。
2.创建Promise实例管理一个异步事件,通过then添加(注册)异步任务:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 });
复制代码
通过示例可以看到Promise实例化对象需要传入一个excutor函数作为参数,这个函数有两个形参resolve、reject分别表示受理事件与拒绝事件。这两个事件也就是通过实例对象调用then方法传入的两个函数,也就是说then方法传入的两个函数分别表示resolve与reject。
3.微任务与宏任务:
在js线程中,XMLHttpRequest网络请求响应事件、浏览器事件、定时器都是宏任务,会被统一放到一个任务队列中(用task queue1表示)。
而由Promise产生的异步任务resolve、reject被称为微任务(用task queue2表示)。
这两种任务的区别就是当异步任务队列中即有宏任务又有微任务时,无论宏任务比微任务早多久添加到任务队列中,都是微任务先执行,宏任务后执行。
来看下面这个示例,了解Promose微任务与宏任务的执行顺序:
复制代码
1 setTimeout(function(){
2 console.log(0); //这是个宏任务,被先添加到异步任务队列中
3 },0);
4 let oP = new Promise((resolve, reject) => {
5 resolve(1);//这是个微任务,被后添加到异步任务队列中
6 console.log(2);//这是第一个同步任务,最先被打印到控制台
7 });
8 oP.then((val) => {
9 console.log(val);
10 },null);
11 console.log(3);//这也是个同步任务,第二个被打印到控制台
复制代码
测试的打印结果必然是:2 3 1 0;这就是微任务与宏任务的区别,从这一点可以了解到,JS自身实现的Promise是一个全新的功能并非语法糖,所以除原生promise以外的promise实现都是一种模拟实现,在模拟实现中基本上都是使用setTimeout来实现Promise异步任务的,所以如果不支持原生Promise浏览器使用的是兼容的Promise插件,其Promise异步任务是宏任务,在程序执行时可能会出现与新版本浏览器原生的Promise实现的功能会有些差别,这是需要注意的一个小问题。
4.Promise中的then的链式调用与抛出错误:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 }).then((val) => {
13 console.log("then2 受理:" + val);
14 },(reason) => {
15 console.log("then2 拒绝:" + reason);
16 });
复制代码
在上面的示例中,第一个then肯定出现两种情况,受理或者拒绝,这个毫无疑问,但是上面的链式调用代码中第二个then注册的resolve与reject永远都只会触发受理,所以最后的执行结果是:
复制代码
//第一种情况:
受理:ok
then2 受理:undefined
//第二种情况:
拒绝:no
then2 受理:undefined
复制代码
ES6中的Promise实现的then的链式调用与jQuery的Deferred.then的链式调用是有区别的,jQuery中实现的链式调用的第一个then的受理或者拒绝回调被调用后,后面的then会相应的执行受理或者拒绝。但是ES6中的Promise除第一个then以外后面都是调用受理,这里不过多的讨论jQuery的Deferred的实现,但是这是一个需要注意的问题,毕竟ES6的Promise是总结了前人的经验的基础上设计的新功能,在使用与之前的相似的功能时容易出现惯性思维。
ES6中的Promise.then链式调用的正确姿势——抛出错误:
这种设计的思考逻辑是:Promise1管理异步任务___>受理 Promise1没有抛出错误:Promise2受理
___>Promise2管理Promise1___>
___>拒绝 Promise2抛出错误:Promise2拒绝
所以前面的示例代码在拒绝中应该添加抛出错误才是正确的姿势:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
4 },1000);
5 });
6 oP.then((val) => {
7 console.log("受理:" + val);
8 },(reason) => {
9 console.log("拒绝:" + reason);
10 throw new Error("错误提示...");
11 }).then((val) => {
12 console.log("then2 受理:" + val);
13 },(reason) => {
14 console.log("then2 拒绝:" + reason);
15 });
复制代码
在第一个then注册的reject中抛出错误,上面的示例的执行结果就会是这样了:
复制代码
//第一种情况:
受理:ok
then2 受理:undefined
//第二种情况:
拒绝:no
then2 拒绝:Error: 错误提示...
复制代码
好像这样的结果并不能说明之前的设计思考逻辑,仅仅只能说明then的链式调用在reject中抛出错误才能触发后面的reject,但是在我们的开发中必然会有即便异步正确受理,但不代表受理回调就能正确的执行完,受理的代码也可能会出现错误,所以在第一个then中受理回调也抛出错误的话同样会触发后面链式注册的reject,看示例:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 if(Math.random() * 100 > 30){
11 return "then1受理成功执行完毕!";
12 }else{
13 throw new Error("错误提示:then1受理没有成功执行完成。");
14 }
15 },(reason) => {
16 console.log("拒绝:" + reason);
17 throw new Error("错误提示...");
18 }).then((val) => {
19 console.log("then2 受理:" + val);
20 },(reason) => {
21 console.log("then2 拒绝:" + reason);
22 });
复制代码
这时候整个示例最后的执行结果就会出现三种情况:
复制代码
//情况1:
受理:ok
then2 受理:then1受理成功执行完毕!
//情况2:
promise.html:24 拒绝:no
then2 拒绝:Error: 错误提示...
//情况3:
受理:ok
then2 拒绝:Error: 错误提示:then1受理没有成功执行完成。
复制代码
5.then方法链式调用的返回值与传值:
在前面的代码中,相信你已经发现,如果前面then注册的回调不返回值或者不抛出错误,后面的then接收不到任何值,打印出来的参数为undefined。这一点也与jQuery中的then有些区别,在jQuery中如果前面的then没有返回值,后面then注册的回调函数会继续使用前面回调函数接收的参数。
在前面的示例中已经有返回值、抛出错误和传值的展示了,这里重点来看看如果返回值是一个Promise对象,会是什么结果:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 return new Promise((resolve, reject) => {
11 Math.random() * 100 > 60 ? resolve("then1_resolve_ok") : reject("then_resolve_no");
12 });
13
14 },(reason) => {
15 console.log("拒绝:" + reason);
16 return new Promise((resolve, reject) => {
17 Math.random() * 100 > 60 ? resolve("then1_reject_ok") : reject("then1_reject_no");
18 })
19 }).then((val) => {
20 console.log("then2 受理:" + val);
21 },(reason) => {
22 console.log("then2 拒绝:" + reason);
23 });
复制代码
以上的示例出现的结果会有四种:
复制代码
//情况一、二
受理:ok
then2 受理:then1_resolve_ok / then2 拒绝:then1_resolve_no
//情况三、四
拒绝:no
then2 受理:then1_reject_ok / then2 拒绝:then1_reject_no
复制代码
通过示例可以看到,当前面一个then的回调返回值是一个Promise对象时,后面的then触发的受理或者拒绝是根据前面返回的Promise对象触发的受理或者拒绝来决定的。
二、Promise的基本使用进阶
1.在Promise中标准的捕获异常的方法是catch,虽然前面的示例中使用了reject拒绝的方式捕获异常,但一般建议使用catch来实现捕获异常。需要注意的是异常一旦被捕获就不能再次捕获,意思就是如果在链式调用中前面的reject已经捕获了异常,后面链式调用catch就不能再捕获。
建议使用catch异常捕获的代码结构:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 throw new Error("错误提示...");
13 }).then((val) => {
14 console.log("then2 受理:" + val);
15 }).catch((err) => {
16 console.log(err);
17 })
复制代码
catch不能捕获的情况:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 throw new Error("错误提示...");
13 }).then((val) => {
14 console.log("then2 受理:" + val);
15 }, (reason) => {
16 console.log("then2 拒绝:" + reason);
17 }).catch((err) => {
18 console.log("异常捕获:",err);
19 })
20 //这种情况就只能是在第二个then中的reject捕获异常,catch不能捕获到异常
复制代码
一个非技术性的问题,调用了一个空的then会被忽视,后面的then或者catch依然正常执行:
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 throw new Error("错误提示...");
13 })
14 .then()//这个then会被忽视,如果前面一个then调用了reject拒绝,后面的catch能正常捕获(或者后面链式调用then都能正常执行)
15 .catch((err) => {
16 console.log("异常捕获:",err);
17 });
复制代码
2.在Promise方法中,除了finally都会继续返回Promise对象,而且finally传入的回调函数一定会被执行,这个跟前面的一种情况非常类似,就是当前面的then不抛出错误的时候,后面的then一定是调用受理,实际上底层的实现也就是同一个逻辑上实现的。只是finally不再返回Promise对象,但需要注意的是finally注册的回调函数获取不到任参数。
复制代码
1 let oP = new Promise((resolve, reject) => {
2 setTimeout(() => {
3 //使用定时器开启异步任务,使用随机数模拟异步任务受理或者拒绝
4 // (数字大于60表示异步任务成功触发受理,反之失败拒绝)
5 Math.random() * 100 > 60 ? resolve("ok") : reject("no");
6 },1000);
7 });
8 oP.then((val) => {
9 console.log("受理:" + val);
10 },(reason) => {
11 console.log("拒绝:" + reason);
12 throw new Error("错误提示...");
13 }).catch((err) => {
14 console.log("异常捕获:",err);
15 }).finally(() => {
16 console.log("结束");//这个回调函数接收不到任何参数
17 })
复制代码
3.Promise给并发处理提供了两种实现方式all、race,这两个的处理逻辑非常类似条件运算符的与(&&)或 (||)运算,all就是用来处理当多个Promise全部成功受理就受理自身的受理回调resolve,否则就拒绝reject。race的处理多个Promise只需要一个Promise成功受理就触发自身的受理回调,否则就拒绝reject。它们处理Promise实例的方式都是将Promise实例对象作为数组元素,然后将包裹的数组作为all或race的参数进行处理。
这里使用一段nodejs环境读取文件代码来展示Promise.all的使用:
复制代码
//路径+文件名: 内容:data
./data/number.txt "./data/name.txt"
./data/name.txt "./data/score.tet"
./data/score/txt "99"
//src目录结构
--index.js
--data
----number.txt
----name.txt
----score.txt
复制代码
Promise.all实现文件数据并发读取:
复制代码
1 let fs = require('fs');
2
3 function readFile(path){
4 return new Promise((resolve,reject) => {
5 fs.readFile(path,'utf-8', (err,data) => {
6 if(data){
7 resolve(data);
8 }else{
9 reject(err);
10 }
11 });
12 });
13 }
14
15 Promise.all([readFile("./data/number.txt"),readFile("./data/name.txt"),readFile("./data/score.txt")]).then((val) =>{
16 console.log(val);
17 });
复制代码
在nodejs环境中执行代码,打印结果:
node index.js //执行js文件
[ './data/name.txt', './data/score.txt', '99' ] //打印结果
从示例中可以看到Promise.all获取的值是全部Promise实例受理回调传入的值,并且以数组的方式传入。
接着来看一个Promise.race的示例,这个示例:
复制代码
1 var op1 = new Promise((resolve, reject) => {
2 setTimeout(resolve, 500, "one");
3 });
4 var op2 = new Promise((resolve, reject) => {
5 setTimeout(resolve, 100, "two");
6 });
7 Promise.race([op1,op2]).then((val) => {
8 console.log(val);
9 });
10 //打印结果:two
复制代码
Promise.race获的值是第一个Promise实例受理回调传入的值。
4.Promise.all与Promise.race的传值规则:
all:
所有Promise实例受理resolve,即所有异步回调成功的情况下,将所有Promise实例的resolve接收的参数合并成一个数组,传递给Promise.all生成的新的Promise实例的resolve回调处理。
如果有一个失败的情况下,即Promise.all生成的新的Promise实例触发回调reject函数,这个函数会接收到最先失败的Promise实例通过reject回调传入的参数。
race:
通过Promise.race处理的Promise实例中最先获得结果的Promise实例的参数,传递给Promise.race产生的Promise实例,不论成功与失败,成功就出发resolve函数,失败就出发reject函数。
三、Promise的实现目的
1.链式调用解决回调地狱:在一开始学习编程的时候我们一定都写过一连串的作用域嵌套代码,来解决一些业务逻辑链相对比较长的功能,然后还可能跟同学炫耀“你看我把这个功能写出来了,还能正确执行”。不要问我为什么这么肯定,这种事我做过,我的同学和朋友也有做过。这为什么值得炫耀呢?无非就是面对这种业务逻辑链比较长的功能很难保证在那个不环节不出错,所以能驾驭层层嵌套的代码的确可以说很“认真”的在编码。我在jQuery的ajax的一篇博客中就是用了一个非常详细的案例展示了回调地狱:jQuery使用(十二):工具方法之ajax的无忧回调(优雅的代码风格)
这里我使用第二节中的(3:Promise.all)案例,(应用之前的文件结构)来写一个文件层级读取的示例:
复制代码
1 //这是一个基于nodejs环境的js示例,请在nodejs环境中执行index.js
2 let fs = require('fs');
3
4 fs.readFile("./data/number.txt","utf-8",(err,data) => {
5 if(data){
6 fs.readFile(data,"utf-8",(err,data) => {
7 if(data){
8 fs.readFile(data,"utf-8",(err,data) => {
9 console.log(data);
10 })
11 }
12 })
13 }
14 });
复制代码
相信大家遇到这种代码都会知道这样的代码结构,不易于维护,编写容易出错并且还不容易追踪错误。下面来看看使用Promise如何回避这样的问题,来提高代码质量:
复制代码
1 //这是一个基于nodejs环境的js示例,请在nodejs环境中执行index.js
2 let fs = require('fs');
3
4 function readFile(path){
5 return new Promise((resolve,reject) => {
6 fs.readFile(path,'utf-8', (err,data) => {
7 if(data){
8 resolve(data);
9 }else{
10 reject(err);
11 }
12 });
13 });
14 }
15 readFile("./data/number.txt").then((val) => {
16 return readFile(val);//这里去获取nama.text的文本数据
17 },(reason) => {
18 console.log(reason);
19 }).then((val) => {
20 return readFile(val);//这里去获取score.text的文本数据
21 },(reason) => {
22 console.log(reason);
23 }).then((val) => {
24 console.log(val);//这里最后打印score.text的文本数据
25 },(reason) => {
26 console.log(reason);
27 });
复制代码
2.异步回调现在与未来任务分离:
Kyle Simpson大神在《你不知道的js中卷》的第二部分第一章(1.3并行线程)中给我说明了一个我长期混洗的知识点,“异步”与“并行”,他明确的阐述了异步是关于现在和将来的事件间隙,而并非关于能同时发生的事情。
简单来说,在js中我们可以把同步任务理解为现在要执行的任务,异步则是将来要执行的任务,个人认为这是Promise的核心功能,Promise的then本质上就是这样的设计思路,在实例化的Promise对象的时候就已经调用了回调任务resolve或者reject,但是Promise将这两个回调任务处理成了异步(微任务)模式,通过前面的应用介绍我们知道Promise实例化的时候并没有添加这两个任务,而是后面基于同步任务的then添加的,所以resolve和reject才能在未来有真正的任务可以执行。
利用异步的这种现在与未来的异步设计思路实现了Promise.all和Promise.race,解决了前端回调的竞态问题。关于js竞态问题可以了解《你不知道的js中卷》第二部分第一章和第三章的3.1。(这给内容可多可少,但是想想Kyle Simpson的清晰明了的分析思路,建议大家去看他书。)
3.信任问题(控制反转):
相信大家在应用js开发的时候都使用果类似这样的代码:
ajax("...",function(...){ ... })
通常这样的代码我们都会想到插件或者第三方库,如果这是一个购物订单,你知道这段代码存在多大的风险吗?我们根本就不知道这个回调函数会被执行多少次,因为怎么执行是由别让人的插件和库来控制的。顺着这个思路,在《你不知道的js中卷》的第二部分第二章2.3.1最后,大神提出这样的追问:调用过早怎么办?调用过晚怎么办?调用多次或者次数太少怎么办?没有传递参数或者环境怎么办?出现错误或者异常怎么办?这些内容在《你不知道的js中卷》第二部分第三章3.3都详细的描述了基于Promise的解决方案。
本质上也就是Promise的控制反转的设计模式,比如前面的ajax()请求可以这样来写:
复制代码
var oP = new Promise((resolve,reject) => {
resolve(...);
});
oP.then((val) => {
ajax("...",function(...){...});
});
复制代码
我们知道,每个Promise只能决议一次,无论成功或者失败,所以就不用当心一个购物订单请求会不会被插件或者第三方库误操作发送多次(这并不是绝对的,毕竟ajax回调函数内部怎么执行还是别人的代码,这里我能只能假设ajax回调函数是可信任的)。
关于Promise的实现目的还有很多,我也只能在这里列举一些比较典型的和常见的问题,如果想了解更多我首先建议大家去看