深入理解ES6异步编程

JavaScript 的单线程,如果没有异步编程的话将会苦不堪言。ES6 之前,异步编程的方法,大概有下面4种:

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise对象

无论采用哪种方法,代码都不显得那么优雅。ES6将 JavaScript 异步编程带入了一个全新的阶段。回想起第一次遇见 Generator 时的兴奋之情仍然历历在目。一直以为自己理解了Generator 函数,直到今天有朋友问起一些问题才发现自己根本就没有理解清楚,为了让自己更加清楚明白,参考了阮一峰的深入掌握 ECMAScript 6 异步编程系列的文章后,就有了下文。

主要从4个方面讲述 ES6 的异步编程,分别是Generator、Thunk、Co以及Async (严格说是属于 ES7 的范畴了)。

Generator

如何使用 Generator 函数,执行一个真实的异步任务。

1
2
3
4
5
6
7
var fetch = require('node-fetch');

function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了 yield 命令。
Generator函数已经写好了,但是怎么执行呢,和普通的函数直接调用不一样,为了执行上述的 Generator 函数,需要进行如下操作。

1
2
3
4
5
6
7
8
var g = gen();
var result = g.next();

result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个next 方法。
以上我们可以看到 Generator 函数写起来很简单,像同步编程一样,但是执行起来却很麻烦,所以就有了下文。

Thunk

Thunk 函数的作用是可以用于 Generator 函数的自动流程管理。
以读取文件为例。下面的 Generator 函数封装了两个异步操作。

1
2
3
4
5
6
7
8
9
10
var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);

var gen = function* (){
var r1 = yield readFile('/etc/fstab');
console.log(r1.toString());
var r2 = yield readFile('/etc/shells');
console.log(r2.toString());
};

我们先看如何手动执行上面这个 Generator 函数。

1
2
3
4
5
6
7
8
9
10
11
var g = gen();

var r1 = g.next();
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});

上面代码中,变量 g 是 Generator 函数的内部指针,表示目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)。
仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程。

Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
function run(fn) {
var gen = fn();

function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}

next();
}

run(gen);

上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。
有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。

1
2
3
4
5
6
7
8
var gen = function* (){
var f1 = yield readFile('fileA');
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};

run(gen);

上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。而大名鼎鼎的 Co 便是基于 Promise 的自动执行器。

Co

Co 就是用于 Generator 函数的自动执行。

Co可以省去自己写 run 函数的麻烦,直接引用即可,如下:

1
2
3
4
var co = require('co');
co(gen).then(function (){
console.log('Generator 函数执行完成');
})

为什么 co 可以自动执行 Generator 函数?
前面说过,Generator 函数就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。
两种方法可以做到这一点。

  • 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。

co 函数库其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个库。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。

上面的 run 函数是基于回调函数的自动执行器,下面的 run 函数是基于 Promise 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function run(gen){
var g = gen();

function next(data){
var result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data){
next(data);
});
}

next();
}

run(gen);

Co 的源码就是上面这个自动执行器的扩展,有兴趣的可以去看一下。

Async

就在人们沉浸在 Generate 和 Co 带来的兴奋之中时,ES7 却带来了另一个异步编程的解决方案,即 Async,有人将它称为是异步编程的终极解决方案。
一句话解释 Async,Async 函数就是 Generator 函数的语法糖。

前文有一个 Generator 函数,依次读取两个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fs = require('fs');

var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) reject(error);
resolve(data);
});
});
};

var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

写成 async 函数,就是下面这样。

1
2
3
4
5
6
var asyncReadFile = async function (){
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

一比较就会发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,而async 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。
因此 Async 函数却具有如下几个优点:

  • 内置执行器。 Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行,并且 Async 函数返回一个 Promise 对象,使用 then 方法即可获取到异步操作的最终结果。
1
2
3
4
var result = asyncReadFile();
result.then(function(data) {
console.log(data)
});
  • 更好的语义。 async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。
  • 更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。

Async 函数的实现:

1
2
3
4
5
6
7
8
9
10
11
async function fn(args){
// ...
}

// 等同于

function fn(args){
return spawn(function*() {
// ...
});
}

所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。而 spawn 函数的实现,基本就是前面自动执行器的翻版。

总结

  1. Generator 函数提供了一种函数写法,像同步编程一样,但是执行起来却很麻烦。
  2. Thunk 函数为执行 Generator 函数提供了便利,可以自动执行 Generator 函数。
  3. Co 函数库就是将Thunk 函数自动执行器和 Promise 对象自动执行器的封装,进一步简化了 Generator 函数的执行。
  4. Async 函数就是 Generator 函数的语法糖,将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,而它的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。