概述:简介及使用

本文先介绍 koa 的简单使用和一些背景概念,已经了解的可以跳过直接到 co 和 koa 执行分析部分。

koa的简介如下:

由 Express 原班人马打造的 koa,致力于成为一个更小、更健壮、更富有表现力的 Web 框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升常用错误处理效率。Koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手。

简单使用

使用 koa 构建一个 web 应用有多简单?先来看一个使用例子 app.js

1
2
3
4
5
6
7
8
9
var koa = require('koa');
var app = koa();
// 中间件
app.use(function *(){
this.body = 'Hello World';
});
app.listen(3000);

运行使用命令 $ node ./app.js 就能在本机的 3000 端口启动一个web服务。

这里查看 koa 源码:

1
2
3
4
5
app.listen = function(){
debug('listen');
var server = http.createServer(this.callback());
return server.listen.apply(server, arguments);
};

可以发现事实上上文的 app.listen(3000); 可以改写为

1
2
3
var http = require('http');
var server = http.createServer(app.callback());
server.listen(3000);

级联代码

与 Connect 实现中间件的方法相对比,Koa 的做法不是简单的将控制权依次移交给一个又一个的中间件直到程序结束,Koa 执行代码的方式有点像回形针,用户请求通过中间件,遇到 yield next 关键字时,会被传递到下一个符合请求的路由(downstream),在 yield next 捕获不到下一个中间件时,逆序返回继续执行代码(upstream)。

看下面的例子:

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 koa = require('koa');
var app = koa();
app.use(function* fun_a(next){
console.log('a-head');
yield next;
console.log('a-tail');
});
app.use(function* fun_b(next){
console.log('b-head');
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
console.log('b-tail');
});
app.use(function* fun_c(next){
this.body = 'Hello World';
});
app.listen(3000);

每处理一个请求,console就会输出:

1
2
3
4
5
a-head
b-head
GET / - 4
b-tail
a-tail

观察执行结果,很容易理解程序的运行顺序。首先会执行第一个中间件,但当执行到 yield next 时,代码流会暂停执行这个中间件的剩余代码,转而切换到下一个被定义的中间件执行代码。直到执行到最后一个没有 yield next 的中间件,该中间件返回后,程序流会按反向顺序执行每个中间件的剩余代码。

这被称为“洋葱”结构。可以形象地使用下图来表示:

洋葱结构

具体对应到上文的例子,图可以画成这样:

洋葱结构

处理异步操作

首先将异步操作封装为 Promise

1
2
3
4
5
6
7
8
9
10
11
function asyncOne(){
return new Promise(function(resolve, reject){
doAsync(function(error, data) {
if(error){
reject(error);
}else{
resolve(data);
}
});
});
}

在中间件中如下调用

1
2
var ret = yield asyncOne();
console.log(ret);

可以看到这样执行异步操作,从代码顺序上看和一般的同步执行代码已经很类似了,易于理解代码业务逻辑,并能避免被复杂的异步回调搞晕。但事实上代码还是异步执行的,不存在线程等待。阮一峰老师提到过协程的概念,了解一下有助于这里的理解。

但是这样做有一个缺点是多个异步操作都是等待上一个操作完成后才开始执行,事实上是强制顺序执行了。比如如下的代码,两个异步执行其实并没有依赖关系,完全可以并行执行。

1
2
3
var ret1 = yield asyncOne1();
var ret2 = yield asyncOne2();
console.log(ret1, ret2);

这种情况下可以将多个异步操作合并,内部还是异步执行,外部同步等待。可以使用 Promise.all 来包裹多个 Promise。

1
2
3
4
5
function asyncMulti(){
return Promise.all([
asyncOne(), asyncOne()
]);
}

在中间件中如下调用

1
2
var ret = yield asyncMulti();
console.log(ret);

背景概念:遍历器和 Generator 函数

上文用到了关键字 yield,这是ES2015(ES6)中引入的概念,相关的概念还有 Iterator 遍历器和 Generator 函数。下面大部分的理解和解释都参考于阮一峰老师的《ECMAScript 6入门》一书。

下文还会更多提到异步回调和 Promise ,默认读者都了解,不再详述。

Iterator 遍历器

遍历器是一种协议,只要符合这个协议就能完成遍历操作。ES6 的协议规定只要实现了 next 方法的对象都具备了遍历器的功能。next 方法返回的对象包含两属性,其中 value 表示当前遍历的值,done 是一个布尔值,表示是否遍历结束。

下面贴一个阮一峰老师书里的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function makeIterator(array){
var nextIndex = 0;
return {
next: function(){
return nextIndex < array.length ?
{value: array[nextIndex++], done: false} :
{value: undefined, done: true};
}
}
}
var it = makeIterator(['a', 'b']);
it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }

上文 makeIterator 是一个遍历器生成函数,实现数组的遍历,next 函数执行时移动指针并返回当前遍历的值。

ES6还规定了,只要部署了next方法,就可以用for…of循环遍历它的值。for…of 和 for…in 的区别是后者只能获取对象的键名,而前者直接遍历了键值。事实上,数组原生就部署了遍历器的接口,可以如下实现遍历

1
2
3
4
var arr = ['red', 'blue', 'yellow'];
for(let v of arr){
console.log(v);
}

输出结果

1
2
3
red
blue
yellow

Generator 函数

Generator函数是ES6提供的一种异步编程解决方案,语法行为与传统函数完全不同。执行Generator函数会返回一个遍历器对象,也就是说,Generator函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历Generator函数内部的每一个状态。

Generator 函数事实上就是一个普通的函数,但是它有几点特殊:

  • function 后面会有一个星号;
  • 函数内部会使用 yield 定义状态,将函数分成几个部分;
  • 函数运行结果返回一个遍历器。

所以说Generator 函数就是一个遍历器生成器函数。

Generator 函数返回的遍历器有一个特殊的名字就叫 Generator。它是一个内部状态的遍历器,每一次遍历都是内部状态的一次改变,ES6引入这个特性之后就能控制函数内部的执行状态。

来看一个例子:

1
2
3
4
5
6
7
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}
var hw = helloWorldGenerator();

如是,hw就是一个 Generator(状态遍历器),遍历的执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
hw.next()
// { value: 'hello', done: false }
hw.next()
// { value: 'world', done: false }
hw.next()
// { value: 'ending', done: true }
hw.next()
// { value: undefined, done: true }

分析执行过程,我们可以看到 helloWorldGenerator 函数内部使用了两次 yield 关键字,它们将整个函数分成了三个部分,这三个部分以状态遍历器的形式作为函数返回结果被返回。当这个状态遍历器在被遍历时,每一次遍历就执行了函数的一个部分。yield 关键字后面的表达式值就是 next 方法的返回对象中的 value 值。当 done 为 true 时,表示函数内部的所有状态都已被执行。

事实上,我们可以把 Generator 函数理解为一种可以被暂停执行的函数,而使用遍历器的 next 方法控制函数的暂停和执行。

next 方法的参数

next 方法是可以传入参数的,传入的参数将作为 yield 语句的执行结果。

先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* helloWorldGenerator(p) {
console.log(p);
var a = yield 'hello';
console.log(a);
var b = yield 'world';
console.log(b);
return 'ending';
}
var hw = helloWorldGenerator(0);
console.log(hw.next(1));
console.log(hw.next(2));
console.log(hw.next(3));
console.log(hw.next(4));

执行结果:

1
2
3
4
5
6
7
0
{ value: 'hello', done: false }
2
{ value: 'world', done: false }
3
{ value: 'ending', done: true }
{ value: undefined, done: true }

第一次遍历时,next 方法传入参数 1,并控制执行了第一部分代码,并返回了 yield 关键字后面的字符串作为 value 值。但是我们发现传入的 1 即没有给变量 p 也没有给变量 a,而是被丢掉了,而打印出来的 p 的值是 Generator 函数执行时传入的 0。第二次遍历时,next 方法传入参数 2,并控制执行了第二部分代码,此时我们才看到 next 传入的 2 作为 yield 语句的执行结果给了a

以图来理解更形象一点。

next

yield 及 yield* 语句

yield 关键字上文已经讲到了,它后面跟的值将被直接作为 next 方法返回的 value 值。而当 yield 关键字后面跟的是 Generator 遍历器时,需要在 yield 后面加上星号 * 来表明。

还是用例子来解释它们之间的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* generator1() {
yield 'red';
yield 'blue';
}
function* generator2() {
yield 'yellow';
yield generator1();
yield 'grey';
}
var sum = 0;
for(let v of generator2()){
sum ++;
console.log(v);
}
console.log(sum);

执行结果:

1
2
3
4
yellow
{}
grey
3

generator1() 函数被调用时返回一个状态遍历器,当在它之前的 yiled 关键字不使用星号时,这个状态遍历器直接被作为 next 方法返回的 value值(例子中没有显式地使用next,而用了for…of方式),状态遍历器无法序列化,所以在打印时只打印了{}

然后来看使用星号的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* generator1() {
yield 'red';
yield 'blue';
}
function* generator3() {
yield 'yellow';
yield* generator1();
yield 'grey';
}
var sum = 0;
for(let v of generator3()){
sum ++;
console.log(v);
}
console.log(sum);

执行结果:

1
2
3
4
5
yellow
red
blue
grey
4

generator1 函数被调用时还是返回了一个状态遍历器,但是不再仅当成一个值,而是被当成了外层状态遍历器的一部分。generator3 返回的就是这个外层状态遍历器,可以看到外部遍历器被遍历了5次(当done为true时并不会执行循环体内的代码,左移遍历次数等于sum + 1),但是 generator3 只被分成了 4 部分。我们可以将上面的过程理解为下面这样一个合并的函数,这样就可以理解 generator3 返回的状态遍历器为什么被遍历了5次了。

1
2
3
4
5
6
function* generator3() {
yield 'yellow';
yield 'red';
yield 'blue';
yield 'grey';
}

Generator 在异步流程控制中的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function asyncOne(){
return new Promise(function(resolve, reject){
request.get({
url: 'http://www.baidu.com'
}, function optionalCallback(error, response, body) {
console.log('in' + response.statusCode);
resolve(response.statusCode);
});
});
}
function* generator(){
console.log('step1');
yield asyncOne();
console.log('step2');
}
var hm = generator();
var promise = hm.next().value;
promise.then(function(data){
console.log('out' + data);
hm.next();
});

重要工具:流程控制执行器co

co 是 TJ 结合 Generator 和 Promise 编写的执行器,实现以类似同步代码的方式来执行异步代码。

上文说到Generator函数能生成一个迭代器,操作迭代器来使内部代码分步执行。那么当Generator函数内有一步为异步操作时,可以使用Promise来控制等待异步执行结束之后再执行下一步。这是co原理的简单理解,也是上文Generator 在异步流程控制中的使用中讲到的方式基于的原理。当然,TJ 大神用简洁完备的代码对其进行了封装。

一般使用例子:

1
2
3
4
5
6
7
8
co(function* () {
var result = yield Promise.resolve(true);
return result;
}).then(function (value) {
console.log(value);
}, function (err) {
console.error(err.stack);
});

例如上文代码中,假设Promise.resolve(true);部分代码替换为封装了异步执行函数的Promise,那么该步next()执行的返回值就是这个Promise。外部将能由这个Promise获知到异步函数什么时候执行结束,然后再继续执行下一步。具体逻辑用下文的源码来分析。

co的代码总共只有200多行,而关键代码去掉注释甚至只有如下不到50行:

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);
return null;
}
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函数,而返回值是一个Promise。

参考文档

koa 中文文档

ECMAScript 6入门