yield 和 yield*

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

代理 yield

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 的用法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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 代码里有这样的判断:

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

所以,其实我们的代码:

1
2
3
4
// yield generator function
var b = yield fn;
// yield generator
var c = yield fn(5);

实际上会变成类似这样:

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

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

1
2
3
4
5
6
7
8
9
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 后,保证了我们取到了正常结果。

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

1
2
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 后面的到底是个什么鬼吗?

1
var v = yield later();

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

但是下面这样的代码:

1
var v = yield* later();

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 实际上是类似这样执行上面的代码的:

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

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

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

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

以上。

参考资料:

本文采用 知识共享署名 3.0 中国大陆许可协议,可自由转载、引用,但需署名作者且注明文章出处 。