yield 和 yield*
作者: 发布于:

最近在看 koa 的中间件实现时,总是看到 yield* next 这种用法,觉得很困惑。下面是学习成果。

代理 yield

我们先来看看原生语法中,代理 yield(delegating yield)的含义和用法。

首先是下面的代码(普通的 yield):

function* outer() {
    yield 'begin';
    yield inner();
    yield 'end';
}

function* inner() {
    yield 'inner';
}

var it = outer(), v;

v = it.next().value;
console.log(v);            // -> 输出:begin

v = it.next().value;
console.log(v);            // -> 输出:{}
console.log(v.toString()); // -> 输出:[object Generator]

v = it.next().value;
console.log(v);            // -> 输出:end

然后是下面的代码(代理 yield):

function* outer() {
    yield 'begin';

    /*
     * 这行等价于 yield 'inner';就是把inner里面的代码替换过来
     * 同时获得的rt刚好就是inner的返回值
     */
    var rt = yield* inner();
    console.log(rt);  // -> 输出:return from inner

    yield 'end';
}

function* inner() {
    yield 'inner';
    return 'return from inner';
}

var it = outer(), v;

v = it.next().value;
console.log(v);            // -> 输出:begin

v = it.next().value;
console.log(v);            // -> 输出:inner

v = it.next().value;
console.log(v);            // -> 输出:end

根据文档的描述,yield* 后面接受一个 iterable object 作为参数,然后去迭代(iterate)这个迭代器(iterable object),同时 yield* 本身这个表达式的值就是迭代器迭代完成时(done: true)的返回值。调用 generator function 会返回一个 generator object,这个对象本身也是一种 iterable object,所以,我们可以使用 yield* generator_function() 这种写法。

啊,好绕。在我实际的使用过程中,yield* 一般用来在一个 generator 函数里“执行”另一个 generator 函数,并可以取得其返回值。

yield 和 co

我们再来看看 co 中 yield 的用法,代码如下:

co(function* () {

  // yield promise
  var a = yield Promise.resolve(1);
  console.log(a); // -> 输出:1

  // yield thunk
  var b = yield later(10);
  console.log(b); // -> 输出:10

  // yield generator function
  var c = yield fn;
  console.log(c); // -> 输出:fn_1

  // yield generator
  var d = yield fn(5);
  console.log(d); // -> 输出:fn_5

  // yield array
  var e = yield [
    Promise.resolve('a'),
    later('b'),
    fn,
    fn(5)
  ];
  console.log(e); // -> 输出:['a', 'b', 'fn_1', 'fn_5']

  // yield object
  var f = yield {
    'a': Promise.resolve('a'),
    'b': later('b'),
    'c': fn,
    'd': fn(5)
  };
  console.log(f); // -> 输出:{a: 'a', b: 'b', c: 'fn_1', d: 'fn_5'}

  function* fn(n) {
    n = n || 1;
    var a = yield later(n);
    return 'fn_' + a;
  }

  function later(n, t) {
    t = t || 1000;
    return function(done) {
      setTimeout(function() { done(null, n); }, t);
    };
  }

}).catch(function(e) {
  console.error(e);
});

等等!为什么 co 可以直接 yield 一个 generator 而不用 yield*,更奇怪的是居然可以直接 yield 一个 generator function?

实际上,co 里面做了封装,在 co 的 toPromise 代码里有这样的判断:

if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);

所以,其实我们的代码:

// yield generator function
var b = yield fn;
// yield generator
var c = yield fn(5);

实际上会变成类似这样:

var b = yield co(fn);
var c = yield co(fn_generator);

而 co 方法最后会返回一个 Promise,同时还会对传入的参数进行处理,如果是函数则直接调用这个 generator function 取得 generator,大致如下:

function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1)

  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    // 其他代码
  });
}

所以,再 yield 这个 Promise 后,保证了我们取到了正常结果。

其实,我们也可以直接用“原生”的语法写成:

var b = yield* fn();
var c = yield* fn(5);

因为 yield* 接受一个 iterable object 作为参数(比如:generator function 返回的 generator object),所以,我们需要调用 generator function 来返回一个 generator object。

所以,co 背后是封装了很多黑魔法的,这多创建了闭包,会有一点点点点的内存消耗。不是我打错了,根据这里的讨论:https://github.com/koajs/compose/issues/2,确实没有非常要命的影响。而我们的 TJ 大神非常不喜欢在 yield 和代理  yield 间切换,他觉得本来就应该是语言自己去帮我做代理的,反正我就是不想写 yield*

不过嘛,这个 issue 最后的结果还是将 next 实现成了一个 generator object,所以我们才会在各种 koa 的中间件里面看到 yield* next 这种写法。

yield* 的作用

好了,在 co 的世界里,yield* fn “几乎”等价于 yield co(fn)。既然这样,那 yield* 到底有啥用啊?还是有用处的。具体有下面这几点:

  • 用原生语法,抛弃 co 的黑魔法,换取一点点点点性能提升
  • 明确表明自己的意图,避免混淆
  • 调用时保证正确的 this 指向

第一点上面有提到,就不展开说了。

第二点我们来解释下。看看下面的代码,你能知道 yield 后面的到底是个什么鬼吗?

var v = yield later();

如果你不看 later 的实现,实际上你并不知道。later 方法完全可以实现成返回一个 Promise,返回一个 thunk,返回一个数组、对象,或者就是返回 generator object。

但是下面这样的代码:

var v = yield* later();

我们就很自信的知道 later 肯定是返回一个 generator object 了。

第三点也是 co 黑魔法的一个大坑。

比如我们有下面这样的代码:

function later(n, t) {
  t = t || 1000;
  return function(done) {
    setTimeout(function() { done(null, n); }, t);
  };
}
function Runner() {
  this.name = 'runner';
}
Runner.prototype.run = function* (t) {
  var r = yield later(this.name, t);
  return 'run->' + r;
};

var runner = new Runner();
var result = yield runner.run;
console.log(result); // -> 输出:run->undefined

你会发现, result 是 run->undefined,这说明 this 的指向不对了。这是因为 co 实际上是类似这样执行上面的代码的:

var runner = new Runner();
var result = yield co(function() {
    runner.run.call(this); // 这里的this变成了co的实例...
});

破解大法也很简单,就是使用原生语法:

var result = yield* runner.run();
console.log(result); // -> 输出:run->runner

那么,我就说完了。如果各位没有了解过 co 的黑魔法,看的可能会有点累,推荐大家看看参考资料的第一条。

以上。

参考资料:

关于题图:写这篇文章的过程,就像在一个螺旋中不断向前,直达那扇明亮的窗。还有我觉得这个图的感觉和 yield* 很像,我们进入螺旋,开始不断去 yield,最终越过那扇窗,取得结果。