你不知道JS:异步
第四章:生成器(Generators)
接上篇4-1
生成器委托(Generator Delegation)
在前一节中,我们展示了从生成器内部调用普通函数,以及为什么抽离实现细节是个有用的技术(像异步Promise流)。但采用普通函数的主要缺点是必须遵循不同函数规则,这意味着无法像生成器一样使用yield
来暂停函数自身。
你突然想到,通过辅助函数run(..)
,可以试着从另一个生成器中调用生成器,形如:
function *foo() {
var r2 = yield request( "http://some.url.2" );
var r3 = yield request( "http://some.url.3/?v=" + r2 );
return r3;
}
function *bar() {
var r1 = yield request( "http://some.url.1" );
// "delegating" to `*foo()` via `run(..)`
var r3 = yield run( foo );
console.log( r3 );
}
run( bar );
通过使用run(..)
utility,我们在*bar()
内部运行*foo()
。此处,我们利用了这一事实,即早先定义的run(..)
返回一个promise,当该生成器运行直至结束(或者发生错误)时,该promise得到解析。因此,如果我们yield
出另一个run(..)
调用生成的promise给run(..)
实例,它会自动暂停*bar()
直至*foo()
完成。
但是有个更好的方法来整合*bar()
内的*foo()
调用,称为yield
委托。yield
委托的特殊语法是:yield * _
(注意多出的*
)。在我们看它如何在之前例子中工作之前,先看一个简单点的场景:
function *foo() {
console.log( "`*foo()` starting" );
yield 3;
yield 4;
console.log( "`*foo()` finished" );
}
function *bar() {
yield 1;
yield 2;
yield *foo(); // `yield`-delegation!
yield 5;
}
var it = bar();
it.next().value; // 1
it.next().value; // 2
it.next().value; // `*foo()` starting
// 3
it.next().value; // 4
it.next().value; // `*foo()` finished
// 5
注意: 类似于本章早些时候的注意事项,即为什么我偏爱function *foo() ..
而不是function* foo() ..
,同样,我也偏爱--不同于其它大多数相关文档--用yield *foo()
,而不是yield* foo()
。*
的位置纯粹是风格上的喜好,由你自己决定。但我觉得风格一致比较有吸引力。
yield *foo()
委托是如何工作的呢?
首先,foo()
调用创建了一个迭代器。之后,yield *
委托/转移迭代器实例的控制权(当前*bar()
生成器的)给另一个*foo()
迭代器。
因此,前两个it.next()
调用控制*bar()
,但是当进行第三个it.next()
调用时,*foo()
启动了,现在我们控制foo()
而不是*bar()
了。这就是称为委托的原因--*bar()
将自己迭代的控制权委托给*foo()
。
一旦it
迭代器控制迭代完整个*foo()
迭代器,就会自动返回到*bar()
控制之中。
现在回到之前的三个序列化Ajax请求的例子:
function *foo() {
var r2 = yield request( "http://some.url.2" );
var r3 = yield request( "http://some.url.3/?v=" + r2 );
return r3;
}
function *bar() {
var r1 = yield request( "http://some.url.1" );
// "delegating" to `*foo()` via `yield*`
var r3 = yield *foo();
console.log( r3 );
}
run( bar );
和早先版本唯一的不同是采用了yield *foo()
,而不是之前的yield run(foo)
注意: yield *
yield出迭代控制权,而不是生成器的控制权;当你激活*foo()
生成器时,yield
委托到它的迭代器。但实际上也可以yield
委托任何iterable
,yield *[1,2,3]
会处理[1,2,3]
的默认迭代器。
为什么委托?(Why Delegation?)
yield
委托的主要目的是组织代码,那样的话就和普通的函数调用没什么区别了。
假设两个模块分别提供了foo()
和bar()
方法,bar()
调用foo()
。
分开的原因通常是为合理的代码组织考虑,即可能在不同的函数中调用它们。比如,可能有些时候,foo()
是单独调用的,有时是bar()
调用foo()
。
几乎基于同样的原因,即保持生成器分离有助于提高程序的可读性、可维护性和可调试性。从那个角度讲,当在*bar()
内部时,yield *
是手动迭代*foo()
步骤的简写形式。
如果*foo()
的步骤是异步的,手动方法可能特别复杂,这就是为什么需要run(..)
utility。如上所示,yield *foo()
就不需要run(..)
utility的子实例(比如run(foo)
)了。
委托信息(Delegating Messages)
你可能想知道yield
委托是如何实现迭代器控制和两路信息传递的。通过yield
委托,仔细观察信息的流入、流出:
function *foo() {
console.log( "inside `*foo()`:", yield "B" );
console.log( "inside `*foo()`:", yield "C" );
return "D";
}
function *bar() {
console.log( "inside `*bar()`:", yield "A" );
// `yield`-delegation!
console.log( "inside `*bar()`:", yield *foo() );
console.log( "inside `*bar()`:", yield "E" );
return "F";
}
var it = bar();
console.log( "outside:", it.next().value );
// outside: A
console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B
console.log( "outside:", it.next( 2 ).value );
// inside `*foo()`: 2
// outside: C
console.log( "outside:", it.next( 3 ).value );
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E
console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: 4
// outside: F
特别关注一下it.next(3)
调用后的处理步骤:
- 值
3
被传入*foo()
内(通过*bar()
内的yield
委托)等待的yield "C"
表达式。 - 之后
*foo()
调用return "D"
,但这个值并没有返回给外面的it.next(3)
。 - 反而,
D
值返回作为*bar()
内等待的yield *foo()
表达式的结果--当*foo()
被穷尽时,这种yield
委托表达本质上已经被暂停了。因此*bar()
内的"D"
最终被打印出来了。 -
yield "E"
在*bar()
内部被调用,E
值被yield到外部,作为it.next(3)
调用的结果。
从外部迭代器
(it
)的角度来看,控制初始生成器和委托生成器似乎没什么区别。
事实上,yield
委托甚至没有必要定向到另一个生成器,可以只定向到一个非生成器、通用iterable
。比如:
function *bar() {
console.log( "inside `*bar()`:", yield "A" );
// `yield`-delegation to a non-generator!
console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );
console.log( "inside `*bar()`:", yield "E" );
return "F";
}
var it = bar();
console.log( "outside:", it.next().value );
// outside: A
console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B
console.log( "outside:", it.next( 2 ).value );
// outside: C
console.log( "outside:", it.next( 3 ).value );
// outside: D
console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: undefined
// outside: E
console.log( "outside:", it.next( 5 ).value );
// inside `*bar()`: 5
// outside: F
注意下这个例子和前一个例子中信息的接收和报告的区别。
最不可思议的是,array
的默认迭代器不关心通过next(..)
调用传入的任何信息,因此值2
,3
和4
会被忽略。另外,因为那个迭代器没有显式的return
值(不像之前的*foo()
),当结束的时候,yield *
表达式获得一个undefined
。
也委托异常!(Exceptions Delegated, Too!)
与yield
委托两路透明传值一样,错误/异常也是两路传值的:
function *foo() {
try {
yield "B";
}
catch (err) {
console.log( "error caught inside `*foo()`:", err );
}
yield "C";
throw "D";
}
function *bar() {
yield "A";
try {
yield *foo();
}
catch (err) {
console.log( "error caught inside `*bar()`:", err );
}
yield "E";
yield *baz();
// note: can't get here!
yield "G";
}
function *baz() {
throw "F";
}
var it = bar();
console.log( "outside:", it.next().value );
// outside: A
console.log( "outside:", it.next( 1 ).value );
// outside: B
console.log( "outside:", it.throw( 2 ).value );
// error caught inside `*foo()`: 2
// outside: C
console.log( "outside:", it.next( 3 ).value );
// error caught inside `*bar()`: D
// outside: E
try {
console.log( "outside:", it.next( 4 ).value );
}
catch (err) {
console.log( "error caught outside:", err );
}
// error caught outside: F
这段代码中有些东西需要注意:
- 当调用
it.throw(2)
时,会将错误信息2
发送给*bar()
,*bar()
会将其委托给*foo()
,之后*foo()
catch
它并处理。之后yield "C"
将C
返回作为it.throw(2)
调用的返回value
。 -
*foo()
内部下一个throw
抛出的"D"
值传播到*bar()
中,*bar()
catch
它并处理。之后yield "E"
返回E
作为it.next(3)
调用的返回value
。 - 之后,
*baz()
throw
出的异常没有在*bar()
中捕获--尽管我们确实在外面catch
它--因此,*baz()
和*bar()
都被设为完成状态。这段代码之后,你可能无法利用随后的next(..)
调用获得"G"
值--它们只会简单地返回undefined
作为value
。
委托异步(Delegating Asynchrony)
让我们回到早先的多个序列化Ajax请求的yield
委托例子:
function *foo() {
var r2 = yield request( "http://some.url.2" );
var r3 = yield request( "http://some.url.3/?v=" + r2 );
return r3;
}
function *bar() {
var r1 = yield request( "http://some.url.1" );
var r3 = yield *foo();
console.log( r3 );
}
run( bar );
我们只是简单地在*bar()
内部调用yield *foo()
,而不是yield run(foo)
。
在这一例子的前一版本中,Promise机制(由run(..)
控制)用来传递*foo()
内return r3
的值给*bar()
内的局部变量r3
。现在,现在,那个值通过yield *
机制直接返回。
除此之外,行为完全一致。
委托“递归”(Delegating "Recursion")
当然,yield
委托能够跟踪尽可能多的委托步骤。你甚至可以对异步的生成器“递归”--生成器yield
委托给自己--使用yield
委托:
function *foo(val) {
if (val > 1) {
// generator recursion
val = yield *foo( val - 1 );
}
return yield request( "http://some.url/?v=" + val );
}
function *bar() {
var r1 = yield *foo( 3 );
console.log( r1 );
}
run( bar );
注意: run(..)
utility本该以run(foo,3)
的形式调用,因为它支持附加参数,用来传递给生成器的初始化过程。然而,这里我们用了没有参数的*bar()
,来突出yield *
的灵活性。
那段代码遵循什么执行步骤?坚持住,细节描述有点复杂:
-
run(bar)
启动*bar()
生成器。 -
foo(3)
创建一个*foo(3)
的迭代器,传入3
作为val
的值。 - 因为
3>1
,foo(2)
创建了另一个迭代器,传入2
作为val
的值。 - 因为
2>1
,foo(1)
创建了另一个迭代器,传入1
作为val
的值。 -
1>1
是false
,因此之后以值1
调用request(..)
,获得第一个Ajax调用返回的promise。 - 那个promise被
yield
出来,返回到*foo(2)
生成器实例。 -
yield *
将那个promise传回到*foo(3)
生成器实例。另一个yield *
将promise传出给*bar()
生成器实例。再一次,另一个yield *
将promise传出给run()
utility,它会等待那个promise(第一个Ajax请求)解析。 - 当promise解析后,它的fulfillment信息被发送出去用来恢复
*bar()
,信息通过yield *
传入到*foo(3)
实例,之后通过yield *
传入到*foo(2)
实例,之后通过yield *
传入到*foo(3)
中等待的普通yield
中。 - 现在,第一个调用的Ajax响应立即从
*foo(3)
生成器实例中return
出来,之后将其返回作为*foo(2)
实例中yield *
表达式的结果,并将其赋给局部变量val
。 - 在
*foo(2)
内部,request(..)
发起了第二个Ajax请求,它的promise被yield
回*foo(1)
实例,之后yield *
将其一路传到run(..)
(重复第7步)。当promise解析后,第二个Ajax响应一路传回*foo(2)
生成器实例,赋给局部变量val
。 - 最后,
request(..)
发起了第三个Ajax请求,它的promise返回给run(..)
,之后它的解析值一路返回,直至被return
,以便回到*bar()
中等待的yield *
表达式。
唷!精神饱受摧残了?你可能想再读几次,之后吃点零食清空下大脑!
生成器并发(Generator Concurrency)
如第一章和本章早些时候提到的,两个同时运行的“进程”可以协作式的交叉各自的操作,很多时候能够yield出强大的异步表达式。
坦白来讲,早先的多个生成器并发交叉的例子证明了是多么的令人感到混乱。但我们暗示了某些场合,这种能力非常有用。
回想下第一章中的场景,两个不同的同时Ajax响应处理函数需要相互协调,以便数据通信不会造成竞态。我们把响应像这样放入到res
数组中:
function response(data) {
if (data.url == "http://some.url.1") {
res[0] = data;
}
else if (data.url == "http://some.url.2") {
res[1] = data;
}
}
但在这一场景中,我们如何并发使用多个生成器呢?
// `request(..)` is a Promise-aware Ajax utility
var res = [];
function *reqData(url) {
res.push(
yield request( url )
);
}
注意: 此处我们打算使用*reqData(..)
的两个生成器实例,但是和运行两个不同生成器的单个实例没有什么区别;两种方法的思考方式是一致的。一会我们看看两个不同生成器的协调。
我们会使用协调排序,以便res.push(..)
能够将值以可预料的顺序放置
,而不是手动地分为res[0]
和res[1]
赋值。表达逻辑因此也可以更清晰些。
但实际上该如何编排这种交互呢?首先,让我们手动用Promise实现一下:
var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );
var p1 = it1.next().value;
var p2 = it2.next().value;
p1
.then( function(data){
it1.next( data );
return p2;
} )
.then( function(data){
it2.next( data );
} );
*reqData(..)
的两个实例都开始发起Ajax请求,之后通过yield
暂停。之后当p1
解析后,我们选择恢复第一个实例,之后p2
的解析结果会重启第二个实例。这样的话,我们用promise编排来确保res[0]
存放第一个响应,res[1]
存放第二个响应。
但坦白来讲,这种方式太手动化了,并没有真正让生成器编排它们,这才是强大之处(译者注:指让生成器编排)。让我们换个方式试一下:
// `request(..)` is a Promise-aware Ajax utility
var res = [];
function *reqData(url) {
var data = yield request( url );
// transfer control
yield;
res.push( data );
}
var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );
var p1 = it1.next().value;
var p2 = it2.next().value;
p1.then( function(data){
it1.next( data );
} );
p2.then( function(data){
it2.next( data );
} );
Promise.all( [p1,p2] )
.then( function(){
it1.next();
it2.next();
} );
OK,好一点了(尽管仍然是手动的!),因为现在*reqData(..)
的两个实例真的并发、独立(至少从第一部分来讲)运行。
在上一个代码中,直到第一个实例完全结束之后,第二个才给出它的数据。但这里,只要各自的响应返回,两个实例都能尽快的接收到数据,之后每个实例为了控制转移目的,作了另一个yield
。之后通过在Promise.all([ .. ])
的处理函数中来选择恢复顺序。
不大明显的一点是,由于对称性,这个方法暗含了一种更简单的复用utility形式。我们可以做的更好。假设使用一个称为runAll(..)
的utility:
// `request(..)` is a Promise-aware Ajax utility
var res = [];
runAll(
function*(){
var p1 = request( "http://some.url.1" );
// transfer control
yield;
res.push( yield p1 );
},
function*(){
var p2 = request( "http://some.url.2" );
// transfer control
yield;
res.push( yield p2 );
}
);
注意: 我们没有展示出runAll(..)
的实现代码,不仅因为太长,而且还是早先实现的run(..)
逻辑的拓展。因此,作为读者很好的练习补充,试着从run(..)
演化代码,使其运行原理和想象的runAll(..)
一样。另外,我的asynquence
库提供了之前提到了runner(..)
utility,其内建有这种功能,会在本书的附录A中讨论。
以下是runAll(..)
内部的运行方式:
- 第一个生成器获得来自
"http://some.url.1"
的第一个Ajax响应的promise,之后yield
控制权回到runAll(..)
utility。 - 第二个生成器运行,同样处理
"http://some.url.2"
,yield
控制权回到runAll(..)
utility。 - 第一个生成器恢复,之后
yield
出它的promisep1
,这种情况下,runAll(..)
utility和之前的run(..)
做的一样,在其内部,等待promise的解析,之后恢复同一个生成器(不是控制权转移!)。当p1
解析后,runAll(..)
用解析值再次恢复第一个生成器,之后res[0]
就被赋了该值。当第一个生成器结束之后,有个隐式的控制权转移。 - 第二个生成器恢复,
yield
出promisep2
,等待其解析。一旦解析,runAll(..)
以解析值恢复第二个生成器,并且设置res[1]
。
在这个运行例子中,我们使用了外部变量res
来保存两个不同Ajax响应的结果--这使得并发协调成为可能。
但进一步扩展下runAll(..)
,提供一个由多个生成器实例共享的内部变量空间可能更好,比如下面我们称为data
的空对象。另外,也可以yield
出非Promise变量,并把它们传递给下一个生成器。
考虑如下:
// `request(..)` is a Promise-aware Ajax utility
runAll(
function*(data){
data.res = [];
// transfer control (and message pass)
var url1 = yield "http://some.url.2";
var p1 = request( url1 ); // "http://some.url.1"
// transfer control
yield;
data.res.push( yield p1 );
},
function*(data){
// transfer control (and message pass)
var url2 = yield "http://some.url.1";
var p2 = request( url2 ); // "http://some.url.2"
// transfer control
yield;
data.res.push( yield p2 );
}
);
这种形式中,两个生成器不仅协调控制权转移,而且还相互通信,都是通过data.res
和交换rul1
和rul2
值yield
出的信息。相当强大!
这样的实现也为一种更复杂的称为CSP(通信序列进程,Communicating Sequential Processes)的异步技术充当了概念基础,我们会在本书的附录B中讨论。
Thunks
迄今为止,我们一直假定生成器yield
出Promise--通过如run(..)
的辅助utility让Promise恢复生成器的运行--是用生成器管理异步的最好的方法。说明白点,它就是。
但我们跳过了另一种被广泛接受的模式,因此,为了保证完整性,我们简单看下。
在一般的计算机科学中,有一个很老的、在JS之前的概念,叫"thunk"。就不提它的历史了,在JS中,thunk简单点的表达就是一个调用另一个函数的函数(没有任何参数)。
换句话说,就是用函数定义包装一个函数调用--用它所需的任何参数--来推迟调用的执行,包装函数就称为一个thunk。当之后执行thunk时,最终会调用初始的函数。
例如:
function foo(x,y) {
return x + y;
}
function fooThunk() {
return foo( 3, 4 );
}
// later
console.log( fooThunk() ); // 7
因此,同步的thunk相当直接。但要是异步的thunk呢?我们可以简单地扩展thunk定义,允许接收一个回调。
考虑如下:
function foo(x,y,cb) {
setTimeout( function(){
cb( x + y );
}, 1000 );
}
function fooThunk(cb) {
foo( 3, 4, cb );
}
// later
fooThunk( function(sum){
console.log( sum ); // 7
} );
如你所见,fooThunk(..)
只期望一个cb(..)
参数,因为它已经预设有值3
和4
(分别对应x
和y
),并且准备传给foo(..)
。thunk只需耐心等待做最后一件事:回调。
然而,你并不想手动实现thunk。因此,让我们实现一种utility来为我们做这种包装工作。
考虑如下:
function thunkify(fn) {
var args = [].slice.call( arguments, 1 );
return function(cb) {
args.push( cb );
return fn.apply( null, args );
};
}
var fooThunk = thunkify( foo, 3, 4 );
// later
fooThunk( function(sum) {
console.log( sum ); // 7
} );
提示: 此处我们假定初始函数(foo(..)
)希望回调处在最后的位置,其余任何参数都是在它之前。这是异步JS函数标准中相当常见的“标准”。可以称之为“callback-last style”,如果处于某种原因,你需要处理”callback-first style“的形式,只需在utility中使用args.unshift(..)
,而不是args.push(..)
。
之前的thunkify(..)
实现采用foo(..)
函数引用和其它任何需要的参数,之后返回thunk本身(fooThunk(..)
)。然而,这并不是JS中典型的thunk方法。
如果不是太困惑的话,thunkify(..)
utility会生成一个函数,该函数能生成thunks,而不是thunkify(..)
直接生成thunks。
哦。。。耶。
考虑如下:
function thunkify(fn) {
return function() {
var args = [].slice.call( arguments );
return function(cb) {
args.push( cb );
return fn.apply( null, args );
};
};
}
主要区别在于额外的return function() { .. }
层,以下是用法的不同:
var whatIsThis = thunkify( foo );
var fooThunk = whatIsThis( 3, 4 );
// later
fooThunk( function(sum) {
console.log( sum ); // 7
} );
很明显,这段代码暗含的大问题是whatIsThis
如何称呼。它不是thunk,它会生成thunk。有点像"thunk"“工厂”,似乎对其命名没有个统一的标准。
因此,我的提议是“thunkory”(“thunk”+“factory”)。那么,thunkify(..)
生成一个thunkory,之后thunkory生成thunks。原因和我第三章提议的promisory
差不多:
var fooThunkory = thunkify( foo );
var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );
// later
fooThunk1( function(sum) {
console.log( sum ); // 7
} );
fooThunk2( function(sum) {
console.log( sum ); // 11
} );
注意: 运行的foo(..)
期望的回调形式不是“error-first style”。当然,“error-first style”更常见。如果foo(..)
预期会有某种合理的错误生成,我们可以修改一下,采用错误优先回调。随后的thunkify(..)
不关心假定的是哪种形式的回调。使用方法上的唯一区别就是fooThunk1(function(err,sum){..
。
暴露thunkory方法--而不是早先的thunkify(..)
将中间步骤隐藏起来--似乎增加了不必要的复杂度。但通常而言,在程序开始之前,生成thunkory来包装现有的API方法很有用,当需要thunk的时候,可以传入并调用这些thunkory。两个分开的步骤实现了更清晰的功能分离。
举例如下:
// cleaner:
var fooThunkory = thunkify( foo );
var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );
// instead of:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );
不管你喜欢显式还是隐式地处理thunkory,thunk fooThunk1(..)
和fooThunk2(..)
的用法仍然一样。
s/promise/thunk/
那么,thunk如何处理生成器呢?
通常将thunk比作promise:它们不是直接可以相互取代的,因为在行为上并不对等。相比于裸奔的thunk,Promise功能更强,更值得信任。
但从另一方面来说,它们都可以视作请求一个值,并且都是异步的。
回想下第三章中我们定义的用来promisify函数的utility,称为Promise.wrap(..)
--我们也可称之为promisify(..)
!这个Promise 包装utility并不生成Promise;它生成promisory,promisory能够生成Promise。这与讨论的thunkory和thunk完全一致。
为了说明一致性,首先将早先的foo(..)
例子改为“error-first style”回调:
function foo(x,y,cb) {
setTimeout( function(){
// assume `cb(..)` as "error-first style"
cb( null, x + y );
}, 1000 );
}
现在,我们比较下使用thunkify(..)
和promisify(..)
(即第三章中的Promise.wrap(..)
):
// symmetrical: constructing the question asker
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );
// symmetrical: asking the question
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );
// get the thunk answer
fooThunk( function(err,sum){
if (err) {
console.error( err );
}
else {
console.log( sum ); // 7
}
} );
// get the promise answer
fooPromise
.then(
function(sum){
console.log( sum ); // 7
},
function(err){
console.error( err );
}
);
本质而言,thunkory和promisory都在问一个问题(请求值),thunk fooThunk
和promise fooPromise
分别代表问题的未来答案。从那个角度而言,一致性很明显。
有这样的想法之后,为了实现异步,yield
Promise的生成器也可以yield
thunk。我们所需的只是个更精简的run(..)
utility(和之前的差不多),不仅能够搜寻并连接到一个yield
出的Promise,而且能够为yield
出的thunk提供回调函数。
考虑如下:
function *foo() {
var val = yield request( "http://some.url.1" );
console.log( val );
}
run( foo );
在这个例子中,request(..)
既可以是个返回promise的promisory,又可以是个返回thunk的thunkory。从生成器内部代码逻辑角度而言,我们不关心实现细节,这相当强大!
因此,request(..)
可以是这样:
// promisory `request(..)` (see Chapter 3)
var request = Promise.wrap( ajax );
// vs.
// thunkory `request(..)`
var request = thunkify( ajax );
最后,作为早先run(..)
utility的thunk式补丁,可能需要如下的逻辑:
// ..
// did we receive a thunk back?
else if (typeof next.value == "function") {
return new Promise( function(resolve,reject){
// call the thunk with an error-first callback
next.value( function(err,msg) {
if (err) {
reject( err );
}
else {
resolve( msg );
}
} );
} )
.then(
handleNext,
function handleErr(err) {
return Promise.resolve(
it.throw( err )
)
.then( handleResult );
}
);
}
现在,我们的生成器既可以调用promisory yield
Promise,也可以调用thunkory yield
thunk,并且无论哪一种情形,run(..)
都能够处理那个值并且使用它来等待其完成,继而恢复生成器。
由于对称性,这两个方法看起来一致。然而,我们应该指出的是,只有从Promise或者thunk代表能够推进生成器执行的未来值的角度来说,这才是正确的。
从更大角度而言,thunk内部几乎没有任何Promise所具备的可信任性和可组合性保证。在这种特定的生成器异步模式中,使用thunk作为Promise的替身是有效地,但相比于Promise提供的种种好处(见第三章),使用thunk应视为不太理想的方案。
如果可以选择,优先用yield pr
而不是yield th
。但让run(..)
utility可以处理这两种值类型没什么问题。
提示: 我的asynquence库中的runner(..)
utility,能够处理Promise,thunk和asynquence序列。
ES6前的生成器(Pre-ES6 Generators)
现在,你很希望相信生成器是异步编程工具箱中一个非常重要的添加项。但它是ES6中新增的语法,意味着你无法像Promise(只是一个新的API)那样polyfill 生成器。那么如果无法忽略ES6前的浏览器,我们如何将生成器引入浏览器中呢?
对于所有ES6中的语法扩展,有些工具--最常用的术语叫转译器,全称转换-编译--可以提供给你ES6语法,并将其转换为对等的(但相当丑陋)ES6前的语法。因此,生成器可以转译为具有同样的行为的代码,能够在ES5或者更低的版本JS中运行。
但怎么转呢?yield
“魔法”听起来很明显不容易转译。其实在早先的基于闭包的迭代器中,我们已经暗示了一种解决方案。
手动转换(Manual Transformation)
在我们讨论转译器之前,让我们研究一下手动转译生成器是如何工作的。这不仅仅是个学术活动,也可以帮助你增强对其工作原理的理解。
考虑如下:
// `request(..)` is a Promise-aware Ajax utility
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()
函数,并且仍然需要返回一个迭代器。因此,简单勾画一下非生成器转译:
function foo(url) {
// ..
// make and return an iterator
return {
next: function(v) {
// ..
},
throw: function(e) {
// ..
}
};
}
var it = foo( "http://some.url.1" );
接下来要关心的是生成器通过暂停它的域/状态来实现其“魔法”,但我们可以用函数闭包来模拟。为理解如何写这些代码,我们首先用状态值注释一下生成器的不同部分:
// `request(..)` is a Promise-aware Ajax utility
function *foo(url) {
// STATE *1*
try {
console.log( "requesting:", url );
var TMP1 = request( url );
// STATE *2*
var val = yield TMP1;
console.log( val );
}
catch (err) {
// STATE *3*
console.log( "Oops:", err );
return false;
}
}
注意: 为了更准确地说明,我们采用临时变量TMP1
,将val = yield request..
语句分成两部分。request(..)
在状态*1*
时发生,将其完成值赋给变量val
在状态*2*
时发生。当将代码转换为它的非生成器对等时,我们需要去掉中间的TMP1
。
换句话说,*1*
是开始状态,*2*
是request(..)
成功状态,*3*
是request(..)
失败状态。你可以想象一下附加的yield
步骤是如何编码成附加的状态。
回到我们转译的生成器,让我们在闭包中定义一个变量state
,用来追踪状态:
function foo(url) {
// manage generator state
var state;
// ..
}
现在,在处理状态的闭包中定义一个称为process(..)
的函数,采用switch
语句:
// `request(..)` is a Promise-aware Ajax utility
function foo(url) {
// manage generator state
var state;
// generator-wide variable declarations
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;
}
}
// ..
}
生成器中的每个状态对应switch
语句中的case
。每次需要处理新状态时,就要调用process(..)
。之后我们会讲下它的工作原理。
对于任何一般的生成器变量申明(val
),我们将其移至process(..)
外的var
声明中,这样可供多次process(..)
调用使用。但是“块域”变量err
仅状态*3*
需要使用,因此我们将其放在块里。
在状态*1*
时,我们作了return request(..)
,而不是yield request(..)
。在终止状态*2*
中,没有显式的需要return
,所以只有个简单的return;
这和return undefined
一样。在终止状态*3*
中,有个return false
,我们能够保存该值。
现在我们需要在迭代器内部定义代码,以便能够合适地调用process(..)
:
function foo(url) {
// manage generator state
var state;
// generator-wide variable declarations
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;
}
}
// make and return an iterator
return {
next: function(v) {
// initial state
if (!state) {
state = 1;
return {
done: false,
value: process()
};
}
// yield resumed successfully
else if (state == 1) {
state = 2;
return {
done: true,
value: process( v )
};
}
// generator already completed
else {
return {
done: true,
value: undefined
};
}
},
"throw": function(e) {
// the only explicit error handling is in
// state *1*
if (state == 1) {
state = 3;
return {
done: true,
value: process( e )
};
}
// otherwise, an error won't be handled,
// so just throw it right back out
else {
throw e;
}
}
};
}
这段代码是如何工作的呢?
- 对迭代器
next()
的第一次调用会将生成器从未初始状态转到状态1
,之后调用process()
来处理该状态。request()
的返回值,即Ajax的响应promise,被返回作为next()
调用的value
属性值。 - 如果Ajax请求成功,第二个
next(..)
调用需要传入Ajax响应值,会将状态切换为2
.process(..)
被再次调用(这次需要传入Ajax响应值),从next(..)
返回的value
属性就为undefined
。 - 然而,如果Ajax请求失败,应当以error调用
throw(..)
,会将状态从1
变成3
(而不是2
)。process(..)
再次被调用,这次是以错误值。那个case
返回false
,会被设为throw(..)
调用返回的value
属性值。
从外部看--它只和迭代器交互--这个foo(..)
普通函数和*foo(..)
表现的完全一样。因此,我们已经有效地将ES6生成器转译为pre-ES6的兼容代码!
之后,我们可以手动实例化生成器并控制其迭代器--调用var it = foo("..")
和it.next(..)
,诸如此类--或者更好的方法,我们可以把它传给之前定义的run(..)
utility,即run(foo,"..")
。
自动转译(Automatic Transpilation)
之前的手动转译ES6生成器为pre-ES6等价代码练习从概念上教会了我们生成器是如何工作的。但那种转译真的很复杂,并且不好移植到其它生成器。手动实现相当不切实际,会完全消除生成器的好处。
但幸运的是,已经有几个工具库能够将ES6生成器转译成类似我们之前转译的结果。它们不仅为我们做了繁重的工作,而且也处理了我们一带而过的几个复杂问题。
其中一个工具是regenerator(https://facebook.github.io/regenerator/),来自Facebook的小folk。
如果我们使用regenerator转译之前的生成器,以下是转译后的代码:
// `request(..)` is a Promise-aware Ajax utility
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
。
当然,有个折中,即regenerator转译需要一个辅助库regeneratorRuntime
,其中包含了管理通用生成器/迭代器的所有复用逻辑。许多那样的样版代码看起来不同于我们的版本,但即使是那样,也能看到相关的概念,比如用来追踪生成器状态的context$1$0.next = 4
。
主要的挑战是,生成器不仅仅限于ES6+环境中。一旦你理解了概念,就可以在代码中使用它们,采用工具来将其转译成旧环境兼容的代码。
相比pre-ES6 Promise,只需用个Promise
API polyfill,这里的工作量显然更多,但努力是完全值得的,因为生成器能够以合理、有意义、看起来同步、序列化的方式更好地表达异步流控制。
一旦你迷上了生成器,你就再也不想回到异步的意大利面条式的回调地狱了!
回顾(Review)
生成器是一种新的ES6函数类型,它不是像普通函数那样运行直至结束的。而是,生成器可以在中间过程(完全保持自身状态)暂停,并且之后可以从暂停的地方恢复。
这种暂停/恢复的切换是协作式的,而不是抢占式的。这意味着生成器有独有的能力暂停自己(采用yield
关键字),之后控制生成器的迭代器能够恢复生成器(通过next(..)
)。
yield
/next()
对不仅仅是一种控制机制,实际上还是一种两路信息传递机制。本质上,yield..
表达式暂停生成器并等待值,下一个next(..)
调用传回一个值(或者隐式的undefined
)来恢复生成器。
生成器关于异步流控制的关键优点是,生成器内部的代码能够以同步/序列化的方式表示任务序列。技巧在于我们将异步隐藏到yield
关键字之后了--将异步移到生成器的迭代器控制的代码部分。
换句话说,生成器实现了异步代码的序列化、同步化和阻塞性,这可以让我们的大脑能够更自然地推演代码,解决基于回调的异步的两大缺点之一。