前ES6时代的Generator
我希望你已经被说服了,generator是一个异步编程工具箱里的非常重要的增强工具。但它是ES6中的新语法,这意味着你不能像填补Promise(它只是新的API)那样填补generator。那么如果我们不能奢望忽略前ES6时代的浏览器,我们该如何将generator带到浏览器中呢?
对所有ES6中的新语法的扩展,有一些工具——称呼他们最常见的名词是转译器(transpilers),也就是转换编译器(trans-compilers)——它们会拿起你的ES6语法,并转换为前ES6时代的等价代码(但是明显地变难看了!)。所以,generator可以被转译为具有相同行为但可以在ES5或以下版本进行工作的代码。
但是怎么做到的?yield
的“魔法”听起来不像是那么容易转译的。在我们早先的基于闭包的 迭代器 例子中,实际上提示了一种解决方法。
手动变形
在我们讨论转译器之前,让我们延伸一下,在generator的情况下如何手动转译。这不仅是一个学院派的练习,因为这样做实际上可以帮助我们进一步理解它们如何工作。
考虑这段代码:
// `request(..)` 是一个支持Promise的Ajax工具
function *foo(url) {
try {
console.log( "requesting:", url );
var val = yield request( url );
console.log( val );
}
catch (err) {
console.log( "Oops:", err );
return false;
}
}
var it = foo( "http://some.url.1" );
第一个要注意的事情是,我们仍然需要一个可以被调用的普通的foo()
函数,而且它仍然需要返回一个 迭代器。那么让我们来画出非generator的变形草图:
function foo(url) {
// ..
// 制造并返回 iterator
return {
next: function(v) {
// ..
},
throw: function(e) {
// ..
}
};
}
var it = foo( "http://some.url.1" );
下一个需要注意的地方是,generator通过挂起它的作用域/状态来施展它的“魔法”,但我们可以用函数闭包来模拟。为了理解如何写出这样的代码,我们将先用状态值注释generator不同的部分:
// `request(..)` 是一个支持Promise的Ajax工具
function *foo(url) {
// 状态 *1*
try {
console.log( "requesting:", url );
var TMP1 = request( url );
// 状态 *2*
var val = yield TMP1;
console.log( val );
}
catch (err) {
// 状态 *3*
console.log( "Oops:", err );
return false;
}
}
注意: 为了更准去地讲解,我们使用TMP1
变量将val = yield request..
语句分割为两部分。request(..)
发生在状态*1*
,而将完成值赋给val
发生在状态*2*
。在我们将代码转换为非generator的等价物后,我们就可以摆脱中间的TMP1
。
换句话所,*1*
是初始状态,*2*
是request(..)
成功的状态,*3*
是request(..)
失败的状态。你可能会想象额外的yield
步骤将如何编码为额外的状态。
回到我们被转译的generator,让我们在这个闭包中定义一个变量state
,用它来追踪状态:
function foo(url) {
// 管理 generator 状态
var state;
// ..
}
现在,让我们在闭包内部定义一个称为process(..)
的内部函数,它用switch
语句来处理各种状态。
// `request(..)` 是一个支持Promise的Ajax工具
function foo(url) {
// 管理 generator 状态
var state;
// generator-范围的变量声明
var val;
function process(v) {
switch (state) {
case 1:
console.log( "requesting:", url );
return request( url );
case 2:
val = v;
console.log( val );
return;
case 3:
var err = v;
console.log( "Oops:", err );
return false;
}
}
// ..
}
在我们的generator中每种状态都在switch
语句中有它自己的case
。每当我们需要处理一个新状态时,process(..)
就会被调用。我们一会就回来讨论它如何工作。
对任何generator范围的变量声明(val
),我们将它们移动到process(..)
外面的var
声明中,这样它们就可以在process(..)
的多次调用中存活下来。但是“块儿作用域”的err
变量仅在*3*
状态下需要,所以我们将它留在原处。
在状态*1*
,与yield request(..)
相反,我们return request(..)
。在终结状态*2*
,没有明确的return
,所以我们仅仅return;
也就是return undefined
。在终结状态*3*
,有一个return false
,我们保留它。
现在我们需要定义 迭代器 函数的代码,以便人们恰当地调用process(..)
:
function foo(url) {
// 管理 generator 状态
var state;
// generator-范围的变量声明
var val;
function process(v) {
switch (state) {
case 1:
console.log( "requesting:", url );
return request( url );
case 2:
val = v;
console.log( val );
return;
case 3:
var err = v;
console.log( "Oops:", err );
return false;
}
}
// 制造并返回 iterator
return {
next: function(v) {
// 初始状态
if (!state) {
state = 1;
return {
done: false,
value: process()
};
}
// 成功地让出继续值
else if (state == 1) {
state = 2;
return {
done: true,
value: process( v )
};
}
// generator 已经完成了
else {
return {
done: true,
value: undefined
};
}
},
"throw": function(e) {
// 在状态 *1* 中,有唯一明确的错误处理
if (state == 1) {
state = 3;
return {
done: true,
value: process( e )
};
}
// 否则,是一个不会被处理的错误,所以我们仅仅把它扔回去
else {
throw e;
}
}
};
}
这段代码如何工作?
- 第一个对 迭代器 的
next()
调用将把gtenerator从未初始化的状态移动到状态1
,然后调用process()
来处理这个状态。request(..)
的返回值是一个代表Ajax应答的promise,它作为value
属性从next()
调用被返回。 - 如果Ajax请求成功,第二个
next(..)
调用应当送进Ajax的应答值,它将我们的状态移动到2
。process(..)
再次被调用(这次它被传入Ajax应答的值),而从next(..)
返回的value
属性将是undefined
。 - 然而,如果Ajax请求失败,应当用错误调用
throw(..)
,它将状态从1
移动到3
(而不是2
)。process(..)
再一次被调用,这词被传入了错误的值。这个case
返回false
,所以false
作为throw(..)
调用返回的value
属性。
从外面看——也就是仅仅与 迭代器 互动——这个普通的foo(..)
函数与*foo(..)
generator的工作方式是一样的。所以我们有效地将ES6 generator“转译”为前ES6可兼容的!
然后我们就可以手动初始化我们的generator并控制它的迭代器——调用var it = foo("..")
和it.next(..)
等等——或更好地,我们可以将它传递给我们先前定义的run(..)
工具,比如run(foo,"..")
。
自动转译
前面的练习——手动编写从ES6 generator到前ES6的等价物的变形过程——教会了我们generator在概念上是如何工作的。但是这种变形真的是错综复杂,而且不能很好地移植到我们代码中的其他generator上。手动做这些工作是不切实际的,而且将会把generator的好处完全抵消掉。
但走运的是,已经存在几种工具可以自动地将ES6 generator转换为我们在前一节延伸出的东西。它们不仅帮我们做力气活儿,还可以处理几种我们敷衍而过的情况。
一个这样的工具是regenerator(https://facebook.github.io/regenerator/),由Facebook的聪明伙计们开发的。
如果我们用regenerator来转译我们前面的generator,这就是产生的代码(在编写本文时):
// `request(..)` 是一个支持Promise的Ajax工具
var foo = regeneratorRuntime.mark(function foo(url) {
var val;
return regeneratorRuntime.wrap(function foo$(context$1$0) {
while (1) switch (context$1$0.prev = context$1$0.next) {
case 0:
context$1$0.prev = 0;
console.log( "requesting:", url );
context$1$0.next = 4;
return request( url );
case 4:
val = context$1$0.sent;
console.log( val );
context$1$0.next = 12;
break;
case 8:
context$1$0.prev = 8;
context$1$0.t0 = context$1$0.catch(0);
console.log("Oops:", context$1$0.t0);
return context$1$0.abrupt("return", false);
case 12:
case "end":
return context$1$0.stop();
}
}, foo, this, [[0, 8]]);
});
这和我们的手动推导有明显的相似性,比如switch
/case
语句,而且我们甚至可以看到,val
被拉到了闭包外面,正如我们做的那样。
当然,一个代价是这个generator的转译需要一个帮助工具库regeneratorRuntime
,它持有全部管理一个普通generator/迭代器 所需的可复用逻辑。它的许多模板代码看起来和我们的版本不同,但即便如此,概念还是可以看到的,比如使用context$1$0.next = 4
追踪generator的下一个状态。
主要的结论是,generator不仅限于ES6+的环境中才有用。一旦你理解了它的概念,你可以在你的所有代码中利用他们,并使用工具将代码变形为旧环境兼容的。
这比使用Promise
API的填补来实现前ES6的Promise要做更多的工作,但是努力完全是值得的,因为对于以一种可推理的,合理的,看似同步的顺序风格来表达异步流程控制来说,generator实在是好太多了。
一旦你适应了generator,你将永远不会回到面条般的回调地狱了!