co 是TJ Holowaychuk基于ECMAScript 6 generator 特性开发的一个用于简化异步开发的模块。如果你想尝试使用,请先升级node到0.11版本(不稳定分支)并启用--harmony-generators选项,或者使用gnode。co的基本使用方法很简单:

var co = require('..');
var fs = require('fs');

function read(file) {
  return function(fn){
    fs.readFile(file, 'utf8', fn);
  }
}

co(function *(){
  var a = yield read('.gitignore');
  var b = yield read('Makefile');
  var c = yield read('package.json');
  //输出a的内容
  console.log(a);
  //输出b的内容
  console.log(b);
  //输出c的内容
  console.log(c);
})()

想弄清楚这段代码里面发生的事情,我们首先需要明白 generator 是怎么玩的(本文只介绍 generator 定义中常用的一部分,详细信息可以参考在线文档)。

  • 首先是创建 generator:

    function * gen (){
     for (var i = 0; i < arguments.length; i++) {
       yield arguments[i];
     }
    }
    var g = gen(1, 2, 3);
    

    通过在 function 关键字后面加入*我们可以创建一个 generator 构造函数,调用这个构造函数我们就生成了一个 generator 对象。

  • 然后是理解yield关键字。首先注意yield只能接收一个参数。generator 对象上有个next方法用于迭代,这里迭代的意思就是执行代码到下一个yield关键字,next方法返回一个对象,对象只有两个属性,value属性表示 yield 右侧传入的参数,done属性是布尔值,表示是否所有的 yield 语句均执行完毕。你可以把yield放到无限循环里面,这样done属性永远都不会变为true。比如说

    function * gen (){
        console.log(1);
        yield 1;
        console.log(2);
        yield 2;
    }
    var g = gen();
    console.log(g.next().value);
    console.log(g.next().value);
    console.log(g.next());
    

    第一次调用next方法输出两个1,第二次调用next方法输出两个2,第三次调用next方法时由于yield已经全部执行完毕,所以返回的对象是{ value: undefined, done: true }

  • 最后,通过向next方法传入参数我们可以设定上一次yield调用的返回值,要注意的是yield只能接收一个参数,而且第一次调用next方法传入的参数是无意义的,例如:

    function * gen (){
        var x = yield 1;
        console.log(x);
    }
    var g = gen();
    g.next();
    //将x赋值为2
    g.next(2);
    

总之,通过generator,我们可以控制函数内部执行到哪个yield,可以知道是否所有yield声明执行完毕,可以对上一次的yield语句赋予一个返回值。

我们来实现一个简易版的co模块,目标是实现上文第一个例子中接受回调函数做为yield参数的API,至于co中支持的其它类型(promise、数组、对象、generator)为了简化程序便于理解,先不做支持。

var slice = Array.prototype.slice;

function co(fn) {
  //只支持generator函数做为参数
  if (!isGeneratorFunction(fn)) return new Error('only generator supported');
  return function(done) {
    var ctx = this;
    var args = slice.call(arguments);
    if (!arguments.length) done = error;
    //最后一个参数如果是函数则做为回调函数,其它的传给generator构造函数
    else if ('function' == typeof args[args.length - 1]) done = args.pop();
    else done = error;
    var gen = fn.apply(this, args);

    next();

    function next(err, res) {
      var ret;
      if (err) return done(err);

      // 多于2个参数时,将除了err的参数整合为数组
      if (arguments.length > 2) res = slice.call(arguments, 1);

      try {
        ret = gen.next(res);
      } catch(e) {
        return done(e);
      }

      //所有yield执行完毕, 结束next递归,调用最终回调函数
      if(ret.done) return done(null, ret.value);

      var called = false;
      try {
        //ret.value是yield接收的参数,必须为一个接收一个回调函数为参数的函数,而这个回调函数必须把error对象做为第一个参数
        ret.value.call(ctx, function() {
          if (called) return;
          called = true;
          //递归调用next方法,这里的arguments第一个总是error,其余为数据
          next.apply(ctx, arguments);
        })
      } catch(e) {
        //总是在下次事件轮询时抛错,这样能防止同步异步导致状态不一致的情况
        setImmediate(function(){
          if (called) return;
          called = true;
          next(e);
        });
      }
    }
  }
}

function error(err) {
  if (!err) return;
  //下次轮询时抛出错误,正式使用时应该总是传入回调函数,此方法仅仅用于演示
  setImmediate(function() {
    throw err;
  })
}

function isGeneratorFunction(obj) {
  return obj && obj.constructor && 'GeneratorFunction' == obj.constructor.name;
}

其实质就是递归的调用一个自己构建的next函数这么的简单,做为练习,你可以考虑考虑如何让co中的yield支持数组做为参数,从而实现并发的支持。

_(完)


chemzqm
2k 声望83 粉丝

Javascript全栈开发,产品设计,自动化工具。追求简洁的设计,模块化开发,卓越的用户体验。