JavaScript中的异步编程 下

本文启发于阮一峰老师的深入掌握 ECMAScript 6 异步编程

上篇传送门JavaScript中的异步编程 上

上篇说到了使用回调的思路解决JavaScript中异步编程的难题。可不论是显式的指定回调函数,通过事件绑定响应还是通过事件订阅、promise.then,都和逃不出回调的思路。写起来仍不够自然,且在批次回调任务时难以解决。

有没有办法能使我们像平时写同步代码那样,来书写异步代码呢?ES6出现后,Generator对象给了我们这个机会。

生成器函数

提到生成器函数前,需要提到协程(coroutine)这个概念。协程是轻量级用户态的线程。用户可以手动控制协程的暂停和继续,配合线程实现异步任务。协程间通过yield方式切换执行权,并交换信息。就像下面这样:

1
2
3
4
function asyncFunc() {
// 执行someFunc后交出执行权
var t = yield someFunc();
}

协程在遇到yield关键字时交出自己的执行权,直到执行权返回。这里someFunc方法可以是一个异步操作。

ES6中协程体现在Generator函数中。函数在function关键字后添加星号*以示和普通函数的区分。Generator函数是可以通过yield暂停执行的。比如:

1
2
3
4
5
6
7
8
9
function* gen () {
for (let i = 0; i < 10; i++) {
yield i;
}
}

var g = gen();
g.next(); // 1
g.next(); // 2

Generator函数的调用通过next方法完成。每次调用后会将函数流程移动到下一个yield语句处。yield的返回包含两个属性valuedone。前者代表yield的返回值,后者代表生成器函数是否已经执行完毕。

同时,每次调用next方法时,可以输入参数作为上个异步任务的返回值。调用throw方法可以向生成器函数内抛出错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* gen () {
var x = yield 1;
try {
yield x;
} catch (e) {
console.log(e);
}
}

var g = gen();
g.next(); // 1
g.next(2); // 2
g.throw('some error') // 'some error'

使用Generator函数封装一个异步操作,再通过执行器管理函数内部的异步流程。通过这种方式,在Generator函数中可以很方便地书写异步操作。例如,利用fetch API发起一次跨域请求。

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
var text = yield fetch('http://www.example.org/data.json');
console.log(text);
}

var g = gen();
g.next().value.then(function (data) {
return data.json();
}).then(function (data) {
g.next(data);
});

fetch API返回一个promise对象,通过为之指定then,处理fetch成功后的返回值。

co和koa

我们上面提到了使用Generator还缺少的一样东西——执行器。使用Generator函数在其中通过yield返回Promise,但是外层还是需要在promise的then方法中书写g.next(data)来通知协程继续操作。co函数库帮助我们完成了执行器的工作

以回调函数中完成读文件操作为例(注意:其中的readFile先被改写成返回thunk函数的格式,即只接收callback作为唯一的输入参数)

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

function readFile(path) {
return function (callback) {
fs.readFile(path, {encoding: 'utf-8'}, cb);
}
}

function* gen() {
var d1 = yield readFile('file1.js');
console.log(d1);
var d2 = yield readFile('file2.js');
console.log(d2);
}

co(gen);

上面的代码里,为co函数传入Generator函数,就会自动依次执行其中的异步任务,并在返回一个Promise对象。因此,可以给co函数通过then的方式添加回调函数。

co

co的代码并不复杂,核心代码只有数十行。摘录如下:

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
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.apply(ctx, args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);

onFulfilled();

function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}

function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}

function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});
}

在看co的代码前,我们不妨先想一下它的原理。Generator 函数只是一个异步操作的容器,它的流程和控制是由外部机制完成的。而thunk函数(这个在下面介绍)和Promise对象恰恰可以方便得在回调函数和then方法中交还执行权给Generator函数

这么来看就简单了,co函数库针对thunk函数和Promise对象封装了执行器。以比较好理解的Promise对象为例(co在内部也会将thunk函数转为Promise对象)。

首先将readFile的thunk函数转为Promise对象。其中ctx绑定到co函数体内。(co函数库的thunkToPromise函数)

1
2
3
4
5
6
7
8
9
var readFile = function (path) {
var ctx = this;
return new Promise (function (resolve, reject){
readFile.call(ctx, function (err, data){
if (err) reject(err);
resolve(data);
});
});
}

之后同样使用上节中的生成器函数gen(),并手动执行下一步操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* gen() {
var d1 = yield readFile('file1.js');
console.log(d1);
var d2 = yield readFile('file2.js');
console.log(d2);
}

var g = gen();

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

发现规律了么?自动执行器实际上就是在g.done == false时,不断地在then方法中嵌套添加回调函数。结果呼之欲出。

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

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

next();
}

run(gen);

每执行一次next,检查done的状态,若未结束则在then方法继续指定next方法,等待下一次返回结果。这也是co函数库的基本原理。

源码

理解了原理后,回头看co的源码,就比较好理解了。前6行对传入的gen检测是否为Generator类型。onFulfilled函数和onRejected函数对Generator函数原有的nextthrow函数进行了封装,便于错误捕获和处理。

next方法中,主要做了下面的4步微小的工作:

  1. 查是否已经到Generator函数的最后一步,如果是则返回
  2. 确保每次yield返回值都是Promise对象
  3. 通过then方法,为返回值添加回调函数,并在回调中再次调用自身
  4. 对于类型不合适的gen,将状态修改为rejected

co能接收的yield返回值类型是有限的(尽管Generator函数中的yield后不限制返回值类型),有thunk函数,array,object,Promise对象。其中array和object使co可以胜任并发的操作,即可以在yield中返回多个异步操作任务。

koa

koa是建立在generator和co之上的中间件框架,由Express开发人员打造。它通过组合不同的生成器函数,避免了繁杂易出错的回调函数嵌套。koa中没有绑定任何中间件,仅仅提供了一个轻量级函数库。

Koa 中间件以一种更加传统的方式级联起来, 跟你在其他系统或工具中碰到的方式非常相似。 然而在以往的 Node 开发中, 级联是通过回调实现的, 想要开发用户友好的代码是非常困难的, Koa 借助 generators 实现了真正的中间件架构, 与 Connect 实现中间件的方法相对比,Koa 的做法不是简单的将控制权依次移交给一个又一个的方法直到某个结束,Koa 执行代码的方式有点像回形针,用户请求通过中间件,遇到 yield next 关键字时,会被传递到下游中间件(downstream),在 yield next 捕获不到下一个中间件时,逆序返回继续执行代码(upstream)。

thunk

上文中提到的thunk函数实际上由来已久,它是函数传名调用的一种实现方式,不同于函数的传值调用。就像下面的例子一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 执行器
function f(a, b) {
return a + b;
}

f(x * 3 + 1, 1);

// 可以写成下面这样

function thunk () {
return x * 3 + 1;
}

function f(thunk, 1) {
return thunk() + 1;
}

JavaScript中的thunk函数有着另外的意思,它替换的不是一个输入参数,而是将多参数的函数替换成单参数的版本,且只接受回调函数作为输入参数,正如之前写到的例子一样。

一个简单的thunk函数转换器写起来并不复杂,像下面这样:

1
2
3
4
5
6
7
8
9
var thunk = function(fn) {
return function () {
var args = Array.prototype.slice.call(arguments);
return function (done) {
args.push(done);
fn.apply(this, args);
}
}
}

node-thunkify模块对此又多了一些监测,如在最内层的function添加called变量确保回调函数只执行一次。Thunkify的源码相比co就更短了。

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 thunkify(fn) {
assert('function' == typeof fn, 'function required');
// 返回一个包含thunk函数的函数,返回的thunk函数用于执行yield,而外围这个函数用于给thunk函数传递参数
return function() {
var args = new Array(arguments.length);
// 缓存当前上下文环境,给fn提供执行环境
var ctx = this;

// 将参数类数组转化为数组(实现方式略显臃肿,可直接用Array.prototype.slice.call(arguments)实现)
for (var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}

// 真正的thunk函数(有且只有一个参数是callback的函数,且callback的第一个参数为error)
// 类似于:
// function(cb) {fs.readFile(path, {encoding: 'utf8}, cb)}
return function(done) {
var called;

// 将回调函数再包裹一层,避免重复调用;同时,将包裹了的真正的回调函数push进参数数组
args.push(function() {
if (called) return;
called = true;
done.apply(null, arguments);
});

try {
// 在ctx上下文执行fn(一般是异步函数,如:fs.readFile)
// 并将执行thunkify之后返回的函数的参数(含done回调)传入,类似于执行:
// fs.readFile(path, {encoding: 'utf8}, done)
// 关于done是做什么用,则是在co库内
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};

thunk函数的特点和Promise对象类似,就是将回调函数的绑定单独抽离出来,thunk函数结合Generator函数实现自动流程管理方法和Promise一样。

async

从Promise对象到Generator函数,JavaScript中的异步编程越来越简单,但是还是有戴着镣铐跳舞的感觉,async函数的提出即将把这个镣铐摘掉。

async函数的使用和Generator函数很像,我们改写之前的那个读取文件函数如下:

1
2
3
4
5
6
async function readFile() {
var d1 = await readFile('file1.js');
console.log(d1);
var d2 = await readFile('file2.js');
console.log(d2);
}

虽然和Generator函数很像,但是它有着更清晰易懂的语法,更广的适用性(await后可以跟任何类型,在原始类型时等同于同步操作)。最关键的是,async函数自带执行器!!!

在实现上,async函数和Generator函数是一样的,不过是将执行器放在自身内部而已。

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
function fn(args) {
return spawn(function* () {
//...
});
}
function spawn (genF, self) {
return new Promise(function (resolve, reject) {
var gen = genF.call(self);
step(() => gen.next(undefined));
function step (nextF) {
var next;
try {
next = nextF();
} catch(e) {
// finished with failure, reject the promise
reject(e);
return;
}
if (next.done) {
// finished with success, resolve the promise
resolve(next.value);
return;
}
// not finished, chain off the yielded promise and `step` again
Promise.resolve(next.value).then(
v => step(() => gen.next(v)),
e => step(() => gen.throw(e))
);
}
});
}

在使用上,async函数返回一个Promise对象。可以使用then方法添加回调函数。当遇到await时先返回,等待异步操作完成后再执行函数体后的语句。await只能用在async函数中,在普通函数中使用会报错。同时,async函数目前是ES7的标准,需要通过Babel转码使用。

参考

This API is so Fetching!
co
co和koa
Understanding JavaScript’s async await