ES6 generator函数与co再一瞥

离上文《ES6 generator函数与co一瞥》已经过去了两个月,真是惭愧,赶紧补完。

本文将会介绍ES6中的generator/yield的异常处理,以及分析并实现一个简单的、只支持Promise的co,嗯我们这里山寨的叫做cool

异常处理

这里我们分两种情况来看,一种是在generator function当中发生的异常,一种是在迭代中发生的异常。

在generator function中发生的异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* boring() {
yield 'one';
throw 'oops';
yield 'two';
}
var gen = boring();
var iter = gen.next();
while (!iter.done) {
console.log(iter.value, iter.done);
try {
iter = gen.next();
} catch (ex) {
console.log('exception happend while iterating:', ex);
break;
}
}

上面的代码只会输出

1
2
one false
exception happend while iterating: oops

因为在继续第二个next()的时候,发生了异常,这个异常导致迭代终止了。

在迭代中发生的异常

通过gen.throw()可以把异常抛到generator function里面去,它会作为“整个yield表达式的异常”,然后迭代将会继续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function* boring() {
try {
yield 'one';
} catch (ex) {
console.log('exception caught inside:', ex);
}
yield 'two';
}
var gen = boring();
var iter = gen.next();
while (!iter.done) {
console.log(iter.value, iter.done);
if (iter.value === 'one') {
iter = gen.throw('shut up'); // 强行异常,无情无义无理取闹
} else {
iter = gen.next();
}
}

将会输出

1
2
3
one false
exception caught inside: shut up
two false

当然,如果generator function内部没有捕获这个异常,最终它还是会被抛到外界来,回到上文的情况1。

用于将异步的错误处理同步化

结合上面两个特性,我们可以将yield表达式当中的异步操作中的错误处理进行“同步化”,我们知道异步操作最恶心的地方就是错误处理,例如thunk风格的错误处理

1
2
3
4
5
6
7
fs.readFile(path, function(err, data) {
if (!err) {
// 正常
} else {
// 异常
}
});

或者Promise风格的错误处理

1
2
3
4
5
$.getJSON(url).then(function(data) {
// 正常
}, function(err) {
// 异常
});

很容易把程序逻辑扯得支离破碎。而同步的错误处理就容易得多,可以直接用JS的结构化异常处理try/catch/finally
于是我们可以扩展上一篇文章当中的async函数(本文将改名叫cool),让它对于yield表达式中的异步操作也可以进行错误处理,并且将错误通过gen.throw抛回generator function内,从而内部就可以使用try/catch来处理异常了。

动手实现一个co

为了简化代码,我们先去掉对yield一个thunk的支持,只留下对于Promisegenerator的支持,并且最终也把这组“同步化”之后的异步操作返回为一个Promise。

核心

回顾一下前作当中的async函数,它已经做到了

  • yield一个thunk函数
  • yield一个普通值
  • 完成迭代

这次我们先把thunk函数换成Promise的风格,然后还差的是

  • 通过gen.throw()将Promise的错误抛到generator function
  • cool返回一个Promise
  • 对于generator function没能处理的异常,将其转化成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
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    function cool(gen){
    return new Promise(function(resolve, reject) {
    var iter = gen();
    function onResolve(data) {
    try {
    var it = iter.next(data); // 进行一步迭代
    step(it);
    } catch (ex) {
    reject(ex); // 捕获到generator function内的异常,终止迭代
    }
    }
    function onReject(err) {
    try {
    var it = iter.throw(err);
    // 将yield表达式中的异步操作的错误抛进generator function,并继续迭代
    step(it);
    } catch (ex) {
    reject(ex); // generator function没有妥善处理异常,终止迭代
    }
    }
    function step(it) {
    if (it.done) {
    // 迭代已完成
    resolve(it.value);
    return;
    }
    var value = it.value;
    if (typeof value.then === 'function') {
    // 收到的是一个Promise
    value.then(onResolve, onReject);
    } else {
    // 收到的是一个值
    onResolve(value);
    }
    }
    onResolve(); // 开始迭代
    });
    }

上面的代码和co@4.0的核心代码几乎如出一辙,当然少了很多各种异步API格式的兼容,但是实际上generator/yield真的就是这么简单,很难写出什么花样来。

现在我们的cool函数已经可以支持try/catchyield Promise的用法了

试试看

先写一个名为sleepRandom的辅助函数

1
2
3
4
5
6
function sleepRandom() {
return new Promise(function(resolve) {
var ms = Math.floor(Math.random() * 500);
setTimeout(resolve.bind(this, ms), ms); // Promise的返回值就是sleep的毫秒数
});
}

顺序执行

1
2
3
4
5
6
7
8
9
10
11
12
13
var boringJob = cool(function*() {
console.log(yield 'yield sync value');
for (var i=0; i<5; ++i) {
var ms = yield sleepRandom();
console.log(i, ms);
}
return 'success';
});
boringJob.then(function(data) {
console.log('finished:', data);
}, function(err) {
console.log('failed:', err);
});

输出

1
2
3
4
5
6
7
yield sync value
0 47
1 343
2 40
3 339
4 423
finished: success

这个例子中,sleepRandom本来是一个异步的操作,但是被我们的“语法糖”搞成同步的了,JS也能sleep了,你满足了吧……

未处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function bad() {
return new Promise(function(resolve, reject) {
setTimeout(reject.bind(this, 'breaking bad'), 200);
});
}
var weakJob = cool(function*() {
console.log(yield 'yield sync value');
console.log(yield bad()); // bad()被reject后,其错误将会作为`yield bad()`语句的异常抛出
console.log(yield sleepRandom());
return 'success';
});
weakJob.then(function(data) {
console.log('finished:', data);
}, function(err) {
console.log('failed:', err);
});

输出

1
2
yield sync value
failed: breaking bad

因为yield bad()的异常没被处理,它就被抛出来了,一来造成迭代终止,二来造成了weakJobreject

用try/catch/fanally处理异步任务的异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var robustJob = cool(function*() {
console.log(yield 'yield sync value');
try {
console.log(yield bad());
} catch (ex) {
console.log('caught exception:', ex); // 异常被处理了,不会造成迭代终止
}
console.log(yield sleepRandom());
return 'success';
});
robustJob.then(function(data) {
console.log('finished:', data);
}, function(err) {
console.log('failed:', err);
});

输出

1
2
3
4
yield sync value
caught exception: breaking bad
487
finished: success

这个例子中,虽然bad是一个异步操作,但是因为我们的cool函数把Promise的错误处理格式转换成了try/catch,所以可以用编写同步代码的方式来处理异常了,编程体验好多了。

补充

然后我们再实现一个yield另一个generator的兼容,这个就很简单了。

首先对cool的传入参数进行一下重构,使其可以兼容generatorgenerator function两种输入

1
2
3
4
5
function cool(gen){
return new Promise(function(resolve, reject) {
var iter = typeof gen === 'function' ? gen() : gen;
function onResolve(data) {
...

然后对yield内容是generator的情况也做一下兼容

1
2
3
4
5
6
7
8
9
10
11
12
...
if (typeof value.then === 'function') {
// 收到的是一个Promise
value.then(onResolve, onReject);
} else if (typeof value.next === 'function' && typeof value.throw === 'function') {
// 收到的是一个Generator,将其用cool包装为一个Promise然后继续
cool(value).then(onResolve, onReject);
} else {
// 收到的是一个值
onResolve(value);
}
...

于是构建了一个还算凑合够用的“同步编程、异步执行”的体系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* boring() {
for (var i=0; i<5; ++i) {
var ms = yield sleepRandom();
console.log('boring', ms);
}
return 'boring end';
}
var boringJob = cool(function*(){
var boringResult = yield boring(); // 一个generator function里可以yield另一个generator
console.log('boring result:', boringResult);
return 'success';
});
boringJob.then(function(data) {
console.log('finished:', data);
}, function(err) {
console.log('failed:', err);
});

上面的例子可以看做我们把来自系统或其他库的基于callback也好,Promise也好,反正是异步操作,先封装成Promise,然后在我们的应用程序代码里,使用cool,或者co来实现“同步代码”,当然事实上这些代码执行的时候都还是异步的,我们只是实现了一个同步的语义。

问题

并行任务

JS是单线程的,因此但就JS本身而言很难说真正的“并行任务”,但是runtime是多线程的,因此我们可以充分利用这一点,比如同时开多个Ajax请求,同时开多个fs.readFile等等。

这本身不是难事,难的是当遇到类似“当a, b, c三个请求都完成时,渲染界面”这种需要控制异步流程的地方,使用async.js这类的工具可以帮助我们做这种操作,使用Promise.all也可以实现这样的语义。事到如今,JS社区对于用async.jsthen.js还是Promise,甚至是ES6的Promise还是重新实现的Promise,还讨论的喋喋不休,乐此不疲,足见异步语义对于程序员的负担是很大的。

在co中,yield一个数组的时候,它会把这个数组中的每一项都当做Promise,然后用Promise.all来让他们并发地执行。而如果yield的是一个Plain Object,它会遍历这个对象所有key,将其进行“Promise化”。听起来比较复杂,不过其实也就一二十行代码的事情,有兴趣的同学自己去看看co的代码就OK了。

这样的话在使用co的时候,如果yield一个数组或者一个Plain Object,它会对数组或者对象里的各项并发地执行,当它们全部都完成的时候一次性完成yield,依然可以用同步的语义实现并发,例如

1
2
var results = yield [$.ajax(url1), $.ajax(url2), $.ajax(url3)];
// 全部完成后,yield才会完成,返回值是三个ajax的结果所组成的数组

调用栈

前阵子有爆料co在某些情况下会出现Maximum call stack size exceeded的情况(例子),其实非常符合预期并且好理解,这是因为用同步语义写的循环yield代码将会被变换成函数调用,一不小心就会造成非常长的Call Stack。解决的办法也比较容易,那就是不要yield同步函数

在我们这个例子里没这个问题,因为我们用的是ES6的Promise,它是严格异步的。而co支持yield一个thunk,thunk虽然是callback语义,但是没有任何担保它是异步的,也就是说thunk有“同步callback”和“异步callback”之分,这就是I神所谓“Release Zalgo”的问题,有兴趣可以继续探讨下。

支持程度

JS最让人恶心的地方就是有了新特性你不知道敢不敢用,因为不知道generator/yield支持程度怎么样。

  • 在node.js >= 0.11的版本中通过--harmony--harmony-generator参数可以开启支持。
  • 在io.js >= 1.0中已经相当于默认开启了这个支持。
  • 在Chrome较高版本中通过chrome:flags中的“启用实验性 JavaScript”可以开启支持。
  • 通过regenerator可以将generator/yield代码编译成ES5代码,用的时候需要一个大约500行源码的runtime。