ES6 generator 、yield 与co

@youngwind 2016-03-22 08:48:43发表于 youngwind/blog JS

起因

最近在看一些node项目的时候发现里面用到了ES6的generator函数,yield和tj的co库,花了一些时间搞明白它们之间的关系,下面用一些例子说明。

溯源

对于异步的操作,最常规的写法是回调函数,但是深度回调会出现可怕的金字塔。那么,如何用更好的书写方式来避免金字塔,又或者说,怎么样把异步的代码写得看起来好像同步那样子呢?
其中一种解决方案是promise模式,.then一直then下去。ok,从ES6开始,有两个新的特性,叫generator和yield,借助它们,我们能够更优雅地解决这个问题。

generator和yield简介

请看下面的代码

function* Hello(){
 yield 1;
 yield 2;
}
var hello = Hello();
console.log(hello.next());  // { value:1, done:false }
console.log(hello.next());  // {  value:2, done:false }
console.log(hello.next());  // { value:undefined, done:true }
  1. function后面的*号代表这是一个generator函数,而非普通函数,只有在generator函数中才能使用yield,在普通函数中使用yield会报错。
  2. generator函数的函数是分段的。第一次执行next的时候,程序会执行到第一个yield,然后返回{ value:1, done:false },表示yield后面返回1,但是函数Hello还没执行完,函数既不会退出,也不会往下执行。
  3. 当再次执行next的时候,从上次中断的地方接着执行,直到下一个yield或者函数结尾。

正是这种在单个函数内分步执行性质的引入,使得我们能够通过它来完成异步操作的"优化"。

假设有这样的例子

function delay(time, cb){
 setTimeout(function(){
   cb && cb()
 },time);
}

delay(200,function(){
  console.log('200ms done');
  delay(1000,function(){
    console.log('1200ms done');
    delay(500,function(){
       console.log('finish');
     });
  });
});

如何优化这个例子呢?

思路:根据generator的特性,如果我构造一个generator函数包含这三个异步操作,并且把他们各自的callback函数都设置为执行next()函数,这样不就可以实现"看起来是同步"的了吗?

function cl(){
  yieldDelay.next();
}

function* YieldDelay(){
  yield delay(3200,cl);
  console.log('3200ms done!');
  yield delay(4400,cl);
  console.log('4400ms done!');
  yield delay(5500,cl);
  console.log('5500ms done!');
}

var yieldDelay = YieldDelay();
yieldDelay.next();

ok。我们已经迈出了一大步了。不过这个写法看着还是有些别扭。

  1. 第一次执行需要我手动出发next()函数。
  2. 回调函数只是简单地执行next()函数,为什么不能把它更加抽象化,以至于不用定义这个回调函数呢?
    让我们先激动一小会儿,因为你在走tj大神曾经走过的路!

进一步优化这段代码

我们先想想思路,到底有什么办法能够做到呢?最开始的写法之所以会导致金字塔现象,是因为:函数a的执行里面包含执行函数b,所以函数b的执行里面也必须包含执行函数c……如果我们在函数a执行的时候只返回一个function,而这个function接收函数b作为参数。ok,我们先按照这个思路改造一下delay函数和generator函数

function delay(time){
  return function(fn){
    setTimeout(function(){
      fn();
    },time)
  }
}

co(function* (){
  yield delay(4200);
  yield delay(4000);
  yield delay(3000);
})(function(){
  // 回调函数
  console.log('all done!');
})

function co(GenFunc){
   return function(cb){
      //......先略过
   }
}

我们分析一下:

  1. co函数接收generator函数作为参数,然后返回一个函数,该函数接收回调函数。
  2. delay函数接收时间作为参数,返回一个函数,该函数接收回调函数。

再次理一下思路,我们应该如何编写//........先略过这一部分的内容呢?
yield特性可以让我们分阶段执行,暂停→开始→暂停→开始……**如果我们可以让第一次执行的结果是一个函数,而这个函数接收第二次执行本身作为cb函数,第二次执行的结果也是一个函数,而这个函数接收第三次执行本身作为cb函数……直到结束。好吧,说再多还不如来几行代码!

function co(GenFunc) {
  return function(cb) {
    var gen = GenFunc();  // 第一次执行的时候构造出对象
    next()    // 调用自定义的next方法
    function next() {
      var ret = gen.next();   
     // 在generator函数中走一步,delay函数返回一个函数赋给ret.value
      if (ret.done) {    
        // 判断ret.done是否为真,如果为真,说明generator函数执行完了,该调用回调函数了
        cb && cb();
      } else {
      // 如果ret.done为假,那么调用上一个返回的函数,并且把next函数传递给它作为回调函数
        ret.value(next);
      }
    }
  }
}

嗯,看起来有点绕,多看几遍就好了。
至此,你已经山寨了一个极其简单的co库。
当然tj的co库比这个复杂多了,但是原理就是这样,还可以传参数,支持promise

遗留问题:

  1. 该看看ES6原生支持的promise对象了。
  2. generator+co这样的模式确实可以优雅地解决金字塔问题,不过ES7中提供async函数,利用它,不需要依赖co库,也一样可以解决这个问题。

参考资料:

  1. http://es6.ruanyifeng.com/#docs/generator
  2. http://bg.biedalian.com/2013/12/21/harmony-generator.html
  3. http://www.ruanyifeng.com/blog/2015/04/generator.html