前端异步编程系列之Promise/Deferred模式(3/4)

在这篇文章中,我会介绍另外一种异步编程的解决方案:Promise/Deferred模式。这种模式最早出现于Dojo的代码中,09年被Kris Zyp抽象为一个提议草案,发布于CommonJS规范中,并抽象出Promise/A、Promise/B、Promise/D这样典型的异步Promise/Deferred模型,这使得异步操作可以以一种优雅的方式出现。他最大的特点就是可以先执行异步调用,然后延迟传递处理操作。有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。本篇文章通过Promise/A模式来作为敲门砖来简略介绍Promise/Deferred模式,以ES6的Promise实现来重点介绍Promise的特性以及对于异步编程问题的解决方案。

1.Promise/A的定义。

Promise/A提议对单个异步操作有如下规定:

1.Promise操作只会有三个状态:未完成,完成,失败

2.他的状态转换只能是:未完成 => 完成 和 未完成 => 失败两种,并且转换是不可逆,并且完成和失败之间不能相互转换。

3.状态一旦变化就不能被更改。

如下图所示:

而在api的定义上,最低要具备一个then方法即可,并且then方法的行为包括:

1.接受完成态,错误态时的回调方法,以便在Promise状态改变时,调用相应回调方法。

2.只接受函数

3.then方法会继续返回一个Promise,以实现链式调用。

其实then方法的行为只是将他接受的回调函数储存起来,在某一刻Promise状态改变时,再进行调用罢了。

示例代码如下:

const myPromise = function () {
    this.handle = {};  // 储存处理函数
};
// then的作用在于把,成功和失败的回调给储存起来。以方便在以后的某个时刻调用
myPromise.prototype.then = function(resolveHandler,rejectHandler) {
    let handle = {};
    if(typeof resolve == "function") {
        handle.resolve = resolveHandler
    }
    if(typeof reject == "function") {
        handle.reject = rejectHandler
    }
    this.handle = handle;
}

不过如果要完整走完流程,还需要一个可以改变Promise状态的对象,而这个对象就是Deferred,即延迟对象。他用来改变Promise的状态,示例代码如下:

const myDeferred = function () {
    this.status = "pending";  // 未完成的等待状态。
    this.promise = new myPromise();  // 让deferred和一个promise进行关联,以控制promise的状态。
};
// 更改为完成状态,并执行相应的处理函数。
myDeferred.prototype.resolve = function (obj) {
    this.status = "resolve";
    let handle = this.promise.handle;
    if(handle && handle.resolve) {
        handle.resolve(obj);
    }
}
// 更改为失败状态,并执行相应的处理函数。
myDeferred.prototype.reject = function (obj) {
    this.status = "reject";
    let handle = this.promise.handle;
    if(handle && handle.reject) {
        handle.reject(obj);
    }
}

在这里,Deferred的作用主要就是更改状态,然后从关联的Promise中取出then方法所存储的相应的处理函数。

Promise和Deferred的整体关系图如下所示:

可以看出,Promise主要作用于外部,通过then方法,存储逻辑处理函数,而Deferred用于内部,改变Promise的状态,并调用相应的处理函数。

拿node中读取文件为例,读取一个文件,经过Promsie/Deferred封装后,会变成如下形式:

const fs = require("fs");
function read(path) {
    let deferred = new myDeferred();
    fs.readFile(path,(err,data) => {
        if(err) {
            deferred.reject(err);
            return;
        }
        deferred.resolve(data);
    })
    return deferred.promise;
}
// 异步调用一个文件
read("./promise/test1.txt").then((data) => {
    console.log(data);
},(err) => {
    console.log(err);
})

以上算是对Promise/Deferred模式的一些基本介绍。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。当然了,上面的代码只是基本简略的按照规范实现他的功能而已,看不出什么,至于剩余的更加完善的Promise/Deferred模式,以及他对于解决异步编程问题方式,以下我会以ES6的Promise实现来进行介绍和讲诉。

2.ES6的Promise实现

在ES6也实现了Promise这种异步模式。他也拥有then方法来存储处理函数,并且拥有catch函数来专门存储异常处理函数,并支持链式调用,处理异步协同等。接下来会一个个介绍。

使用:

ES6内置Promise对象,可以通过一个他来获取一个Promise实例:

const promise = new Promise(function(resolve, reject) {
  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);//异步操作失败
  }
});

Promise构造函数接受一个函数,这个函数有resolve和reject两个由JavaScript引擎提供的函数。

1.resolve函数的作用是把这个Promise实例从未完成变为完成的状态。

2.reject函数的作用是把这个Promise实例从未完成变为失败的状态。

而返回的Promise实例,你可以调用他的then方法,为其设置完成时和失败时的处理函数:

promise.then(function(data) {
  // 成功,data的值为调用resolve函数传入时的那个参数
}, function(error) {
  // 失败,error的值为调用reject函数传入时的那个参数
});

而resolve和reject都只接受一个参数,会传递给对应的处理函数。所以,then的成功和失败处理函数也只接受一个数据参数。

比如下面读取一个文件的使用例子:

const fs = require("fs");
const filePromiseTest = new Promise((resolve,reject) => {
    fs.readFile("./test.txt",(err,data) => {
        if(err) {
            reject(err);
            return;
        }
        resolve(data);
        return;
    })
})
// then方法:then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)
filePromiseTest.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})

其实和之前那个封装后的Promise/A实现的使用方式差不多,不过,和上面那个草率的Promise/A实现相比,其之间的差别就如下面的介绍所示了:

1.链式调用

ES6的Promise对象支持链式调用,因为Promise实例的then方法返回的也是一个新的Promise对象,所以上面的那个读取文件例子中的then可以变成这样:

filePromiseTest
.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})
.then()
.then()
.then().....

你可以写很多个then,一直这样链式调用下去。所以,当有多个异步操作使用回调函数需要嵌套时,那么就可以改为使用Promise的链式调用then来编写。这样,是不是有一点同步写代码的感觉了?

then方法可以只接受一个回调参数,那么这个回调参数会用于resolve时的处理函数,那么reject时的处理函数怎么办?ES6提供了一个catch函数专门用于存储reject状态时的处理函数,即出现异常时的处理函数,他是这样用的:

filePromiseTest.then((data) => {
    console.log(data.toString());
}).catch((err) => {
    console.log(err);
});

这样,如果filePromiseTest的状态变为失败状态了(即调用reject函数),那么代码中的then方法传入的那个函数不会执行,会执行catch方法传入的那个函数,即会执行:console.log(err); 这一句。而且,即使你这么写,只要下面的then中有一个报错了,那么都会跳过之后的所有then,而去执行catch。比如:

filePromiseTest
.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})
.then()
.then()
.then()
.catch((err) => {
    // 只要以上有任意一个then出错或者filePromiseTest本身就为reject,那么就会跳过其间所有的then,而直接执行我这个catch
    //TODO
}).....

而且,catch和then一样,都是返回一个新的Promsie实例。ES6中通常使用catch方法来处理异常,并且所以then或者catch方法都可以支持链式调用。

上面的then和catch的介绍只是ES6中最基本用法,下面说的才是ES6中的then和catch函数的重点:

链式调用中,then或catch的行为。

我们来看一个代码A的例子:

filePromiseTest.then((data) => {
    console.log(data.toString());
}).catch((err) => {
    console.log(err);
});

好,上面的那段代码A那个的行为是:如果filePromiseTest这个Promise的状态为成功 => 调用then方法的那个处理函数,如果filePromiseTest这个Promise的状态为失败 => 就去调用catch方法的那个处理函数。从逻辑上,这段代码A的行为没有问题,而且按理来说就是应该这么走的一个逻辑。

那么这段代码看上去是不是应该这个道理:代码A中调用的then和catch方法是不是就是给filePromiseTest这个Promise实例 本身 添加一个成功时的处理函数和失败时的处理函数呢?当这个filePromiseTest实例状态改变了,是不是就是直接调用存储在这个Promise实例上的相应处理函数呢?

不 ,其实不是,这段代码看上去是存在着误导的,其行为看上去也是存在着误导。这时候就要认真听了啊。

注意,then和catch方法他们返回的是一个新的Promise实例,而不是返回调用这两个方法的实例。所以,代码A其实等价于下面这个代码B:

代码B:

const promise = filePromiseTest.then((data) => {
    console.log(data.toString());
})
上面的那个promise是一个then方法所返回的一个新Promsie实例。然后我又在这个promise上面调用catch方法,来监听这个Promsie失败时的处理函数。
promise.catch((err) => {
    console.log(err);
});

所以,filePromiseTest这个Promise实例上面只存在成功时的处理函数,而promise实例上面只存在失败时的回调函数。这就是代码A所产生的结果。所以,你看那种很多链式调用then和catch的代码,其实他们的then和catch所添加的处理函数都是添加到不同的Promise实例上了,但是为什么代码的逻辑行为确可以是我们想要的呢?

那就要了解另外一个关于then和catch的知识了:then和catch返回的新Promise实例的状态是怎么确定的。

我们来看代码B吧,虽然在真正写代码的时候会一般写成代码A,不过代码B更好理解一些:

代码B中的promise是一个then方法所返回的Promise实例,那么他的状态其实和返回他的那个then方法的下列情况有关:

1.如果那个then方法返回一个非Promise的js的普通值(字符串,数字,对象,数组等)那么promise的状态为resolve。并且,这个then所返回的值,会传递给这个promise

2.如果那个then方法返回一个新的Promise,代号P,那么这个promise会等待这个P的状态变为成功或者失败时,这个promise才会改变状态,并且:如果P的状态为成功,那么这个promise的状态也为成功,否则,那就是失败了。并且,P的状态变化时的接收的数据,也会同样传递给promise。

3.如果那个then方法中处理函数抛出了错误,那么promise状态会变为reject,并且捕获的错误就是reject的参数。

了解以上三种情况,那么就可以了解下面这种代码的行为了:

filePromiseTest
.then()  //1
.then()  //2
.then()  //3
.then().....

第一个then的执行看filePromiseTest,如果filePromiseTest为resolve成功,那么第一个then会执行,然后,第二个then的执行看第一个then的返回值,如果是返回普通的js值,那么第二个then也会执行,然后第三个then也要看第二个then的执行情况,依次类推。

但是,上面三种情况只是了解其一,还有其二:

为什么代码A中,filePromiseTest为失败时,会直接跳过那个then,而可以直接执行catch?filePromiseTest这个Promise实例只有一个成功时的处理函数,那么他如何控制then方法返回的那个promise的状态的?这就是隐藏的其二了:

在我实验了许多不同的then和catch调用情况后,我猜测了如下规律来解释ES6的这种Promise行为:(重点)

首先:

一个promise1的then和catch都会返回一个和 本promise(代号为p1) 实例 相关连 的新promise实例,并且,在p1实例状态改变时由p1的then和catch方法所返回的所有新实例promise的状态也会改变状态(由p1的then和catch方法所返回的新Promise实例都会和p1进行一种关联)。具体新Promise的状态改变的表现为:

1.当p1为resolve时,执行p1的then,并且,由p1的then方法所返回的新promise实例会根据其相应的每个then的返回值来确定其状态(也就是符合那三种情况的)。而这时候,p1的catch函数不会执行,并且由p1的所有catch方法所返回的新promise实例的状态都会变成resolve,即成功状态,并执行其then。而此时then的参数,为p1的resolve执行时传递的那个参数。

2.当p1为reject时,执行p1的catch,并且,由p1的catch方法所返回的新Promise实例会根据其每个catch方法的返回值来确定其状态(也是符合那三种情况的)。而这时候,p1的then方法的处理函数不会执行,并且由p1的所有then方法所返回的新promise实例的状态都会变成reject,并执行其catch。而此时catch的参数,为p1的reject执行时传递的那个参数

很拗口,那么使用代码B来试一下看代码B的行为是否符合上面的逻辑:

代码B:

const promise = filePromiseTest.then((data) => {
    console.log(data.toString());
})
上面的那个promise是一个then方法所返回的一个新Promsie实例。然后我又在这个promise上面调用catch方法,来监听这个Promsie失败时的处理函数。
promise.catch((err) => {
    console.log(err);
});

如果filePromise的状态为resolve,那么会执行filePromise的then,并且,promise这个Promise实例的状态会由这个then的返回值决定,如果这个then中的处理函数没有报错,那么返回的应该是undefined,是一个js普通值,那么promise的状态会变为resolve,所以promise的catch不会执行。

如果filePromise的状态为reject,那么会执行filePromise的catch(这里没有设置),而这时,filePromise的then方法的处理函数不会执行,由filePromise的then方法返回的promise变量的状态会变为reject(失败态),promise会执行catch。所以会执行:console.log(err); 这一句。

再看一个分解过后的复杂的例子:

代码C:nodejs中运行

const fs = require("fs");
// 读取文件的promise封装方法
function readFile(path) {
    const promise = new Promise((resolve, reject) => {
        fs.readFile(path,(err,data) => {
            if(err) {
                reject(err);
                return;
            }
            resolve(data);
            return;
        })
    })
    return promise;
}
const readFile1 = readFile("./test3.txt");
const read1 = readFile1.then((data) => {
    // 返回一个新的Promise实例
    return readFile(data.toString())
})
const read2 = readFile1.catch((err) => {
    console.log("readFile1输出错误")
    return '我是readFile1的catch处理函数所返回的';
})
read1.then((data) => {
    console.log("read1的then",data.toString())
})
read1.catch((err) => {
    console.log("read1的err",err)
})
read2.then((data) => {
    console.log("read2的then",data)
})
read2.catch((err) => {
    console.log("read2的err")
})
// 执行结果如下:
readFile1输出错误
read1的err { [Error: ENOENT: no such file or directory, open 'C:\Users\Administrator\Desktop\test\test3.txt']
  errno: -4058,
  code: 'ENOENT',
  syscall: 'open',
  path: 'C:\\Users\\Administrator\\Desktop\\test\\test3.txt' }
read2的then 我是readFile1的catch处理函数所返回的

text.txt文件内容:./test2.txt

text2.txt文件内容:我是text2文件

text3.txt文件不存在

read1和read2分别为readFile1这个Promise实例的then方法和catch返回的新Promise实例,并且,都给新的实例添加了then和catch。

readFile1返回的是一个请求text3.txt文件的异步Promsie实例,由于text3.txt不存在,那么readFile1的状态会变为reject,所以输出:readFile1输出错误。然后read1的状态会变为reject,并且参数为readFile1的reject执行所传递的那个参数,所以输出:read1的err + err对象。而read2的状态则是由readFile1的catch处理函数的返回值所决定的,这里返回了一段字符串,所以,read2的状态为resolve,所以执行then方法的处理函数,并且,其处理函数所接收的参数正好是readFile1的catch处理函数所那个返回的那个值。

所以,通过这两个例子看出,我猜测的规律应该是正确的,那么当你如果在编写ES6的Promise异步编程时遇到了比较费解或者不明白的异步行为,不妨可以根据上面的两点进行推导一下,也许就能够理解他的行为了。

当然了,ES6的then和catch还有其他的一些比较重要的知识点,比如:

1.一个Promise的状态已经变了,也就是变为为resolve或者reject了。那么再次执行catch和then方法时,那么catch或者then会立即执行,不过是会在这一轮程序的任务的最后才执行。所以,说明promise实例会把执行resolve或者reject时的那个数据存起来。

2.如果一个Promise(p2)只由他的一个then添加的成功和失败的处理函数,那么这个then所返回的Promise新实例会看p2到底是执行成功处理函数还是失败处理函数,并根据执行处理函数的返回值来确定状态。

3.在promise里面抛出的错误,根本不会被外界代码捕获。一般总是要建议在最后都要添加一个catch方法来处理错误的。不过一般来说,最好不要使用then来定义失败时的回调函数(即不要定义第二个参数函数),而是总是使用catch方法。

ES6的Promise还有一个finally方法,他的作用就是不管Promsie的状态如何,都会执行finally。不过这里就不做过多的介绍。

2.异步协同

异步协同在ES6的Promise实现中也很简单,他提供了专门的all方法,all方法用于将多个Promise实例包装成一个新的Promise实例:

const p = Promise.all([p1, p2, p3]);

p1、p2、p3都是 Promise 实例,如果不是,就会先调用Promise.resolve方法,将参数转为 Promise 实例。(Promise.resolve和Promise.reject方法的作用都是接收一个参数,并将参数转换为一个Proomise实例并返回)

而p的状态分为两种:

1.只有p1、p2、p3的状态都变成成功状态,p的状态才会变成成功状态,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。

2.只要p1、p2、p3之中有一个为失败状态,p的状态就变成失败状态,此时第一个被reject的实例的返回值,会传递给p的回调函数。

除此之外,ES6还定义了一个Promise.race方法,他和all方法的区别在于:只要传入的Promise实例列表中,只要有一个的状态改变了,那么race返回的那个Promise状态也会变化。他可以用来设置请求超时,比如:

const racePromise = Promise.race([
    readFile('/test.txt'),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('请求超时'))
        },5000)
    })
]);

上面的代码中,如果readFile读取text.txt文件在5秒后还没有成功,那么第二个Promise实例就会变成reject失败状态了,从而racePromise就会变成失败状态。

有了all和race方法,我们就很容易的处理多异步协同问题,而我自己在模仿ES6的Promise实现中,all和race方法使用了哨兵变量来实现这一功能。

总结

以上就是关于Promise/Deferred规范的一点介绍和心得,实际上Promise的出现已经可以比较优雅的解决了异步编程的回调以及异常处理,不过,所有异步代码经过Promise的包装,一眼看上去全是then和catch,代码流程看上去不是很清晰,虽然经过链式调用,写起来勉强看上去算是同步代码,不过离真正的同步代码还是有一些不小的差距的,而这时,就要期待我下一篇介绍的Generator函数和ES7的async函数了,他们和Promsie结合,所编写出来的代码才算是真正的写起来像同步代码的。

顺便贴上一个es6的Promise实现的github地址:https://github.com/stefanpenner/es6-promise。如果你的编码环境不支持es6的promise,又想要使用ES6的Promsie特性的话,那么可以不妨试试这一个。

顺便,贴上仅供交流学习之用的我本人模仿ES6的Promise行为所实现的Promise代码(附件中),不过如果你想要真正在自己的代码中使用ES6的Promise,请使用上面的那个。

附件:https://download.csdn.net/download/qq_33024515/10864408

猜你喜欢

转载自blog.csdn.net/qq_33024515/article/details/85121067