使用生成器展平异步回调结构,JS篇

邹业盛 2020-03-25 18:31 更新
  1. 前言
  2. 期望的结果
  3. 生成器的工作方式
  4. 尝试处理异步结构
  5. 更通用一点
  6. 更有想像力一点
  7. 事件环境下的同步思维
  8. 最后的完整代码

1. 前言

2012 年的时候,我去详细了解过 Python 的 Tornado 框架中的 gen.py 这套工具, http://www.zouyesheng.com/generator-for-async.html ,因为觉得它用于异步环境的编程中实在太方便了,而且,适用性上几乎没有成本,你的定义部分代码完全不需要因为这套工具而作任何改动,这套工具完全是“使用时”的一种可选形式。

那时我想的就是,如果在遍地是 callback 的 Javascript 中也有这样的东西可用就好了。后来每每跟人谈论起 js 中的回调控制工具时,我都会提到老赵之前做的 wind.js https://github.com/JeffreyZhao/wind ,这东西牛逼爆了。生成器是一个语法机制,在没有生成器的情况下, wind.js 自己作编译,实现了 CPS 变换的功能,我觉得这几乎是原有 js 语法规则基础上,解决异步回调的结构控制问题的极限了。(不过好像 js 社区更热衷于 Promise 这种程度的工具,真是不能理解)

后来 js 新的标准中,加入了“生成器 Generator”这个机制, Chrome 和 Firefox 目前也都支持了。前段时间看它时,发现跟 Python 中的生成器工作方式基本是一样的,只有“返回值”那块,js 是用一个对象包装了一下。于是,我就想,把 Tornado 中的 gen.py 这套工具在 js 中实现吧。

Tornado 的 gen.py ,使用时有用到 Python 的另一个语法机制,“装饰器 Decorator”,但装饰器只是一个语法糖, js 中没有这个东西,不影响 gen.py 的功能实现,只是写出来没那么好看而已。

2. 期望的结果

Tornado 的 gen.py 工具,它的作用简单来说,就是把一个回调结构,变成顺序结构。

比如,一段异步代码是:

def callback(response):
    print response

def get_http_response(url, cb):
    fetch(url, cb)

get_http_response('http://www.zouyesheng.com', callback)

通过 gen.py 可以写成:

@gen.engine
def get_http_response(url):
    response = yield gen.Task(fetch, url)
    print response

get_http_response('http://www.zouyesheng.com')

换到 js 中,用 jQuery 的 API ,大概期望的结果就是这个样子。

原来的代码:

var callback = function(response){
    console.log(response);
}

var get_http_response = function(url, cb){
    $.get(url, cb);    
}

get_http_response('http://www.zouyesheng.com', callback);

通过生成器工具,可以写成:

var get_http_response = gen.engine(function*(url){
    var res = yield gen.Task($.get, url);
    console.log(res);
});

get_http_response('http://www.zouyesheng.com');

结果就是,可以让你永远告别那无止尽的回调嵌套,考虑下面的代码:

var example = gen.engine(function*(){
    var res_a = yield gen.Task($.get, url_a);
    var res_b = yield gen.Task($.get, url_b);
    var res_c = yield gen.Task($.get, url_c);
    var res_d = yield gen.Task($.get, url_d);
});

当然,还有其它功能,后面会介绍。

3. 生成器的工作方式

先简单介绍一下, 生成器 的语法。

var gen = function*(){
  for(var i = 0; i < 10; i++){
    yield i;
  }
}

obj = gen()

out_obj = obj.next()
console.log(out_obj.value) //0

out_obj = obj.next()
console.log(out_obj.value) //1

可以看出,生成器提供了“保留现场”的能力。我们的代码执行到一个地方之后,跳出去干其它事,之后再回到原来的地方继续执行。

要理解生成器,还有另外一个很重要的点,就是要明确, yield 是一个表达式,表达式是有计算返回值的yield 表达式的返回值,由 next() 函数调用时的参数提供,换句话说, next(123) 中的 123 参数,即是对应的 yield 表达式的返回值。

前面的代码,只是写了一个 yield i ,并没有关心 yield 的返回值,现在作一点修改:

var gen = function*(){
  console.log('start')
  for(var i = 0; i < 10; i++){
    var r = yield i;
    console.log('yield value: ' + r);
  }
}

obj = gen()

out_obj = obj.next('a')
console.log(out_obj.value)

out_obj = obj.next('b')
console.log(out_obj.value)

最后看到的输出是:

start
0
yield value: b
1

解释一下:

4. 尝试处理异步结构

yield 是一个可以切换上下文的地方,所以,异步回调函数的传值,我们可以作为 yield 表达式的返回值。比如:

var callback = function(response){}
async(callback)

可以变成:

var response = yield async

这里,需要做一个“生成器函数”,通过调度生成器对象,来完成对异步回调结果的传递。

var async = function(callback){ callback('hello') }

var run = function*(){
  var res = yield async;
  console.log(res);
}

gen = run();
func = gen.next().value;
func(function(response){
  gen.next(response);
});

上面是一个很简单的例子,它的意义在于,在 run 的定义中,我们确实通过一个顺序的结构,得到了一个原来需要异步回调才能得到的结果。

我们不可能每次使用时,还去写后面那一段调度流程的代码,所以,下一步,我们就把调度部分封装起来。这部分原来就是 Python 中使用装饰器做的事, js 中我们定义一个函数即可。

var async = function(callback){ callback('hello') }

var engine = function(generator){
  gen = generator();
  func = gen.next().value;
  
  return function(){
    func(function(response){
      gen.next(response);
    });
  }

}

var run = engine(function*(){
  var res = yield async;
  console.log(res);
});

run();

好像有点样子了。这里我们只是 yield async ,如果 async 本身可以支持参数的话,我们应该如何处理呢?

首先,直接地 yield async(123) ,这种方式肯定是不可取的。这要求, async(123) 返回一个生成器。这样的话,就改变了 async 函数原本的意义。 async 是一个像 $.get 一样的函数,函数定义时,跟生成器完全没有关系。这一点,在开始时就强调过,我们的工具只是使用时的一种可选形式,跟相关的函数定义没有关系。

所以,要实现对 async 带参数的支持,我们还需要封装一层,专门用来处理函数参数,类似于:

var res = yield Task(async, [123, 456]);

或者:

var res = yield Task(async, 123, 456);

当然,这两种形式,无非只是 callapply 的区别而已了。就以第二种形式为例吧,实现这个 Task 很简单。

var async = function(a, b, callback){ callback('a -> ' + a + ' b ->' + b) }

var engine = function(generator){
  gen = generator();
  func = gen.next().value;
  
  return function(){
    func(function(response){
      gen.next(response);
    });
  }
}

var Task = function(){
  var func = arguments[0];
  var arg = Array.prototype.slice.call(arguments, 1);
  var that = this;
  return function(callback){
    arg.push(callback);
    func.apply(that, arg);
  }
}

var run = engine(function*(){
  var res = yield Task(async, '123', 'abc');
  console.log(res);
});

run()

这个 Task 的实现是一个典型的 Currying 化的应用,在 Task 中把函数扒得只剩 callback 参数,这样在 engine 中就可以简单地无差别处理了。

如果对 Array.prototype.slice.call(arguments, 1) 这行的使用不太明白,可以参见 http://www.zouyesheng.com/js-context.html#toc3

到这里,最开始的那个期望的目标,我们已经实现,现在我们可以这样使用:

var run = engine(function*(){
  var res = yield Task($.get, '/');
  console.log(res);
});

run();

5. 更通用一点

虽然在前面已经实现了我们期望的功能,但是,它还不具有普遍的使用性。比如在 engine 中,连 yield 多次都不支持。

var run = engine(function*(){
  var res_a = yield Task($.get, '/');
  var res_b = yield Task($.get, '/');

  console.log('a -> ', res_a);
  console.log('b -> ', res_b);

});

所以,我们需要改进调度部分的 engine 的实现。

好吧,之前 Python 那篇,我也只谈到这里。因为之后的内容,就是 Tornado 中的 gen.py 这个工具的价值部分,否则前面的代码就只是玩具,而它的实现代码,当时一时半会儿我真看不懂。这次做 js 部分时,再次去翻 Tornado 的 gen.py 的实现,嗯……,其实最后还是没有完全看懂,但是大概是明白了它做的事,而且,同样的事, Python 中是用类,子类,这种方式来实现的。换到 js 上,因为有完整的匿名函数和闭包的支持(Python 中的匿名函数想想都是泪),所以实现起来简单得多。

回到多次 yield 的问题,它的实现,想起来其实也容易,无非就是对 generator 对象递归地多调用几次 next() 方法而已。

之前的 engine 实现:

var engine = function(generator){
  gen = generator();
  func = gen.next().value;
  
  return function(){
    func(function(response){
      gen.next(response);
    });
  }
}

我们先顺手把它改成,生成器调用时本身支持参数的形式,即:

var run = engine(function*(name){
  var res = yield Task($.get, '/');
  console.log(name, res);
});

run('ooooooooo');

只需要作一点小调整就可以了,改进后的 engine 实现:

var engine = function(generator){
  var that = this;
  return function(){
    gen = generator.apply(that, arguments);
    func = gen.next().value;
    func(function(response){
      gen.next(response);
    });
  }
}

后面需要对 gen 的调度作进一步控制,所以,我们再单独抽象一层出来,把 engine 改成:

var engine = function(generator){
  var that = this;
  return function(){
    gen = generator.apply(that, arguments);
    if(gen){
        Runner(gen).run()
    }
  }
}

现在在代码上的问题,转换成 Runner 的实现了。

var Runner = function(generator){
  
  var yielded_action = function(yielded){
    ... ...  
  }
  
  var run = function(){
    var yielded = generator.next().value;
    yielded_action(yielded)
  }

  return { run : run }
}

大概的框架是这样。我们把每次 yield 出来的东西,放到 yield_action 中去处理。例如我们目前要解决的问题:

var run = engine(function*(){
  var res_a = yield Task($.get, '/');
  var res_b = yield Task($.get, '/');

  console.log('a -> ', res_a);
  console.log('b -> ', res_b);

});

每次 yield 出来的都是 TaskTask 里面的东西,是前面讲过的,通过 Currying 化处理之后,接收一个 callback 参数的函数。我们只需要在 callback 函数中递归地处理 next() 就好了。

var Runner = function(generator){
  
  var callback = function(response){
      var y = generator.next(response).value;
      if(y){
        yielded_action(y);
      }
  }
  
  var yielded_action = function(yielded){
    yielded(callback);
  }
  
  var run = function(){
    var yielded = generator.next().value;
    yielded_action(yielded)
  }

  return { run : run }
}

因为生成器在结束时,或者提前 return 时, next() 的调用会返回空值,所以加了一句 if(y)

这样,多次 yield 就没有问题了。

6. 更有想像力一点

来继续打磨这个工具吧,前面已经实现了对多次 yield 的支持。在作 yielded_action 时候,不知道大家有没有这样的感觉, yield 出去的东西,怎么处理,这个完全在我们的控制之中。之前是 yield 出去了一个 Task ,所以我们在 yielded_action 作了对应地处理,同样地,如果是 yield 出去一个数字,一个列表,我们仍然可以作对应的处理,只需要在 yielded_action 中加入条件判断的相关逻辑即可。

事实上, Tornado 中,是支持 yield 一个列表的,这个列表的成员全是 Task 。其行为就是当所有的 Task 都被回调之后,再把所有的回调结果作为一个列表返回。类似于:

var run = engine(function*(){
  var res = yield [Task($.get, '/'), Task($.get, '/')];
  console.log('a -> ', res[0]);
  console.log('b -> ', res[1]);
});

而更通用的一个作法,是支持 yield 一个 Callback ,同时,对应地可以 yield 一个 Wait ,这种通用的方式,可以打破“顺序”结构的限制,类似于:

var run = engine(function*(){
  $.get('/', (yield Callback('first')) );

  var res_b = yield Task($.get, '/');
  console.log('b -> ', res[1]);

  console.log('a -> ', (yield Wait('first')) );

});

在实现 js 中的工具时,我并不打算照搬 Tornado 中的形式。

一方面是因为 Python 中有完善的“类”机制,可以很容易地直接判断某个值是不是指定类的实例,比如上面的 Callback / Wait 实例的判断对 Python 来说是无压力的。但是在 js 中,要实现类似的功能,不想大费周折的话,也许只有用 new 了,这个在 js 中我认为奇丑无比的一个东西。

另一方面是,在我能想到的扩展使用方式中,我发现,其实简单地用“类型”,就可以解决大部分问题了。

比如, yield Task ,在 yielded_action 中得到的 Task ,其实就是一个函数。要支持列表,那么在 yielded_action 中得到的就是一个列表。这些不同的形式,是仅仅在“类型”上就可以判断出来的。那么考虑还有哪些类型我们可以加以利用呢?

根据刚才提到的对于类型的考虑,来重新组织一下之前的 Runner 部分的代码,以便之后的扩展:

var Runner = function(generator){
  
  var callback = function(response){
    var y = generator.next(response).value;
    yielded_action(y);
  }
  
  
  var yielded_action = function(yielded){
    if(Object.prototype.toString.call(yielded) === '[object Function]'){
        yielded(callback);
    }
  }
  
  var run = function(){
    var yielded = generator.next().value;
    yielded_action(yielded)
  }
  return { run : run }
}

上面代码中的类型判断, Object.prototype.toString.call 用了一点小技巧,不细说了。

在此基础上,加入对数字的支持,当 yield 一个数字时,功能是延迟执行:

  var yielded_action = function(yielded){
    if(Object.prototype.toString.call(yielded) === '[object Function]'){
        yielded(callback);
    }

    if(Object.prototype.toString.call(yielded) === '[object Number]'){
        setTimeout(callback, yielded);
    }
  }

现在我们可以这样玩了:

var run = engine(function*(name){
  var res_a = yield Task($.get, '/');
  console.log('a ->', res_a.slice(0, 10));
  yield 2000;
  var res_b = yield Task($.get, '/');
  console.log('b ->', res_a.slice(0, 10));
});

run();

在获取到第一个响应之后,等 2 秒,再进行第二次请求处理。

其它的实现这里就不细讲了,最后会给出代码。

实现之后,可以这样写( Node.js 代码):

var http = require('http');

var fetch = function(url, callback){
  http.request({
    hostname: url,
    port: 80,
    path: '/',
    method: 'GET'
  }, function(res){
    res.setEncoding('utf8');
    res.on('data', function(chunk){
      callback(chunk);
    });
  }).end();
}

var timeout_fetch = function(url, sec, callback){
  setTimeout(function(){
    fetch(url, callback);
  }, sec * 1000);
}


engine(function*(){

  var res_a = yield Task(timeout_fetch, 's.zys.me', 1);
  console.log('A', res_a);

  timeout_fetch('s.zys.me', 1, yield 'x');
  yield 1000;
  console.log('...');
  var res_b = yield Task(timeout_fetch, 's.zys.me', 1);
  console.log('B', res_b);

  res = yield Wait('x');
  console.log('x');
  console.log(res);

  var r = yield [Task(timeout_fetch, 's.zys.me', 1),
                 Task(timeout_fetch, 's.zys.me', 2)];

  console.log(r);

})();

7. 事件环境下的同步思维

一般来说,事件,总是异步的,这点没错。即使大部分时候我们的思维都趋向于是同步的,但在事件处理时,我们都更习惯异步的思维方式,比如,当然按钮被点击之后做什么,总是会事先定义好。

异步方式的特点是并行,同步方式的特点是顺序。

所以,当我们在异步环境中,碰到“顺序”相关的场景时,换一种同步的思维与实现方式,问题可能就会变得简单许多,对于事件也是如此,只是,事件可能不像 AJAX 那样直接。

考虑这样的场景,页面上有三个方块,我们要实现的逻辑是,用户只能按从左到右的顺序,依次点亮这些方块。

<div id="a"></div>
<div id="b"></div>
<div id="c"></div>

样式:

$(function(){
  $('div').css({
    width: 100,
    height: 100,
    border: '1px solid black',
    margin: '10px',
    float: 'left'
  });
});

异步思维,可能是给三个 divclick 事件绑定,点击之后,在回调函数中去判断三个 div 目前的状态,以决定是否给它们填充颜色。一看到这个“判断状态”,就知道这肯定不是轻松的活。

而同步的思维,就是,从左到右,处理完第一个 div 之后,再去处理第二个 div ,就是这么简单直接,不涉及任何状态。当然,实现这个,即使不用生成器,你自己去嵌套回调函数也是可以的。

如果用生成器的话:

  engine(function*(){
    var event = yield Task($('#a').click.bind($('#a')));
    $('#a').css('background-color', 'red');
    $('#a').off('click');
  
    yield Task($('#b').click.bind($('#b')));
    $('#b').css('background-color', 'red');
    $('#b').off('click');
  
    yield Task($('#c').click.bind($('#c')));
    $('#c').css('background-color', 'red');
    $('#c').off('click');
  })();

( $('#a').click.bind($('#a')) 这里要显式绑定上下文,是因为 jQuery 在实现 click 这些事件处理 API 时,用了动态上下文 this 的方式 )。

再简单一点:

  engine(function*(){
    for(var query in {'#a': true, '#b': true, '#c': true}){
      var event = yield Task($(query).click.bind($(query)));
      $(query).css('background-color', 'red');
      $(query).off('click');
    }
  })();

才发现, js 中好像没有简单点的同步遍历列表的方法。

8. 最后的完整代码

下面代码的所有能力,在之前的 Node.js 代码中都有展示了。

function Runner(gen){
  var key_response = {};

  function yielded_action(yielded){
    if(Object.prototype.toString.call(yielded) === '[object Function]'){
      var context = {
        get_response_by_key: function(key, callback){
          if(key in key_response){
            var res = key_response[key];
            delete key_response[key];
            callback(res);
          } else {
            key_response[key] = callback;
          }
        }
      };
      yielded.call(context, callback);
      return;
    }

    if(Object.prototype.toString.call(yielded) === '[object Number]'){
      setTimeout(callback, yielded);
      return;
    }

    if(Object.prototype.toString.call(yielded) === '[object String]'){
      var yielded = gen.next(reg_callback(yielded)).value;
      if(yielded === undefined){return}
      yielded_action(yielded);
      return;
    }

    if(Object.prototype.toString.call(yielded) === '[object Array]'){
      var res = new Array(yielded.length);
      var count = 0;

      var cb = function(index){
        return function(response){
          res[index] = response;
          count++;
          if(count == yielded.length){
            callback(res);
          }
        }
      }

      for(var i = 0, l = yielded.length; i < l; i++){ yielded[i](cb(i)) }
      return;
    }
  }

  function reg_callback(key){
    return function(response){
      if(key in key_response){
        var cb = key_response[key];
        delete key_response[key];
        cb(response);
      } else {
        key_response[key] = response;
      }
    }
  }

  function callback(response){
    var yielded = gen.next(response).value;
    if(yielded === undefined){return}
    yielded_action(yielded);
  }

  function run(){
    var yielded = gen.next().value;
    yielded_action(yielded);
  }

  return { run: run }
}




function engine(func){
  var that = this;
  var wrapper = function(){
    gen = func.apply(that, arguments)
    if(gen){
      Runner(gen).run()
    }
  }

  return wrapper
}


function Wait(key){
  var that = this;
  return function(callback){
    this.get_response_by_key.call(that, key, callback);
  }
}


function Task(){
  var func = arguments[0];
  var arg = Array.prototype.slice.call(arguments, 1);
  var that = this;

  return function(callback){
    arg.push(callback)
    func.apply(that, arg);
  }
}
评论
©2010-2020 zouyesheng.com All rights reserved. Powered by GitHub , txt2tags , MathJax