关于Promise的一些知识

  • 预备知识,关于bind,call等方法
  • Promise的使用
  • 实现一个Promise
  • 略长,慎入

预备知识

有一些有趣的东西需要先了解一下,比如在functionthis的指向很容易把人搞晕。
因此我们需要看看JS中的bind方法和callapply方法等。
这对我们会很有帮助,当然不看也没有关系,有机会的时候再看看就ok了。

bind

举个例子如果有如下代码:

1
2
3
4
5
6
7
function Foo(name) {
this.name = name
this.printName = function() {
console.log(this.name);
}
}
new Foo('Balabala').printName();

此时我们的输出是

1
Balabala

但是如果我们这么干:

1
2
3
const balabala = new Foo('balabala');
let balabalaPrintName = balabala.printName;
balabalaPrintName();

此时的输出是:

1
undefined

为什么呢,我们需要知道的一件事是,在方法里面的this总是指向了调用者。
我们在把balabala对象的printName方法赋值给balabalaPrintName的时候,balabalaPrintName只是变成了一个内部使用了this的方法,而不是一个某名为balabalaFoo对象专有的printName
因此我们在调用balabalaPrintName的时候,this指向了window对象,而window对象并没有name属性,所以输出就变成了undefined
如果我们不把printName写在Foo方法里面,而是用Foo.prototype.printName的话,这件事就更好理解了。

我们可以试着给window加一个属性再回来看看效果:

1
2
name = "Walawala";
balabalaPrintName();

输出是:

1
Walawala

这样我们就能理解这件事情了,当然我们想要的是一个能输出BalabalabalabalaPrintName方法,而不是让它输出window.name,这时候我们就可以使用bind了。

先解释一下这个方法,这里提到的bind也就是Function.prototype.bind,我们可以在每一个Function对象后面接上.bind(某些参数)来调用它。Function对象就是比如我们定义的方法,是这些方法本身,不是它们调用之后的结果,调用之后的结果的类型就是这个方法的返回值的类型,不是Function了。

bind方法可以接受一个或者多个参数,第一个参数会作为调用bind方法的方法(Function的对象,我们称它为方法A吧)在被调用时的调用者,也就是方法A被调用时方法内的this会指向方法A.bind方法调用时传进去的第一个参数。而bind方法后面的参数则会塞进方法A调用时的参数列表的开头。
因此我们除了可以让一个方法绑定调用它的对象之外,还可以使用bind方法来写一些偏函数,这个概念在python里面也有,就是固定了方法的部分参数。
如果我们通过python类里面的非静态方法来理解这个bind方法,其实它就是把调用bind时传的所有参数按顺序地传给了调用bind的方法,作为它被调用时候的前面的几个参数。python里成员方法第一个参数是self,在JS里面对应的就是this

好我们该动手了,把之前的balabala改一改:

1
balabalaPrintName = balabala.printName.bind(balabala);

这样不是很美观,不过这是使之前的让人迷惑的赋值方式变得正确的做法。假设我们之前把方法写在了prototype里面,就是这样子:

1
balabalaPrintName = Foo.prototype.printName.bind(balabala);

那么我们调用balabalaPrintName的结果就是:

1
Balabala

这时候就达到我们想要的效果了。有一个类似的坑是这样的:

1
2
const write = document.write;
write("<p>Hello</p>");

显然这个是会报错的,原因就是write里面也用了this来指向调用它的document对象的一些属性,我们将window作为调用者就会出问题了。

我们顺带再看看偏函数的写法:

1
2
3
4
5
function add() {
const args = Array.from(arguments);
return args.reduce((a, b) => a + b);
}
const add3 = add.bind(null, 3);

这时候我们就可以用add3(1,2,3)来得到9了。这个例子好像有一点点无聊,想个例子还是得动动脑。
那么bind到此为止。再看看call,或者还有callbind一起打的操作。

call

call的作用是调用一个方法,并将this的指向和参数传入。

比如一个判断类型的方法:

1
Object.prototype.toString.call({});

会输出

1
[object Object]

apply

apply和call的区别在于传参数的形式。

Promise的使用

不用Promise的时候

首先是为什么我们需要使用Promise,Promise的效果是怎么样的。

我们先看看假如我们需要干一件事情,然后干完之后再干下一件事情,应该怎么写呢,不知道怎么写,所以我们需要思考:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getSolution(next) {
console.log("开始思考");
setTimeout(() => {
console.log("我想到办法了!");
next();
}, 2000);
}

function writeSolution() {
console.log("拿个小本本把这个办法记下来。");
}

getSolution(writeSolution);

通过思考我们得到了一个,在找到办法之后再记下来的办法,可以达到同步的效果,这个例子还不是很复杂。我们再想一想,这个办法好不好呢,可能又需要评估一下,找到一个更好的办法,那我们再改改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function getSolution(next) {
console.log("开始思考");
setTimeout(() => {
console.log("我想到办法了!");
next(Array.from(arguments).slice(1));
}, 2000);
}

function getBetterSolution(next) {
if(next instanceof Array) {
next = next[0];
}
console.log("看看有没有更好的办法……");
setTimeout(() => {
const rand = Math.random();
if(rand > 0.5) {
console.log("找到了更好的办法!");
next();
}else if(rand > 0.3) {
console.log("想不出来,再挣扎一下试试…");
getBetterSolution(next);
}else {
console.log("不想了,放弃治疗了");
next();
}
}, 1000);
}

function writeSolution() {
console.log("拿个小本本记下来。");
}

getSolution(getBetterSolution, writeSolution);

可能的结果是这样的:

1
2
3
4
5
6
7
开始思考
我想到办法了!
看看有没有更好的办法……
想不出来,再挣扎一下试试…
看看有没有更好的办法……
找到了更好的办法!
拿个小本本记下来。

好,我们似乎达到了想要的效果!那要不再多一个记完了出去玩……这时候又要改不少了。不管是把后面的过程直接写在setTimeout里面,还是像上面写的一样尽量想办法套着写,感觉都不是很优雅而且如果需要添加一些或者删除一些的时候需要做的改动也不少。如果我们用Promise就可以有一个比较优雅的写法。在进入下一步之前,还是先放一个传说中的回调地狱的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function getSolution() {
console.log("开始思考");
setTimeout(() => {
console.log("我想到办法了!");
getBetterSolution();
}, 2000);
}

function getBetterSolution() {
console.log("看看有没有更好的办法……");
setTimeout(() => {
const rand = Math.random();
if(rand > 0.5) {
console.log("找到了更好的办法!");
writeSolution();
}else if(rand > 0.3) {
console.log("想不出来,再挣扎一下试试…");
getBetterSolution();
}else {
console.log("不想了,放弃治疗了");
writeSolution();
}
}, 1000);
}

function writeSolution() {
console.log("拿个小本本记下来。");
}

getSolution();

看起来也差不多,如果没有递归感觉可以把东西都写进去,可以让嵌套看起来更令人难受一点。好现在该用Promise来解决问题了。

使用Promise的时候

解决上述的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function getSolution() {
return new Promise((resolve, reject) => {
console.log("开始思考");
setTimeout(() => {
console.log("我想到办法了!");
resolve();
}, 2000);
});
}

function getBetterSolution() {
return new Promise((resolve, reject) => {
console.log("看看有没有更好的办法……");
setTimeout(() => {
const rand = Math.random();
if(rand > 0.5) {
console.log("找到了更好的办法!");
resolve();
}else if(rand > 0.3) {
console.log("想不出来,再挣扎一下试试…");
getBetterSolution().then(resolve);
}else {
console.log("不想了,放弃治疗了");
resolve();
}
}, 1000);
});
}

function writeSolution() {
console.log("拿个小本本记下来。");
}

getSolution().then(getBetterSolution).then(writeSolution);

OK这个就是刚才的思考问题的问题的使用Promise的写法,注意递归的时候也需要使用then。我们可以通过then,then,then来组织需要按顺序进行的方法的顺序,对比一下上面的写法,显然比较灵活。
使用Promise来解决问题的时候我们需要把原来的方法返回值改为一个Promise对象,传入的参数是一个方法,而这个方法有两个参数resolve和reject。当这个方法里的任务顺利完成,可以进行下一阶段的任务时,就调用resolve,如果中途出了错误,不能继续接下来的任务,就调用reject。
将方法修改为上述形式之后,配合then方法,我们就可以将任务按顺序地进行。这里then方法返回的仍是一个Promise对象,因此我们可以通过链式调用的方式来串联多个任务。

使用reject来处理错误或异常状况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function getSolution() {
return new Promise((resolve, reject) => {
console.log("开始思考");
setTimeout(() => {
console.log("我想到办法了!");
resolve();
}, 2000);
});
}

function getBetterSolution() {
return new Promise((resolve, reject) => {
console.log("看看有没有更好的办法……");
setTimeout(() => {
const rand = Math.random();
if(rand > 0.5) {
console.log("找到了更好的办法!");
resolve();
}else if(rand > 0.3) {
console.log("想不出来,再挣扎一下试试…");
getBetterSolution().then(resolve);
}else {
console.log("不想了,放弃治疗了");
reject();
}
}, 1000);
});
}

function writeSolution() {
console.log("拿个小本本记下来。");
}

function giveUp() {
console.log("嘤嘤嘤,去玩好了。");
}

getSolution().then(getBetterSolution).then(writeSolution, giveUp);

我们可以达到这样的效果:

1
2
3
4
5
开始思考
我想到办法了!
看看有没有更好的办法……
不想了,放弃治疗了
嘤嘤嘤,去玩好了。

all方法

Promise对各种需要有顺序的情况的写法都做了封装,使我们可以愉快地使用Promise来完成这些问题。比如当我们有一系列的任务,我们需要在任务A, B都完成之后再进行C,可以这么写:

1
Promise.all([A(),B()]).then(C);

race方法

或者我们只需要A、B某个完成之后就执行C,可以这么写:

1
Promise.race([A(), B()]).then(C);

需要注意,在then中传入的是一个方法,也就是一个Function的对象。Promise会在正确的时机执行它,如果返回的是一个Promise,我们可以继续保持Promise要求的顺序,如果是普通的方法,则接连的then中的方法并不保证是同步的。但是在all和race方法时,传入的list里面的元素是Promise的对象而不是一个方法,需要注意区分。
此外我们可以将Promise.all作为then的参数传入从而将各种复杂的情况组合起来。可能需要使用.bind来绑定all的参数。

此处需要补一段示例代码。

自制Promise

需要完成一些Promise的功能,我们把这个称为Qromise吧。

Promise的状态

在Promise里面有三个状态,pending、fullfilled和rejected,分别表示未完成,成功执行,出现异常的三种情况。
为什么需要这三种情况呢?
我们首先看看我们的回调写法,在setTimeout的时候把需要进行的事情写在setTimeout里执行完需要先执行的部分之后。在使用Promise时对应的地方变成了执行resolve()方法。
如果Promise总是知道下一步要干什么,那么resolve方法就去执行下一步要调用的方法就可以了。但是Promise并不总是知道。
我们在使用这样的一个语句的时候:(A和B返回了Promise)

1
A().then(B)

我们是在A里面的Promise构造完成之后,调用.then告诉A,下一步要执行B。我们可以想象比如在如下的情况:

1
2
3
4
5
function A() {
return new Promise((resolve, reject) => {
resolve();
})
}

如果resolve是下一步要执行的方法,就会产生一个矛盾,因为我们还没有把下一步告诉这个Promise,我们是在接下来的then方法里面将B传进去的。
而我们如果在then方法里面来执行下一步,如果A里面没有异步任务而是直接执行完成了,那么我们在then里面再把B执行了就ok了。但是如果A里面有异步的任务,就会导致B里面的动作先开始执行了,A里面的异步任务才执行,这和我们使用Promise的初衷不符合,无法达到顺序执行的效果。也就是这样的情况:

1
2
3
4
5
function A() {
return new Promise((resolve, reject)-> {
setTimeout(resolve, 2000);
});
}

如果我们马上接一个then,那么A中的Promise会在设置了timeout之后立即返回,然后调用了.then方法,如果我们在then方法里执行了B里面的任务,它的时机就会比resolve早了。
但是在这种情况下我们会发现,因为执行resolve()的时机比then要晚了,那么执行resolve的时候,我们就可以知道下一步要做什么了,这时候我们在resolve时执行B,就可以达到我们想要的效果。
也就是说,对于A中有异步任务和没有异步的两种情况,我们可能分别要在then方法或者是传给Promise的resolve方法里面来执行下一步的B方法。而一个比较好的解决方案就是我们将任务执行的状态进行记录,将下一步需要执行的方法也进行记录。在执行then方法时,如果A任务的状态是已经执行完成了,那么我们可以放心地直接执行B。如果A任务状态未完成,那么resolve方法也还没有被调用,那么在resolve的时候就需要知道下一步是什么,所以我们在then方法中将B任务存储到A的Promise对象的某个属性中,resolve时再从中获取这个任务,就可以执行了。

实现resolve和reject

基于上面的想法,我们可以把我们的Qromise的基本功能写出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Qromise(constructor) {
this.success = null;
this.fail = null;
this.state = "pending";
//resolve和reject需要确保调用时this指向Qromise对象
constructor(this.resolve.bind(this), this.reject.bind(this));
}

Qromise.prototype.resolve = function() {
this.state = "fullfilled";
if(this.success) {
console.log("resolve method call next");//这句只是演示用
this.success();
}
}

Qromise.prototype.reject = function () {
this.state = "rejected";
if(this.fail) {
this.fail();
}
}

Qromise.prototype.then = function (success, fail) {
if(this.state =='fullfilled') {
console.log("then method call next");//这句只是演示用
success();
}else if(this.state == 'rejected') {
fail();
}else {
this.success = success;
this.fail = fail;
}
}

这样我们就把一个支持then的Qromise写完了,我们在then方法和resolve方法里面加点打印(为了方便,reject就不测试了),然后用下面的四个情况来验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function A() {
return new Qromise((resolve, reject) => {
console.log("AAAAA");
resolve();
});
}

function B() {
return new Qromise((resolve, reject) => {
setTimeout(() => {
console.log("BBBBB");
resolve();
},1000);
});
}

//情况1
A().then(B);
//情况2
const qromiseA = A();
qromiseA.then(B);
//情况3
B().then(A);
//情况4
const qromiseB = B();
qromiseB.then(A);//一秒之后再输入这一条。

输出分别如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//1
AAAAA
then method call next
BBBBB

//2
AAAAA
then method call next
BBBBB

//3
BBBBB
resolve method call next
AAAAA

//4
BBBBB
then method call next
AAAAA

我们可以发现在这些情况下,分别由then或者resolve执行了下一步需要执行的方法,并且与firefox中使用Promise的情况是一致的。接下来把验证输出的打印去掉就可以了。

当然Promise支持的东西还有很多,比如当A、B方法有参数的时候,resolve需要执行不止一个方法的时候,以及Promise.all和Promise.race方法的实现等等。有时间在后面把它们补上。

实现链式调用

等一下,真的完成了吗?我感觉链式调用是有问题的,再试试这个:

1
A().then(A).then(B);

OK,报错了,因为我们没有返回Qromise对象,所以不能再继续链式调用了。需要做一些修改,同时我们还需要考虑在then和在resolve中执行的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
const QromiseState = {
pending: Symbol(),
fullfilled: Symbol(),
rejected: Symbol()
}

function Qromise(constructor) {
this.successList = [];
this.failList = [];
this.state = QromiseState.pending;
this.finished = false; //为true时交给下一层的Qromise来执行
constructor(this.resolve.bind(this), this.reject.bind(this));
}

Qromise.prototype.resolve = function() {
this.state = QromiseState.fullfilled;
this.handleSList();
}

Qromise.prototype.reject = function () {
this.state = QromiseState.rejected;
this.handleFList();
}

Qromise.prototype.handleSList = function () {
if(this.finished) {
return;
}
for(let i in this.successList) {
const next = this.successList[i];
const result = next();
if(result instanceof Qromise) {
this.successList = this.successList.slice(i + 1);
this.failList = this.failList.slice(i + 1);
result.successList = this.successList;
result.failList = this.failList;
if(result.state == QromiseState.fullfilled) {
result.handleSList();
}else if(result.state == QromiseState.rejected) {
result.handleFList();
}
this.finished = true;
return;
}
}
this.successList = [];
}

Qromise.prototype.handleFList = function () {
let next = this.failList.shift();
next();
}

Qromise.prototype.then = function (success, fail) {
this.successList.push(success);
this.failList.push(fail || function(){});
console.log(this.successList);
if(this.state == QromiseState.fullfilled) {
this.handleSList();
}else if(this.state == QromiseState.rejected) {
this.handleFList();
}
return this;
}

我们使用几个链式调用来试试效果,先定义几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function A() {
return new Qromise((resolve, reject) => {
console.log("AAAAA");
resolve();
});
}

function B() {
return new Qromise((resolve, reject) => {
setTimeout(() => {
console.log("BBBBB");
resolve();
}, 1000);
});
}

function C() {
setTimeout(() => {
console.log("CCCCC");
}, 1000);
}

然后试一下这个:

1
A().then(B).then(A).then(B).then(C).then(C).then(A);

输出:

1
2
3
4
5
6
7
AAAAA
BBBBB
AAAAA
BBBBB
AAAAA
CCCCC
CCCCC

B是异步任务,但返回了Qromise,因此我们保证了B执行完成之后再执行A,B执行完成之后再执行后面的C、C、A。但是由于C并没有返回Promise,所以此处我们不保证两个C和A的log顺序,直接依次调用C、C、A即可。

效果已经达到了,虽然还没有验证reject的效果,不过我们可以先来对比一下一开始的版本,看看为了实现链式调用做了哪些改动。

我们将then中传进来的任务全部记录在了list中,包括任务正常完成和非正常完成时的情况,然后在then方法中返回了自身。通过这种方式我们可以在链式的then的调用中接收到所有的任务。

Qromise会在状态变为fullfilled时执行正常完成时的下一步的任务,如果下一个仍是使用了Qromise的任务,我们也需要确保等这个任务状态为fullfilled时再执行后面的任务。当然如果不是一个使用了Qrmoise的任务,我们调用完继续往下就好了。

对于连续的Qromise任务,我们会递归地使用Qromise来解决问题,将外层的任务列表中已经完成的部分去掉之后,内层的任务列表直接指向外层的任务列表,也就是共享了任务队列,这样我们在继续调用then获取新任务的时候,总是可以将它传给内层的Qromise。

而外层的Qromise在将任务交给内层Qromise后,将一个将finished的属性置为true,就可以不再继续处理后续的任务了。

简化后的整个处理resolve的模型如下:

  1. 使用一个队列Q维护所有的任务。
  2. 调用then时,新任务入队。
  3. 状态为fullfilled时,准备处理队列中的任务,进入4。
  4. 将队列中的第一个任务出队列并执行,如果结果是一个Qromise,进入第5步,否则执行第4步。
  5. 将当前Qromise状态记为finished。
  6. 将4中处理的任务返回的Qromise的任务队列指向Q,进入Q中的3。

注意的是,接任务的总是最先被初始化的Qromise,只有它的then方法会被调用,并且返回自身,而不是在then的时候返回then中任务执行后返回的Qromise。

如果我们返回的是then中传入的任务执行后返回的Qromise,会有下面的两个问题:

  1. 不返回Qromise的方法传入后,无法继续链式调用,因为返回值不再是Qromise了。
  2. 传入的任务需要等待前面的任务完成后执行,不能立即得到Qromise,导致暂时还没有正确的Qromise对象来调用then。

第二点类似于如下的情况:

1
A().then(B).then(C)

我们如果需要让B执行后返回的Qromise对象来调用then,会发现,在A状态为fullfilled之前,B并不会被执行,我们无法得到它返回的Qromise对象。
为了解决这个问题,我们可能需要使用代理的Qromise,先帮它接了这个then里的参数。那么我们采取的实现方式就是,直接使用第一个Qromise来接收所有的任务,在需要交给下一层的Qrmosise时,再将处理权转交,也就是上面给出的处理方式。

那么我们现在就搞定链式调用了,这个Qromise基本上可以用啦。