js异步编程解决方案

在JavaScript中,函数作为一等公民,使用上非常自由,无论调用,或者作为参数,或者作为返回值均可。

于是在无论是前端的事件驱动回调函数中,还是在nodejs中的异步IO,我们可以看见大量的回调函数。所谓的回调函数,就是把函数作为参数传入,并在将来的某个时候”回头调用”。

回调函数通常作为异步编程的一个解决方案,但是回调函数有许多问题

回调函数的问题

问题一:回调地狱

1
2
3
4
5
6
7
var fs = require('fs');
fs.readFile('./text1.txt', 'utf8', function(err, data){
console.log("text1 file content: " + data);
fs.readFile('./text2.txt', 'utf8', function(err, data){
console.log("text2 file content: " + data);
});
});

上面是我们在进行nodejs编程的时候经常会遇见的场景。前端进行异步请求的时候也经常会遇见这样的场景。当回调嵌套过深的时候,就会出现以下场景。

1
2
3
4
5
6
7
8
9
10
11
doSomethingAsync1(function(){
doSomethingAsync2(function(){
doSomethingAsync3(function(){
doSomethingAsync4(function(){
doSomethingAsync5(function(){
// code...
});
});
});
});
});

所以这种嵌套过深的情况有时候是不可忍受的,我们称之为“回调地狱”或“回调金字塔”

问题二:异步编程的理解

我们的大脑习惯顺序思考问题,当要做一件事情的时候,我们会思考先做A再做B然后做C…。然而用回调函数写的异步代码则违反了我们天生的思考原则。

你能够很快的说出以下代码的执行顺序吗。

1
2
3
4
5
6
7
8
9
10
11
doA(function(){
doC();

doD(function(){
doF();
})

doE();
})

doB();

对于这样的代码,我们需要很大的努力才可以理解。也就是说,可读性很差。

回调函数的代替解决方案

拆解function

我们可以通过将各部分的任务拆解为单个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getData(count) {
get(`/sampleget?count=${count}`, data => {
console.log(data);
});
}

function queryDB(kw) {
db.find(`select * from sample where kw = ${kw}`, (err, res) => {
getData(res.length);
});
}

function readFile(filepath) {
fs.readFile(filepath, 'utf-8', (err, content) => {
let keyword = content.substring(0, 5);
queryDB(keyword);
});
}

事件发布/订阅模式

采用发布订阅模式进行解耦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const events = require('events');
const eventEmitter = new events.EventEmitter();

eventEmitter.on('db', (err, kw) => {
db.find(`select * from sample where kw = ${kw}`, (err, res) => {
eventEmitter('get', res.length);
});
});

eventEmitter.on('get', (err, count) => {
get(`/sampleget?count=${count}`, data => {
console.log(data);
});
});

fs.readFile('./sample.txt', 'utf-8', (err, content) => {
let keyword = content.substring(0, 5);
eventEmitter. emit('db', keyword);
});

以上两种解决方案确实可以解决一定问题,但终究没有摆脱回调函数的模式。

Promise

ES 6中原生提供了Promise对象,Promise对象代表了某个未来才会知道结果的事件(一般是一个异步操作),并且这个事件对外提供了统一的API,可供进一步处理。

使用Promise对象可以用同步操作的流程写法来表达异步操作,避免了层层嵌套的异步回调,代码也更加清晰易懂,方便维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var fs = require('fs')
var read = function (filename){
var promise = new Promise(function(resolve, reject){
fs.readFile(filename, 'utf8', function(err, data){
if (err){
reject(err);
}
resolve(data);
})
});
return promise;
}
read('./text1.txt')
.then(function(data){
console.log(data);
return read('./text2.txt');
})
.then(function(data){
console.log(data);
});

Generator

Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
yield 'end';
return 'ending';
}
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'end', done: true }

hw.next()
// { value: undefined, done: true }

上面代码定义了一个 Generator 函数helloWorldGenerator,它内部有三个yield表达式(hello和world、end),即该函数有三个状态:hello,world,end 和 return 语句(结束执行)。

然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

async/await

async/await是ES7中的异步解决方案,可以看我的这篇博文。

[译]在10分钟内解释JavaScript Async/Await

结尾

结合以上,我们可以有五种方法来解决回调地狱的问题

  • 拆解function
  • 事件发布/订阅模式
  • Promise
  • Generator
  • async/await