远离回调地狱,聊聊promise与async

从大学到工作,写了差不多4年的javascript了,经常听到人们谈论起js时,总离不开几个常规吐槽:”写一个类真是太痛苦了,连继承的函数都得自己写”,”我靠,怎么这个地方的this指的不是当前对象啊??”,”回调写起来简直就是噩梦”。前几个问题要么在新的ES规范中得到了解决,要么是需要自己认真了解js的相关特性,不过最后关于回调的吐槽是每个jser确实难以忍受的噩梦。

回调地狱:

什么是回调地狱?这个词可能是js的专属了,我们来看一个例子:

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

function request1(param, callback) {
//do somthing
//do callback
callback();
}

function request2(param, callback) {
//do somthing
//do callback
callback();
}

function request3(param, callback) {
//do somthing
//do callback
callback();
}

function request4(param, callback) {
//do somthing
//do callback
callback();

}

var result = request1(param, function () {
request2(param, function () {
request3(param, function () {
request4(param, function () {
// ... ...
});
});
});
});

这样的情况想必大家都是遇到不少了的,回调里面走回调,然后继续回调,如果还遇上一些复杂的判断或者其他的什么运算,能让这个回调过程惨不忍睹,一旦出现错误,或者有新的需求要改代码,由于其耦合度太高,更是要大动刀子。所以,在js中便有了回调地狱这样的说法。

这里真的要赞扬一下ES6的出现,为js做出了太多给力的改进,很多之前反人类的设计在ES6里面都得到了很棒的修正,这里我们就来看看promise与async函数带来的便捷。

Promise

先来看看阮一峰的《ECMAScript6入门》中对promise的介绍吧:

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

首先,promise是一个对象,他为异步操作提供了一个容器,可以在其中进行相关的异步操作。

他有三个状态:pending(进行中),fulfilled(已成功,可以进行下一步操作),rejected(已失败)。通过异步操作的结果来确定最后的状态是什么,并且,一旦状态确定,其他的操作是无法改变其状态的。

话不多少,先上代码:

1
2
3
4
5
6
7
8
9
10
11

var myPromise = new Promise(function (resolve, reject) {
setTimeout(function () {
console.log("timer run");
resolve("result resolve");
}, 2000);
});

myPromise.then(value => {
console.log(value);
});

把setTimieout当做一个异步操作,当返回相关结果时,将promise的状态设置为resolve,并将相关结果传入resolve函数。然后触发myPromise的then()方法,最终输出相关的结果。

这里要注意一点,new Promise()里面的函数是在你声明完成之后立即执行的。

像下面的:

1
2
3
4
5
6
7
8

var myPromise = new Promise(function (resolve, reject) {
setTimeout(function () {
console.log("timer run");
resolve("result resolve");
}, 2000);
console.log("run immediately");
});

虽然你没有执行myPromise函数,但是控制台还是输出了run immediately字样。

这里再看看then方法,它有两个参数:

1
2

myPromise.then(resolve=>{},reject=>{});

第一个resolve是函数正确执行时候的返回,第二个reject是回调失败后的返回。就像我们发起一个ajax请求,总有成功和失败的情况一样,以前我们需要自己来定义失败后执行的流程,而promise已经为我们准备好了失败后的方法。then函数里面如果有返回对象,那么这个对象一定是Promise对象。

看看一个真正的ajax请求吧:

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

var getAjax = function (url) {
return new Promise(function () {
var result = function () {
if (this.status == 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText))
}
}

var xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onreadystatechange = result;
xhr.responseType = "json";
xhr.setRequestHeader("Accept", "application/json");
xhr.send();
})
}

getAjax("/test.json").then(result => {
console.log(result);
}, error => {
console.log(error);
});

客户端发起请求,当响应结果时调用result函数,根据结果将promise的状态设置为resolve或者reject,然后再触发then函数来输出最后的结果。

so,promise的大致用法就是这样,看着并不是很难。接下来我们要来解决下之前我们遇到的回调地狱问题。首先我们得把之前的request1… … request4用promise的写法来写,就像我们之前的getAjax函数一样:

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

var request1 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data);
}, 200)
});
}

var request2 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data + 1);
}, 200)
});
}

var request3 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data + 1);
}, 200)
});
}

var request4 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data + 1);
}, 200)
});
}

request1(1)
.then(data => {
return request2(data);
})
.then(data => {
return request3(data);
})
.then(data => {
return request4(data);
})
.then(data => {
console.log(data);
})
.catch(error => {
console.log(error);
});

// 最后的结果 1 1 2 3 4

这样看起来是不是比之前无限嵌套的回调函数要舒服多了呢。

这里要注意,如果要形成链式调用,那么return是必不可少的。他会将返回值作为下一个resolve函数的参数

我们在函数的末尾加了一个catch函数,它能捕捉到之前then中所抛出的错误,包括reject中的错误。值得注意的是,如果抛出了错误,整个then的调用链就会在错误抛出的函数中被终止,不会再接下里执行了,也就是,如果request2中的异步结果被reject,那么后续的request3,request4都不会执行。

关于promise最后我们再聊一下Promise的静态方法。

  1. Promise.resolve()

当需要将现有对象转换为Promise对象时,我们就可以用Promise.resolve这个方法了。

1
2
3
4
5
6
7
8

var test = Promise.resolve("hello");

test.then(function(txt){
console.log(txt);
});

// 输出 hello

这里的Promise.resolve()可以接受一个参数,如果该参数是一个非对象,则返回一个新的Promise对象,状态为resolve。

1
2
3
4
5
6
7
8
9
10
11

var testObj = {
then:function(resolve,reject){
resolve("hello Promise")
}
}
var test = Promise.resolve(testObj);

test.then(function(value){
console.log(value);
});

如果这个参数是一个包含了then方法的对象,立即执行这个对象的then方法,将其转换为resolve状态。

  1. Promise.all()

这个方法是将多个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

var request1 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data);
}, 400)
});
}

var request2 = (data) => {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(data);
resolve(data + 1);
}, 200)
});
}

Promise.all([request1(1),request2(2)]).then(([result1,result2]) =>{
console.log(result1,result2);
});

// 输出结果: 2, 1 , 1 3

只有当所有的Promise都返回resolve的时候,Promise.all才会返回resolve,然后其结果会被返回为一个数组。如果有一个Promise状态是reject,那么Promise.all的状态就会变成reject,最先返回reject的返回值,传递给Promise.all的回调函数。

promise还有很多不错的特性,我这里推荐大家去看阮一峰的《ECMAScript 6入门》。大神讲的非常详细,我这里只简单说下上手用法。

async

说完了promise,我们对ES6中新的回调写法算是有了一个初步了解。接下来我们得继续聊一聊和回调非常相关的,js的另一个特性:异步。

都知道js是一门单线程的语言,所以当需要处理多任务的时候,就离不开异步这个东西了。试想一下,我们需要操作某个文件,首先执行了打开文件的指令,可能这个文件有点大,需要5秒的时间来打开,那么,如果不做异步处理,用户就得等着5秒过去,才能执行下一步操作,如果下一步操作又需要5秒……由此可见,异步的使用,在js开发中是一个非常重要的技术项。

那么我们是怎么处理异步任务的呢。

方法也是简单粗暴:回调

回到之前的例子

1
2
3
4
5
6
7
8
9
10
11
12

// consume a lot of time
function readFile(){
// some code
}

function processFile(){
// some code
}

readFile();
processFile();

这里有两个函数,readFile是一个非常消耗性能的函数,可能需要很久的时间,而processFile函数需要等待readFile所带回的结果才能执行,那么这就非常尴尬了。为什么我们要白白的去浪费时间去等待上一个任务的执行呢??

于是,

1
2
3
4
5
6
7
8
9
10
11
12
13
14

// consume a lot of time
function readFile(callback){
setTimeout(function(){
// some code
callback();
},100);
}

function processFile(){
// some code
}

readFile(processFile);

聪明的js开发者想到了用回调的方法,我们用setTimeout来让他在主线程(不是真的线程)执行后开始执行,然后通过回调,来获取耗时操作所返回的结果。

这样确实可以解决一些问题,但是回调这种搞法稍不注意就会变成上面的地狱模式,虽然有了Promise,一大长条的then函数让人也看着不是很舒服吧。

ok,于是聪明的js开发者又想到了新的方法,我们可以用发布订阅模式呀。我们在processFile中做好订阅监听,同样是同样利用setTimeout,把最后返回的结果发布出去不就可以了吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// consume a lot of time
function readFile(callback){
setTimeout(function(){
// some code
dispatchEvent("MyEvent",data)
},100);
}

function processFile(){
registEvent("MyEvent",function(data){
// some code
})
}

readFile();
processFile();

(这里的dispatch和registEvent,需要自己去实现。)

你说都到了ES6 ES7 ES8的年代了,还没有个稍微能让人看着舒服得异步处理的解决方法吗?

async与await出现了。

其实在async之前还有generator这个函数,只是在目前为止,大家都倾向于将async函数作为js异步的最终解决方案。所以这里我就不再展开说generator函数了,这里有详细的关于generator的介绍。

但是,我们必须知道,async和awiat是以generator为基础封装的,只是为使用generator提供了更便捷的方法,是generator的语法糖,所以,有时间去了解下generator函数也是很有必要的。

好了话不多少,看看关于async这个家伙该怎么用。

回到之前的那个读取大文件的例子,如果我们用async来写:

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

// consume a lot of time
function readFile(filename) {
return new Promise(function (resolve, reject) {
// read file
if (/*success*/) {
resolve(result);
} else {
reject(result);
}
});
}

function processFile(data) {
// some code
return result
}

async function main() {
var data = await readFile(); // 直到返回结果才会执行下一步
var result = processFile(data);
return result
}

main().then(result=>{
console.log(result);
});

这里我们首先将readFile函数改造成一个返回值为Promise的异步函数,然后在之后声明了 async function main。

function之前加上了async关键字,表明这个函数为一个异步函数,这个是必须的,只有异步函数内部,才能使用await关键字来获取返回值。

这里main函数在执行的时候,会等待readFile()所带回来的返回值,也就是说,当执行到这里时,程序的执行权交给了readFile方法,当readFile返回结果后,才会进行下一步操作。await就是等待的意思,await后面所跟的函数,是一个Promise对象(如果不是Promise对象,会直接调用Promise.resolve方法转为一个立即执行的Promise)。

来看看一个可以执行的案例,和上面的结构基本一致,最后会在两秒后输出hello world。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

function timeout(value, ms) {
return new Promise((resolve,reject) => {
setTimeout(function(){
resolve(value)
}, ms);
});
}

async function asyncPrint(value, ms) {
var result = await timeout(value, ms);
return result;
}

asyncPrint('hello world', 2000).then(result => {
console.log(result);
});

多个异步函数:

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 timeout(value, ms) {
return new Promise((resolve, reject) => {
setTimeout(function () {
resolve(value)
}, ms);
});
}

function timeout2(value, ms) {
return new Promise((resolve, reject) => {
setTimeout(function () {
var temp = value + " sangliang";
resolve(temp)
}, ms);
});
}

async function asyncPrint(value, ms) {
var result = await timeout(value, ms);
console.log(result);
var result2 = await timeout2(result,ms);
return result2;
}

asyncPrint('hello world', 2000).then(result => {
console.log(result);
});

console.log("bye bye world");

// 输出顺序 bye bye world
// hello world
// hello world sangliang

进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。——《ECMASCRIPT 6 入门》

在这里,了解过generator的同学应该可以看出一些相似点了。

async就和generator函数中的*相似,而await和yield又是相似的。只是关于gererator的执行需要一个单独的执行函数,这里async方法在此给我们做了精简,使用起来更加方便了一些。

最后说下async的错误处理,第一种方式,是在执行async函数时,在函数的最后catch错误:

1
2
3
4
5
6
7
8
9
10
11

async function test() {
await new Promise(function (resolve, reject) {
throw new Error("My error");
// reject("My error") 同样可以被catch到
});
}

test()
.then(value => { console.log(value) })
.catch(error => { console.log(error) });

这种方式比较便捷,能处理所有的async函数中,await所抛出的错误,包括promise中的reject状态。但是这样也是有局限性,如果我的async中有多个异步,那么只要有一个异步抛出了错误或者被reject掉,那么整个async中的函数都不能进行了,直接跳到了最后的catch中,而很多时候,我们希望哪怕有两个异步是失败了,但是还是可以继续执行我需要的功能。那么我么可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

async function test() {
try{
await new Promise(function (resolve, reject) {
reject("first step Error");
});
}catch(e){
console.log(e);
}


return await new Promise(function (resolve, reject) {
resolve("second step")
});
}

test()
.then(value => { console.log(value) })
.catch(error => { console.log(error) })

我们在await函数之前使用try…catch语句,让错误直接在async中就得到处理,这样,不论前一个await返回什么样的结果,我们最后的await都能得以返回,不会被终止。

最后,说下关于两个await同时进行的写法。写代码总是有先后顺序的,而await又是对先后顺序非常敏感的(需要等待前一个await执行完),那么,如果我们需要将两个await同时进行该怎么做呢?

还记得我们之前的Promise.all方法吗,这里同样可以发挥作用:

1
2
3
4
5
6
7
8
9
10
11

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

// ---- 引用自《ECMAScript6 入门》

最后

关于回调与异步,说到这里也差不多了。文章给出了ES6中promise与async函数的基本使用,如果之前从没使用过,那么现在应该有个基本的概念了。其中还有很多细节的方法与技巧,我这里就不多说了,大家如果有需要,自然会去查找更高阶的教程。