使用yield进行异步流程控制

现状

目前我们对异步回调的解决方案有这么几种:回调,deferred/promise和事件触发。回调的方式自不必说,需要硬编码调用,而且有可能会出现复杂的嵌套关系,造成“回调黑洞”;deferred/promise方式则对使用者而言简洁明了,在执行异步函数之前就已经构造好了执行链--then链,而且实现也很灵活,具体可参考Promise的实现;事件机制则是一种观察者模式的实现,但也必须硬编码在异步执行的函数中,当异步函数执行完毕后再trigger相关事件,而观察者则相应执行事件处理函数。

注意,刚刚提到了一个词--硬编码,依赖这种方式仅实现回调局限性很大,如在node中,对fs.readFile('file1','utf-8')完成之后再进行fs.readFile('file2','utf-8'),使用回调和事件触发则必须在第一个异步的回调函数中进行调用trigger,增强了这两个操作的强依赖,使用deferred/promise则会很好的避免。

现在,随着ECMAScript6的逐渐普及,我们可以在chrome和node端尝试一种新的异步流程控制--generator。通过generator,我们可以控制函数内部的执行阶段,进而可以利用高阶函数的特性进行扩展,完成对异步流程的控制。

特性及兼容性

由于隶属于ECMAScript6规范,因此兼容性是一个大问题,不过我们在最新版的chrome和node --harmony下使用该功能,所以做node端开发的小伙伴们可以大胆的使用。

那么,什么是generator呢?

function* (){}

这就是一个匿名的generator。通过function关键字和函数名或者括号之间添加“*”定义一个generator函数,我们也可以这样判断一个函数是否为generator:

typeof fn == 'function' && fn.constructor.name == 'GeneratorFunction'

在generator中我们可以关键字yield,java程序员对yield肯定不陌生,yield在java中是线程调度的一种方式,可以释放时间片让同级别的线程执行,然而在js中,yield却大不相同,因为js的执行线程是单线程,所以调度就不存在,yield我们可以理解为函数执行的一个断点,每次只能执行到yield处,这样原本顺序或者异步执行的函数逻辑都可以通过某种方式使他们以顺序的方式呈现在我们眼前,在这里需要强调下,通过yield只能断点执行generator函数中的逻辑,在函数之外并不会阻塞,否则整个主线程就会挂掉

一个generator函数执行到yield处,我们通过调用generator object的next()继续进行,generator object(下文简写为GO)就是generator函数的返回对象,调用GO的next方法会返回一个{value: '',done: false}这样的对象,value为yield关键字后面的表达式的值,done则表示generator函数是否执行完毕。

这就是基本的generator所有的数据结构,很简单明了。

实例

function * fn(){
var a = yield 1;
console.log(a);
var b = yield 2;
console.log(b);
}
var go = fn(); // 这是一个generator object
go.next(); // 执行到第一个 yield ,执行表达式 1
go.next(); //执行到第二个yield,输出console.log(a)为undefined,执行表达式 2
go.next(); //执行console.log(b),输出 undefined

上面的demo很容易理解,可能唯一有点疑问的就是console.log的输出。这里强调,每次next,只执行yield后面的表达式,这样对于前面的赋值操作就无能为力,那么如何对a进行赋值呢?可以通过第二个next进行传值。通过对第二个go.next(2),这样a的值就被赋为2,同理b的值也可以这样传递。

但是,这对于异步流程控制有什么用呢?其实,还是通过分段执行异步操作来完成。每个yield async1()执行完毕,将结果作为参数传给下一个yield async2(),这样我们只需判断GO.done是否为true来终止这个流程。

异步流程控制

我们的目标是实现这种方式的流程控制:

flow(function *(){
var readFile = helper(fs.readFile);
var t1 = yield readFile('./files/f1', 'utf8');
var t2 = yield readFile(t1, 'utf8');
console.log(t2);
});

其中flow是流程控制函数,参数为一个generator,helper函数则是一个包装函数,负责针对异步操作进行处理,下面我们看看helper函数的逻辑。

var helper = function(fn) {
var feed; // 用于存储回调函数,该函数复用于所有用于helper处理的异步函数
/**
* 执行次序分析:
* helper的参数fn是一个异步函数,通过helper的处理,返回一个含有内部处理逻辑
* 的函数,该函数封装了所需参数和可能的回调函数feed,并且返回一个设置feed的函数。
*
* 在具体的使用中,通过helper函数封装fs.readFile,获取readFile。当执行第一个
* 片段时,首先将所有的参数(包括feed)合并到args,并执行异步调用返回处理函数;此时
* 我们用获取的返回函数设置回调函数,进而影响到args中的最后一项的函数
*/ return function(){
var args = [].slice.call(arguments); args.push(function(){
if(feed) {
feed.apply(null,arguments);
}
console.log(feed)
});
fn.apply(null,args);
// 返回一个函数,用于给yield之前的变量赋值
return function(fn){
feed = fn;
}
};

helper函数的作用就是重新包装异步函数,返回的包装函数也会返回一个函数,用于给回调函数feed赋值。

所有的异步函数都需要用helper进行封装,已传递必要的回调,最后按照flow分发的流程“依次执行”。

下面我们实现flow的控制逻辑:

var flow = function(gfn) {
var generator = gfn();
next(); function next(data){
generator.ret = generator.next(data);
if(generator.ret.done){
return;
}
generator.ret.value(
function(error,d){
if(error)
throw error; next.apply(null,[].slice.call(arguments,1));
}
);
}
};

逻辑依旧很简单,针对传入的generator生产generator object,最后进入next递归。在递归中,首先执行next逻辑并判断是否到了generator的终点,如果没有则调用generator object的value方法(此处为“被helper处理过得函数的返回值,即function(fn){feed = fn}”)对回调进行赋值,在回调中则递归执行next函数,直至generator结束逻辑。

通过这样的方式,我们制定了flow流程,可以将多个异步操作顺序执行,而不影响generator函数之外的其余逻辑,这样避免了硬编码,没有了回调黑洞,我们只需在异步函数前加yield即可,省时省事。

flow(function *(){
var readFile = helper(fs.readFile);
var nt = helper(process.nextTick);
var t1 = yield readFile('./files/f1', 'utf8');
var t2 = yield readFile(t1, 'utf8');
yield nt(function(){console.log(t2)});
// console.log(t2);
});

可以用helper封装各种异步回调,在具体的业务逻辑中传入其余回调返回值作为参数,从而达到目的。

并行异步执行

yield 后面不仅仅可以放置表达式,也可以放置数组。数组的每项为表达式,这样每次执行到yield时,会并行执行这些异步操作,返回对象的value属性也是一个数组,我们依旧可以对value数组的每项进行赋值,从而完成回调的赋值。

var length = generator.ret.value.length,
ret = [];
generator.ret.value.forEach(function(item,i){ item(function(err,data) {
--length;
if (err) {
console.log(err.message);
// throw err;
}
ret.push(data);
if(0 == length){
generator.next(ret);
}
}); });

对value值进行遍历,并判断并行的异步操作是否都已完成,若完成则传递ret数组给变量。

throw特性

这块throw语法糖是后来添加的,之所以提到它是因为它的表现有点独特:

var gen = function* gen() {
try {
yield console.log('hello');
yield console.log('world');
}
catch (e) {
console.log(e);
yield console.log('error...');
}
yield console.log('end');
} var g = gen(); g.next();
g.throw('a');
g.next();

第一个next后,输出‘hello’;

throw后,输出‘a’、‘error...’

第二个next后,输出‘end’

可以发现gen.throw后,不仅执行到catch代码块,而且还会执行下一个yield表达式,在这里需要注意下!

应用

目前generator的兼容性要求其只能在node平台上使用,目前express框架的后继者koa采用了generator实现中间件的方式,中间件处理完每个请求都会通过yield *next的方式进行分发,此处的next也是一个generator object,通过yield *next的方式可以嵌套多层generator链,这样next()就会到下一个generator的yield处。

分解函数的执行,这种方式确实让人耳目一新,我们有理由相信js的未来,我们要坚信js未来的能量,我们要自豪我们处在前端开发这个领域内。

上一篇:2013 QCon北京演讲:跨终端的WebKit渲染机制


下一篇:margin 和 padding 的本质区别