特别说明,为便于查阅,文章转自https://github.com/getify/You-Dont-Know-JS
Generator中的Promise并发
至此,所有我们展示过的是一种使用Promise+generator的单步异步流程。但是现实世界的代码将总是有许多异步步骤。
如果你不小心,generator看似同步的风格也许会蒙蔽你,使你在如何构造你的异步并发上感到自满,导致性能次优的模式。那么我们想花一点时间来探索一下其他选项。
想象一个场景,你需要从两个不同的数据源取得数据,然后将这些应答组合来发起第三个请求,最后打印出最终的应答。我们在第三章中用Promise探索过类似的场景,但这次让我们在generator的环境下考虑它。
你的第一直觉可能是像这样的东西:
function *foo() {
var r1 = yield request( "http://some.url.1" );
var r2 = yield request( "http://some.url.2" );
var r3 = yield request(
"http://some.url.3/?v=" + r1 + "," + r2
);
console.log( r3 );
}
// 使用刚才定义的`run(..)`工具
run( foo );
这段代码可以工作,但在我们特定的这个场景中,它不是最优的。你能发现为什么吗?
因为r1
和r2
请求可以——而且为了性能的原因,应该——并发运行,但在这段代码中它们将顺序地运行;直到"http://some.url.1"
请求完成之前,"http://some.url.2"
URL不会被Ajax取得。这两个请求是独立的,所以性能更好的方式可能是让它们同时运行。
但是使用generator和yield
,到底应该怎么做?我们知道yield
在代码中只是一个单独的暂停点,所以你根本不能再同一时刻做两次暂停。
最自然和有效的答案是基于Promise的异步流程,特别是因为它们的时间无关的状态管理能力(参见第三章的“未来的值”)。
最简单的方式:
function *foo() {
// 使两个请求“并行”
var p1 = request( "http://some.url.1" );
var p2 = request( "http://some.url.2" );
// 等待两个promise都被解析
var r1 = yield p1;
var r2 = yield p2;
var r3 = yield request(
"http://some.url.3/?v=" + r1 + "," + r2
);
console.log( r3 );
}
// 使用刚才定义的`run(..)`工具
run( foo );
为什么这与前一个代码段不同?看看yield
在哪里和不在哪里。p1
和p2
是并发地(也就是“并行”)发起的Ajax请求promise。它们哪一个先完成都不要紧,因为promise会一直保持它们的解析状态。
然后我们使用两个连续的yield
语句等待并从promise中取得解析值(分别取到r1
和r2
中)。如果p1
首先解析,yield p1
会首先继续执行然后等待yield p2
继续执行。如果p2
首先解析,它将会耐心地保持解析值知道被请求,但是yield p1
将会首先停住,直到p1
解析。
不管是哪一种情况,p1
和p2
都将并发地运行,并且在r3 = yield request..
Ajax请求发起之前,都必须完成,无论以哪种顺序。
如果这种流程控制处理模型听起来很熟悉,那是因为它基本上和我们在第三章中介绍的,因Promise.all([ .. ])
工具成为可能的“门”模式是相同的。所以,我们也可以像这样表达这种流程控制:
function *foo() {
// 使两个请求“并行”并等待两个promise都被解析
var results = yield Promise.all( [
request( "http://some.url.1" ),
request( "http://some.url.2" )
] );
var r1 = results[0];
var r2 = results[1];
var r3 = yield request(
"http://some.url.3/?v=" + r1 + "," + r2
);
console.log( r3 );
}
// 使用前面定义的`run(..)`工具
run( foo );
注意: 就像我们在第三章中讨论的,我们甚至可以用ES6解构赋值来把var r1 = .. var r2 = ..
赋值简写为var [r1,r2] = results
。
换句话说,在generator+Promise的方式中,Promise所有的并发能力都是可用的。所以在任何地方,如果你需要比“这个然后那个”要复杂的顺序异步流程步骤时,Promise都可能是最佳选择。
Promises,隐藏起来
作为代码风格的警告要说一句,要小心你在 你的generator内部 包含了多少Promise逻辑。以我们描述过的方式在异步性上使用generator的全部意义,是要创建简单,顺序,看似同步的代码,并尽可能多地将异步性细节隐藏在这些代码之外。
比如,这可能是一种更干净的方式:
// 注意:这是一个普通函数,不是generator
function bar(url1,url2) {
return Promise.all( [
request( url1 ),
request( url2 )
] );
}
function *foo() {
// 将基于Promise的并发细节隐藏在`bar(..)`内部
var results = yield bar(
"http://some.url.1",
"http://some.url.2"
);
var r1 = results[0];
var r2 = results[1];
var r3 = yield request(
"http://some.url.3/?v=" + r1 + "," + r2
);
console.log( r3 );
}
// 使用刚才定义的`run(..)`工具
run( foo );
在*foo()
内部,它更干净更清晰地表达了我们要做的事情:我们要求bar(..)
给我们一些results
,而我们将用yield
等待它的发生。我们不必关心在底层一个Promise.all([ .. ])
的Promise组合将被用来完成任务。
我们将异步性,特别是Promise,作为一种实现细节。
如果你要做一种精巧的序列流控制,那么将你的Promise逻辑隐藏在一个仅仅从你的generator中调用的函数里特别有用。举个例子:
function bar() {
return Promise.all( [
baz( .. )
.then( .. ),
Promise.race( [ .. ] )
] )
.then( .. )
}
有时候这种逻辑是必须的,而如果你直接把它扔在你的generator内部,你就违背了大多数你使用generator的初衷。我们 应当 有意地将这样的细节从generator代码中抽象出去,以使它们不会搞乱更高层的任务表达。
在创建功能强与性能好的代码之上,你还应当努力使代码尽可能地容易推理和维护。
注意: 对于编程来说,抽象不总是一种健康的东西——许多时候它可能在得到简洁的同时增加复杂性。但是在这种情况下,我相信你的generator+Promise异步代码要比其他的选择健康得多。虽然有所有这些建议,你仍然要注意你的特殊情况,并为你和你的团队做出合适的决策。
Generator 委托
在上一节中,我们展示了从generator内部调用普通函数,和它如何作为一种有用的技术来将实现细节(比如异步Promise流程)抽象出去。但是为这样的任务使用普通函数的缺陷是,它必须按照普通函数的规则行动,也就是说它不能像generator那样用yield
来暂停自己。
在你身上可能发生这样的事情:你可能会试着使用我们的run(..)
帮助函数,从一个generator中调用另个一generator。比如:
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" );
// 通过`run(..)`“委托”到`*foo()`
var r3 = yield run( foo );
console.log( r3 );
}
run( bar );
通过再一次使用我们的run(..)
工具,我们在*bar()
内部运行*foo()
。我们利用了这样一个事实:我们早先定义的run(..)
返回一个promise,这个promise在generator运行至完成时才解析(或发生错误),所以如果我们从一个run(..)
调用中yield
出一个promise给另一个run(..)
,它就会自动暂停*bar()
直到*foo()
完成。
但这里有一个更好的办法将*foo()
调用整合进*bar()
,它称为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()
generator的) 迭代器 的控制委托/传递给这另一个*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" );
// 通过`run(..)`“委托”到`*foo()`
var r3 = yield *foo();
console.log( r3 );
}
run( bar );
这个代码段和前面使用的版本的唯一区别是,使用了yield *foo()
而不是前面的yield run(foo)
。
注意: yield *
让出了迭代控制,不是generator控制;当你调用*foo()
generator时,你就yield
委托给它的 迭代器。但你实际上可以yield
委托给任何 迭代器;yield *[1,2,3]
将会消费默认的[1,2,3]
数组值 迭代器。
为什么委托?
yield
委托的目的很大程度上是为了代码组织,而且这种方式是与普通函数调用对称的。
想象两个分别提供了foo()
和bar()
方法的模块,其中bar()
调用foo()
。它们俩分开的原因一般是由于为了程序将它们作为分离的程序来调用而进行的恰当组织。例如,可能会有一些情况foo()
需要被独立调用,而其他地方bar()
来调用foo()
。
由于这些完全相同的原因,将generator分开可以增强程序的可读性,可维护性,与可调试性。从这个角度讲,yield *
是一种快捷的语法,用来在*bar()
内部手动地迭代*foo()
的步骤。
如果*foo()
中的步骤是异步的,这样的手动方式可能会特别复杂,这就是为什么你可能会需要那个run(..)
工具来做它。正如我们已经展示的,yield *foo()
消灭了使用run(..)
工具的子实例(比如run(foo)
)的需要。
委托消息
你可能想知道,这种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`-委托!
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
被传入(通过*bar
里的yield
委托)在*foo()
内部等待中的yield "C"
表达式。 - 然后
*foo()
调用return "D"
,但是这个值不会一路返回到外面的it.next(3)
调用。 - 相反地,值
"D"
作为结果被发送到在*bar()
内部等待中的yield *foo()
表示式——这个yield
委托表达式实质上在*foo()
被耗尽之前一直被暂停着。所以"D"
被送到*bar()
内部来让它打印。 -
yield "E"
在*bar()
内部被调用,而且值"E"
被让出到外部作为it.next(3)
调用的结果。
从外部 迭代器(it
)的角度来看,在初始的generator和被委托的generator之间的控制没有任何区别。
事实上,yield
委托甚至不必指向另一个generator;它可以仅被指向一个非generator的,一般的 iterable。比如:
function *bar() {
console.log( "inside `*bar()`:", yield "A" );
// `yield`-委托至一个非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
。
异常也委托!
与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"
作为返回的value
发送回it.throw(2)
调用。 - 接下来值
"D"
被从*foo()
内部throw
出来并传播到*bar()
,*bar()
会catch
它并平静地处理。然后yield "E"
把"E"
作为返回的value
发送回it.next(3)
调用。 - 接下来,一个异常从
*baz()
中throw
出来,而没有被*bar()
捕获——我们没在外面catch
它——所以*baz()
和*bar()
都被设置为完成状态。这段代码结束后,即便有后续的next(..)
调用,你也不会得到值"G"
——它们的value
将返回undefined
。
异步委托
最后让我们回到早先的多个顺序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 run(foo)
不同的是,我们调用yield *foo()
就可以了。
在前一个版本的这个例子中,Promise机制(通过run(..)
控制的)被用于将值从*foo()
中的return r3
传送到*bar()
内部的本地变量r3
。现在,这个值通过yield *
机制直接返回。
除此以外,它们的行为是一样的。
“递归”委托
当然,yield
委托可以一直持续委托下去,你想连接多少步骤就连接多少。你甚至可以在具有异步能力的generator上“递归”使用yield
委托——一个yield
委托至自己的generator:
function *foo(val) {
if (val > 1) {
// 递归委托
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(..)
工具本可以用run( foo, 3 )
来调用,因为它支持用额外传递的参数来进行generator的初始化。然而,为了在这里高调展示yield *
的灵活性,我们使用了无参数的*bar()
。
这段代码之后的处理步骤是什么?坚持住,它的细节要描述起来可是十分错综复杂:
-
run(bar)
启动了*bar()
generator。 -
foo(3)
为*foo(..)
创建了 迭代器 并传递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)
generator实例。 -
yield *
将这个promise传出并回到*foo(3)
生成generator。另一个yield *
把这个promise传出到*bar()
generator实例。而又有另一个yield *
把这个promise传出到run(..)
工具,而它将会等待这个promise(第一个Ajax请求)再处理。 - 当这个promise解析时,它的完成消息会被发送以继续
*bar()
,*bar()
通过yield *
把消息传递进*foo(3)
实例,*foo(3)
实例通过yield *
把消息传递进*foo(2)
generator实例,*foo(2)
实例通过yield *
把消息传给那个在*foo(3)
generator实例中等待的一般的yield
。 - 这第一个Ajax调用的应答现在立即从
*foo(3)
generator实例中被return
,作为*foo(2)
实例中yield *
表达式的结果发送回来,并赋值给本地val
变量。 -
*foo(2)
内部,第二个Ajax请求用request(..)
发起,它的promise被yield
回到*foo(1)
实例,然后一路yield *
传播到run(..)
(回到第7步)。当promise解析时,第二个Ajax应答一路传播回到*foo(2)
generator实例,并赋值到他本地的val
变量。 - 最终,第三个Ajax请求用
request(..)
发起,它的promise走出到run(..)
,然后它的解析值一路返回,最后被return
到在*bar()
中等待的yield *
表达式。
天!许多疯狂的头脑杂技,对吧?你可能想要把它通读几遍,然后抓点儿零食放松一下大脑!
Generator并发
正如我们在第一章和本章早先讨论过的,另个同时运行的“进程”可以协作地穿插它们的操作,而且许多时候这可以产生非常强大的异步表达式。
坦白地说,我们前面关于多个generator并发穿插的例子,展示了这真的容易让人糊涂。但我们也受到了启发,有些地方这种能力十分有用。
回想我们在第一章中看过的场景,两个不同但同时的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;
}
}
但是我们如何在这种场景下使用多generator呢?
// `request(..)` 是一个基于Promise的Ajax工具
var res = [];
function *reqData(url) {
res.push(
yield request( url )
);
}
注意: 我们将在这里使用两个*reqData(..)
generator的实例,但是这和分别使用两个不同generator的一个实例没有区别;这两种方式在道理上完全一样的。我们过一会儿就会看到两个generator的协调操作。
与不得不将res[0]
和res[1]
赋值手动排序不同,我们将使用协调过的顺序,让res.push(..)
以可预见的顺序恰当地将值放在预期的位置。如此被表达的逻辑会让人感觉更干净。
但是我们将如何实际安排这种互动呢?首先,让我们手动实现它:
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]
持有第二个应答。
但坦白地说,这是可怕的手动,而且它没有真正让generator组织它们自己,而那才是真正的力量。让我们用不同的方法试一下:
// `request(..)` 是一个基于Promise的Ajax工具
var res = [];
function *reqData(url) {
var data = yield request( url );
// 传递控制权
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();
} );
好的,这看起来好些了(虽然仍然是手动),因为现在两个*reqData(..)
的实例真正地并发运行了,而且(至少是在第一部分)是独立的。
在前一个代码段中,第二个实例在第一个实例完全完成之前没有给出它的数据。但是这里,只要它们的应答一返回这两个实例就立即分别收到他们的数据,然后每个实例调用另一个yield
来传送控制。最后我们在Promise.all([ .. ])
的处理器中选择用什么样的顺序继续它们。
可能不太明显的是,这种方式因其对称性启发了一种可复用工具的简单形式。让我们想象使用一个称为runAll(..)
的工具:
// `request(..)` 是一个基于Promise的Ajax工具
var res = [];
runAll(
function*(){
var p1 = request( "http://some.url.1" );
// 传递控制权
yield;
res.push( yield p1 );
},
function*(){
var p2 = request( "http://some.url.2" );
// 传递控制权
yield;
res.push( yield p2 );
}
);
注意: 我们没有包含runAll(..)
的实现代码,不仅因为它长得无法行文,也因为它是一个我们已经在先前的 run(..)
中实现的逻辑的扩展。所以,作为留给读者的一个很好的补充性练习,请你自己动手改进run(..)
的代码,来使它像想象中的runAll(..)
那样工作。另外,我的 asynquence 库提供了一个前面提到过的runner(..)
工具,它内建了这种能力,我们将在本书的附录A中讨论它。
这是runAll(..)
内部的处理将如何操作:
- 第一个generator得到一个代表从
"http://some.url.1"
来的Ajax应答,然后将控制权yield
回到runAll(..)
工具。 - 第二个generator运行,并对
"http://some.url.2"
做相同的事,将控制权yield
回到runAll(..)
工具。 - 第一个generator继续,然后
yield
出他的promisep1
。在这种情况下runAll(..)
工具和我们前面的run(..)
做同样的事,它等待promise解析,然后继续这同一个generator(没有控制传递!)。当p1
解析时,runAll(..)
使用解析值再一次继续第一个generator,而后res[0]
得到它的值。在第一个generator完成之后,有一个隐式的控制权传递。 - 第二个generator继续,
yield
出它的promisep2
,并等待它的解析。一旦p2
解析,runAll(..)
使用这个解析值继续第二个generator,于是res[1]
被设置。
在这个例子中,我们使用了一个称为res
的外部变量来保存两个不同的Ajax应答的结果——这是我们的并发协调。
但是这样做可能十分有帮助:进一步扩展runAll(..)
使它为多个generator实例提供 分享的 内部的变量作用域,比如一个我们将在下面称为data
的空对象。另外,它可以接收被yield
的非Promise值,并把它们交给下一个generator。
考虑这段代码:
// `request(..)` 是一个基于Promise的Ajax工具
runAll(
function*(data){
data.res = [];
// 传递控制权(并传递消息)
var url1 = yield "http://some.url.2";
var p1 = request( url1 ); // "http://some.url.1"
// 传递控制权
yield;
data.res.push( yield p1 );
},
function*(data){
// 传递控制权(并传递消息)
var url2 = yield "http://some.url.1";
var p2 = request( url2 ); // "http://some.url.2"
// 传递控制权
yield;
data.res.push( yield p2 );
}
);
在这个公式中,两个generator不仅协调控制传递,实际上还互相通信:通过data.res
,和交换url1
与url2
的值的yield
消息。这强大到不可思议!
这样的认识也是一种更为精巧的称为CSP(Communicating Sequential Processes——通信顺序处理)的异步技术的概念基础,我们将在本书的附录B中讨论它。
Thunks
至此,我们都假定从一个generator中yield
一个Promise——让这个Promise使用像run(..)
这样的帮助工具来推进generator——是管理使用generator的异步处理的最佳方法。明白地说,它是的。
但是我们跳过了一个被轻度广泛使用的模式,为了完整性我们将简单地看一看它。
在一般的计算机科学中,有一种老旧的前JS时代的概念,称为“thunk”。我们不在这里赘述它的历史,一个狭隘的表达是,thunk是一个JS函数——没有任何参数——它连接并调用另一个函数。
换句话讲,你用一个函数定义包装函数调用——带着它需要的所有参数——来 推迟 这个调用的执行,而这个包装用的函数就是thunk。当你稍后执行thunk时,你最终会调用那个原始的函数。
举个例子:
function foo(x,y) {
return x + y;
}
function fooThunk() {
return foo( 3, 4 );
}
// 稍后
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 );
}
// 稍后
fooThunk( function(sum){
console.log( sum ); // 7
} );
如你所见,fooThunk(..)
仅需要一个cb(..)
参数,因为它已经预先制定了值3
和4
(分别为x
和y
)并准备传递给foo(..)
。一个thunk只是在外面耐心地等待着它开始工作所需的最后一部分信息:回调。
但是你不会想要手动制造thunk。那么,让我们发明一个工具来为我们进行这种包装。
考虑这段代码:
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 );
// 稍后
fooThunk( function(sum) {
console.log( sum ); // 7
} );
提示: 这里我们假定原始的(foo(..)
)函数签名希望它的回调的位置在最后,而其它的参数在这之前。这是一个异步JS函数的相当普遍的“标准”。你可以称它为“回调后置风格”。如果因为某些原因你需要处理“回调优先风格”的签名,你只需要制造一个使用args.unshift(..)
而非args.push(..)
的工具。
前面的thunkify(..)
公式接收foo(..)
函数的引用,和任何它所需的参数,并返回thunk本身(fooThunk(..)
)。然而,这并不是你将在JS中发现的thunk的典型表达方式。
与thunkify(..)
制造thunk本身相反,典型的——可能有点儿让人困惑的——thunkify(..)
工具将产生一个制造thunk的函数。
额...是的。
考虑这段代码:
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 );
// 稍后
fooThunk( function(sum) {
console.log( sum ); // 7
} );
明显地,这段代码隐含的最大的问题是,whatIsThis
叫什么合适?它不是thunk,它是一个从foo(..)
调用生产thunk的东西。它是一种“thunk”的“工厂”。而且看起来没有任何标准的意见来命名这种东西。
所以,我的提议是“thunkory”("thunk" + "factory")。于是,thunkify(..)
制造了一个thunkory,而一个thunkory制造thunks。这个道理与第三章中我的“promisory”提议是对称的:
var fooThunkory = thunkify( foo );
var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );
// 稍后
fooThunk1( function(sum) {
console.log( sum ); // 7
} );
fooThunk2( function(sum) {
console.log( sum ); // 11
} );
注意: 这个例子中的foo(..)
期望的回调不是“错误优先风格”。当然,“错误优先风格”更常见。如果foo(..)
有某种合理的错误发生机制,我们可以改变而使它期望并使用一个错误优先的回调。后续的thunkify(..)
不会关心回调被预想成什么样。用法的唯一区别是fooThunk1(function(err,sum){..
。
暴露出thunkory方法——而不是像早先的thunkify(..)
那样将中间步骤隐藏起来——可能看起来像是没必要的混乱。但是一般来讲,在你的程序一开始就制造一些thunkory来包装既存API的方法是十分有用的,然后你就可以在你需要thunk的时候传递并调用这些thunkory。这两个区别开的步骤保证了功能上更干净的分离。
来展示一下的话:
// 更干净:
var fooThunkory = thunkify( foo );
var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );
// 而这个不干净:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );
不管你是否愿意明确对付thunkory,thunk(fooThunk1(..)
和fooThunk2(..)
)的用法还是一样的。
s/promise/thunk/
那么所有这些thunk的东西与generator有什么关系?
一般性地比较一下thunk和promise:它们是不能直接互换的,因为它们在行为上不是等价的。比起单纯的thunk,Promise可用性更广泛,而且更可靠。
但从另一种意义上讲,它们都可以被看作是对一个值的请求,这个请求可能被异步地应答。
回忆第三章,我们定义了一个工具来promise化一个函数,我们称之为Promise.wrap(..)
——我们本来也可以叫它promisify(..)
的!这个Promise化包装工具不会生产Promise;它生产那些继而生产Promise的promisories。这和我们当前讨论的thunkory和thunk是完全对称的。
为了描绘这种对称性,让我们首先将foo(..)
的例子改为假定一个“错误优先风格”回调的形式:
function foo(x,y,cb) {
setTimeout( function(){
// 假定 `cb(..)` 是“错误优先风格”
cb( null, x + y );
}, 1000 );
}
现在,我们将比较thunkify(..)
和promisify(..)
(也就是第三章的Promise.wrap(..)
):
// 对称的:构建问题的回答者
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );
// 对称的:提出问题
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );
// 取得 thunk 的回答
fooThunk( function(err,sum){
if (err) {
console.error( err );
}
else {
console.log( sum ); // 7
}
} );
// 取得 promise 的回答
fooPromise
.then(
function(sum){
console.log( sum ); // 7
},
function(err){
console.error( err );
}
);
thunkory和promisory实质上都是在问一个问题(一个值),thunk的fooThunk
和promise的fooPromise
分别代表这个问题的未来的答案。这样看来,对称性就清楚了。
带着这个视角,我们可以看到为了异步而yield
Promise的generator,也可以为异步而yield
thunk。我们需要的只是一个更聪明的run(..)
工具(就像以前一样),它不仅可以寻找并连接一个被yield
的Promise,而且可以给一个被yield
的thunk提供回调。
考虑这段代码:
function *foo() {
var val = yield request( "http://some.url.1" );
console.log( val );
}
run( foo );
在这个例子中,request(..)
既可以是一个返回一个promise的promisory,也可以是一个返回一个thunk的thunkory。从generator的内部代码逻辑的角度看,我们不关心这个实现细节,这就它强大的地方!
所以,request(..)
可以使以下任何一种形式:
// promisory `request(..)` (见第三章)
var request = Promise.wrap( ajax );
// vs.
// thunkory `request(..)`
var request = thunkify( ajax );
最后,作为一个让我们早先的run(..)
工具支持thunk的补丁,我们可能会需要这样的逻辑:
// ..
// 我们收到了一个回调吗?
else if (typeof next.value == "function") {
return new Promise( function(resolve,reject){
// 使用一个错误优先回调调用thunk
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 );
}
);
}
现在,我们generator既可以调用promisory来yield
Promise,也可以调用thunkory来yield
thunk,而不论那种情况,run(..)
都将处理这个值并等待它的完成,以继续generator。
在对称性上,这两个方式是看起来相同的。然而,我们应当指出这仅仅从Promise或thunk表示延续generator的未来值的角度讲是成立的。
从更高的角度讲,与Promise被设计成的那样不同,thunk没有提供,它们本身也几乎没有任何可靠性和可组合性的保证。在这种特定的generator异步模式下使用一个thunk作为Promise的替代品是可以工作的,但与Promise提供的所有好处相比,这应当被看做是一种次理想的方法。
如果你有选择,那就偏向yield pr
而非yield th
。但是使run(..)
工具可以处理两种类型的值本身没有什么问题。
注意: 在我们将要在附录A中讨论的,我的 asynquence 库中的runner(..)
工具,可以处理yield
的Promise,thunk和 asynquence 序列。
前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,你将永远不会回到面条般的回调地狱了!
复习
generator是一种ES6的新函数类型,它不像普通函数那样运行至完成。相反,generator可以暂停在一种中间完成状态(完整地保留它的状态),而且它可以从暂停的地方重新开始。
这种暂停/继续的互换是一种协作而非抢占,这意味着generator拥有的唯一能力是使用yield
关键字暂停它自己,而且控制这个generator的 迭代器 拥有的唯一能力是继续这个generator(通过next(..)
)。
yield
/next(..)
的对偶不仅是一种控制机制,它实际上是一种双向消息传递机制。一个yield ..
表达式实质上为了等待一个值而暂停,而下一个next(..)
调用将把值(或隐含的undefined
)传递回这个暂停的yield
表达式。
与异步流程控制关联的generator的主要好处是,在一个generator内部的代码以一种自然的同步/顺序风格表达一个任务的各个步骤的序列。这其中的技巧是我们实质上将潜在的异步处理隐藏在yield
关键字的后面——将异步处理移动到控制generator的 迭代器 代码中。
换句话说,generator为异步代码保留了顺序的,同步的,阻塞的代码模式,这允许我们的大脑更自然地推理代码,解决了基于回调的异步产生的两个关键问题中的一个。