youngwind / blog

梁少峰的个人博客
4.66k stars 385 forks source link

ES6 generator 、yield 与co #49

Open youngwind opened 8 years ago

youngwind commented 8 years ago

起因

最近在看一些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
feibenren commented 6 years ago

发现一个很有意思的现象

//这样不会报错
function delay(time, cb){
 setTimeout(function(){
   cb && cb()
 },time);
}

//这样会报错,为什么???
function delay(time, cb){
console.log(time);
 cb && cb()
}
youngwind commented 6 years ago

@feibenren 报什么错呢?请提供完整的例子和错误信息,不然没有任何帮助。 如何我猜得不错的话,你是指下面这样的情况?但是我实际尝试,并未报错。 es6_generator_ yield_ co_ _issue__49_ _youngwind_blog

feibenren commented 6 years ago

@youngwind ,是这样的,我照着你的例子本地运行了一下,但是我想实现的效果是,不管传入给delay的时间是多少,我都要立马执行,然后我就把delay函数给修改了一下

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

这样就会立马执行,全部代码如下:

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

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

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();

//////////////
$ node test.js
3200ms done!
4400ms done!
5500ms done!

效果达到了,都会立即执行(当然这样没啥实际意义,直接用最简单的函数也可以实现) 后来我又看了delay函数

function delay(time, cb) {
  setTimeout(function() {
    cb && cb();
  }, 0);
}
//能不能简写成
function delay(time, cb) {
    cb && cb();
}
因为反正都是立即执行,要不要定时器无所谓,但是就这么改了一下,反而报错了

$ node test.js
F:\test\test.js:2
  yieldDelay.next();
             ^
TypeError: Generator is already running
    at YieldDelay.next (<anonymous>)
    at cl (F:\test\test.js:2:14)
    at delay (F:\test\test.js:6:9)
    at YieldDelay (F:\test\test.js:13:9)
    at YieldDelay.next (<anonymous>)
    at Object.<anonymous> (F:\test\test.js:22:12)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)

这就不明白什么意思了? 定时器在这里到底有啥用??????

youngwind commented 6 years ago

@feibenren 这现象的确有意思,我并未在官方文档中找到答案,但是下面的这些资料,也许能回答这个问题。

  1. 同步 yield 的坑, By 次碳酸钴
  2. Why does generator's .next need a setTimeout?

我的理解跟上述资料相同:yield 和 next 必须是交替进行的,也就是说,当 yield 执行还没有结束的时候,是不能执行 next 的。在调用 next 之前,会判断 yield 是否已经 return ,如果没有,则报错。在你举出的例子中,就相当于在 yield 里面调用 next 了。