再来聊一聊Promise

由于 JavaScript 采用的是单线程异步 IO 的模型,所以其代码编写方式也是多采用回调的方式。结果就是导致代码中充斥着大量的回调函数,而一旦进行多个有序的异步操作,就会造成代码回调嵌套很难看的问题。人们称之为“回调地狱”。

例如这样的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function request(url, param, successFun, errorFun) {
$.ajax({
type: "GET",
url: url,
param: param,
async: true, //默认为true,即异步请求;false为同步请求
success: successFun,
error: errorFun,
});
}
request(
"test.html",
"",
function (data) {
//请求成功后的回调函数,通常是对请求回来的数据进行处理
console.log("请求成功啦, 这是返回的数据:", data);
},
function (error) {
console.log("sorry, 请求失败了, 这是失败信息:", error);
}
);

一旦需要在 request 得到 data 之后再利用该 data 发起新的请求,则必须在第一个回调函数里继续写一个跟外层一样的回调函数,最终会形成层层嵌套的回调函数:

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
request(
"test1.html",
"",
function (data1) {
console.log("第一次请求成功, 这是返回的数据:", data1);
request(
"test2.html",
data1,
function (data2) {
console.log("第二次请求成功, 这是返回的数据:", data2);
request(
"test3.html",
data2,
function (data3) {
console.log("第三次请求成功, 这是返回的数据:", data3);
//request... 继续请求
},
function (error3) {
console.log("第三次请求失败, 这是失败信息:", error3);
}
);
},
function (error2) {
console.log("第二次请求失败, 这是失败信息:", error2);
}
);
},
function (error1) {
console.log("第一次请求失败, 这是失败信息:", error1);
}
);

使用 Promise 可以部分解决这个问题,因为 Promise 将回调函数的传入,变成了在异步方法调用之后来传入(与异步方法的调用是链式的关系)。相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
request("test1.html", "")
.then(function (data1) {
console.log("第一次请求成功, 这是返回的数据:", data1);
return request(data1);
})
.then(function (data2) {
console.log("第二次请求成功, 这是返回的数据:", data2);
return request(data2);
})
.then(function (data3) {
console.log("第三次请求成功, 这是返回的数据:", data3);
return request(data3);
})
.catch(function (error) {
//用catch捕捉前面的错误
console.log("sorry, 请求失败了, 这是失败信息:", error);
});

而由于 Promise 的 then 可以返回新的 promise,所以可以分成多行来写

1
2
3
var p = request()
var pp = p.then(...)
var ppp = pp.then(...)

写起来没有嵌套的那么难看了。

当然,实际上他还是看起来不那么完美,但解决了前端长久存在的一个问题。 而在 ES6 之后,可以考虑使用 co 和 async 特性书写彻底的同步风格的代码。

本文我们先不谈 async,而是主要来聊一聊 Promise 的注意的地方

Promise 的状态

一个 Promise 对象有 3 种状态:

  • pending:初始值,不是 fulfilled,也不是 rejected
  • fulfilled:代表操作成功
  • rejected:代表操作失败

Promise 既可以从 pending 转变为 fulfilled,也可以从 pending 转变为 rejected。promise 对象会最终变成两种状态中的一种, 即成功(resolve)或失败(reject)。 一旦状态改变,就「凝固」了,会一直保持这个状态,不会再发生变化

异步动作发起的时机

Promise 一般是做一件异步的事情,而这个异步动作会在 Promise 创建时 立即执行。假如一个 request 发送请求的函数是返回一个 Promise,那么我们可以这样写:

1
2
var p = request();
p.then(xxx);

在 request 函数调用的时候,这个异步请求已经发送了。也可以说: 只要 Promise 创建,异步动作就立即执行。

对于我们开发者使用 Promise 来封装传统异步函数的时候,我们也要注意。在使用 Promise 的时候,异步动作会立即发起。对应到我们的代码:

1
2
3
4
5
6
7
8
var p = new Promise(function (resolve, reject) {
console.log("开始一个异步动作");
setTimeout(function () {
console.log("异步完成--最后才打印");
resolve(1);
}, 1000);
});
console.log("我在异步开始之后打印");

在上面的代码中,在你创建这个 Promise 对象的时候(调用 new 操作符),你传入的这个匿名函数就开始执行了。所以”开始一个异步动作”会优先打印出来。

总结一下:
new 一个 Promise 的时候,传入的传统异步操作函数会在创建 Promise 对象的过程中 立即执行。 但 resolve 和 reject 却不会立即触发,一般会在异步操作完成的时候调用 resolve 或 reject,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let p = new Promise((resolve, reject) => {
console.log("hi i begin async code");
setTimeout((res) => {
console.log("async finish");
resolve(2);
}, 3000);
resolve(1);
});

p.then((res) => {
console.log(res);
})
.then((rr) => {
console.log("第二次resolve", rr);
})
.then((zz) => {
console.log("第三次resolve", zz);
});
console.log("i after promise then");

上面代码 ‘hi i begin async code ‘ 会最先打印出来, 接下来 ‘i after promise then’ 会打印,这两个打印过程遵循先写的先执行(因为是同步的)。 而 Promise 内部的 resolve 虽然被优先执行了,但是它是改变 Promise 的状态,状态改变后 then 要等到所有同步任务都完成后才触发。所以 then 回调会在接下来触发。 最后会打印 async finish, 异步任务完成。 但是 resolve(2)不会再次改变 Promise 的状态了(因为 Promise 的状态只能改变一次),因此 resolve(2) 什么也不会发生。

一个 promise 能否 resolve 两次

前面已经说过了,Promise 发生状态改变时,会触发 Promise 通过 then 函数注册的 resolve 和 reject 回调函数。那么,如果同一个 Promise 却 resolve 了两次,会发生什么呢?

1
2
3
4
5
6
7
8
9
10
11
let p = new Promise((resolve, reject) => {
console.log("hi i begin async code");
setTimeout((res) => {
console.log("async finish");
resolve(2);
}, 3000);
resolve(1);
});
p.then((res) => {
console.log(res);
});

上面的代码 1 肯定是可以输出的,2 可以输出吗? 答案是不会,3 秒之后会输出’async finish’(说明不会阻止异步任务的执行和回调), 但 resolve 不会再有反应了,而且不会抛出异常。

由于 then 必然是在 Promise 状态变化时触发,因此我们有理由认为第二次 resolve 的时候 Promise 状态并没有发生变化。因此,说明 Promise 内部状态改变后就会凝固,要么是 fullfill 要么是 reject。不可以再多次 resolve,如果确实多次 resolve 了,则 Promise 不会发生任何反应(也不会抛出异常)

直接 resolve 的 Promise 会立即调用 then 吗?

这是个不错的问题。我们使用如下的代码进行了实验:

1
2
3
4
5
6
7
8
var p = new Promise(function (resolve, reject) {
console.log("1");
resolve(3);
});
p.then((res) => {
console.log("promise状态改变了", res);
});
console.log("2");

这段代码是先创建了一个 Promise,根据上文讲解,2 肯定在 1 之后打印;因为创建 Promise 的匿名函数参数会同步立即执行。

但是匿名函数里面如果直接同步调用了 resolve,那么 resolve()函数是同步执行的吗?

这个例子其实也可以该写成这样:

1
2
3
4
5
var p = Promise.resolve(3); // 直接返回一个即将变成resolve(3)状态的promise
p.then((res) => {
console.log("promise状态改变了", res);
});
console.log(2);

经过测试,我们得到的结果都是: 先打印 2 再打印 3. 也就是说哪怕你同步去执行了 resolve 函数,实际上 then 注册的处理函数是会被异步触发的。 这是因为 Promise 规范规定了: 所有回调都是异步的.

想起之前我博客中分析的 JavaScript 任务队列和事件循环机制,可以大概猜测出 resolve 的原理:

实际上 resolve 调用后,会发送一个 microtask 的任务放入 microtask 的任务队列,等待当前 js 线程空闲后来执行。因此 js 同步任务执行完成之后,发现 microtask 任务队列中有一个 resolve 的回调需要执行,所以才打印了 3

多重链式调用

Promise 的真正强大之处在于它的多重链式调用,可以避免层层嵌套回调. 之所以可以链式调用,是因为通过 then 对 promise 添加 onFulfilled 和 onRejected 回调,返回的是一个新的 Promise 实例(不是原来那个 Promise 实例),且返回值将作为参数传入这个新 Promise 的 resolve 函数。

Promise 更多的面试之坑

可以参考这里关于 Promise:你可能不知道的 6 件事

1
2
3
4
5
6
7
8
9
new Promise((resolve) => {
resolve(1);
console.log(4);
// new Promise(r => r(2)).then(e => console.log(e))
Promise.resolve(2).then((e) => console.log(e));
Promise.resolve(5).then((e) => console.log(e));
}).then((v) => console.log(v));

console.log(3);

已经 resolve 过的 Promise,过一段时间再添加 then 可以吗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var p = new Promise((resolve) => {
resolve(1);
}).then((v) => {
console.log(v);
return 2;
});

setTimeout(function () {
p.then((res) => {
console.log(res);
});
}, 3000);

console.log(3);

一个 Promise 可以添加多个 then 吗,也可以过一段时间再添加 then 吗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var p = new Promise((resolve) => {
resolve(1);
});

p.then((v) => console.log(v));

p.then((res) => {
console.log(res);
});
setTimeout(() => {
p.then((go) => {
console.log("gogo" + go);
});
}, 4000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var p = new Promise((resolve) => {
setTimeout(() => {
console.log("开始resolve");
resolve(1);
}, 1000);
});

p.then((v) => console.log(v));

p.then((res) => {
console.log(res);
});
setTimeout(() => {
p.then((go) => {
console.log("gogo" + go);
});
}, 4000);

从表现上看,microtask 在调用 then 函数的时候,如果 Promise 还没有 resolve,则 Promise 会把这个回调任务记录下来等到 Promise resolve 的时候,把它们放入 microtask 任务队列;如果 then 函数调用时 Promise 已经 resolve 了,那么 Promise 会在此时直接把 then 注册的回调放入 microtask 任务队列。

refer

初探 promise
从 Promise 来看 JavaScript 中的 Event Loop、Tasks 和 Microtasks