Generators + Promises
在我们前面的讨论中,我们展示了generator如何可以异步地迭代,这是一个用顺序的可推理性来取代混乱如面条的回调的一个巨大进步。但我们丢掉了两个非常重要的东西:Promise的可靠性和可组合性(见第三章)!
别担心——我们会把它们拿回来。在ES6的世界中最棒的就是将generator(看似同步的异步代码)与Promise(可靠性和可组合性)组合起来。
但怎么做呢?
回想一下第三章中我们基于Promise的方式运行Ajax的例子:
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}
foo( 11, 31 )
.then(
function(text){
console.log( text );
},
function(err){
console.error( err );
}
);
在我们早先的运行Ajax的例子的generator代码中,foo(..)
什么也不返回(undefined
),而且我们的 迭代器 控制代码也不关心yield
的值。
但这里的Promise相关的foo(..)
在发起Ajax调用后返回一个promise。这暗示着我们可以用foo(..)
构建一个promise,然后从generator中yield
出来,而后 迭代器 控制代码将可以收到这个promise。
那么 迭代器 应当对promise做什么?
它应当监听promise的解析(完成或拒绝),然后要么使用完成消息继续运行generator,要么使用拒绝理由向generator抛出错误。
让我重复一遍,因为它如此重要。发挥Promise和generator的最大功效的自然方法是 yield
一个Promise,并将这个Promise连接到generator的 迭代器 的控制端。
让我们试一下!首先,我们将Promise相关的foo(..)
与generator*main()
放在一起:
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}
function *main() {
try {
var text = yield foo( 11, 31 );
console.log( text );
}
catch (err) {
console.error( err );
}
}
在这个重构中最强大的启示是,*main()
内部的代码 更本就没变! 在generator内部,无论什么样的值被yield
出去都是一个不可见的实现细节,所以我们甚至不会察觉它发生了,也不用担心它。
那么我们现在如何运行*main()
?我们还有一些管道的实现工作要做,接收并连接yield
的promise,使它能够根据解析来继续运行generator。我们从手动这么做开始:
var it = main();
var p = it.next().value;
// 等待`p` promise解析
p.then(
function(text){
it.next( text );
},
function(err){
it.throw( err );
}
);
其实,根本不费事,对吧?
这段代码应当看起来与我们早前做的很相似:手动地连接被错误优先的回调控制的generator。与if (err) { it.throw..
不同的是,promise已经为我们分割为完成(成功)与拒绝(失败),否则 迭代器 控制是完全相同的。
现在,我们已经掩盖了一些重要的细节。
最重要的是,我们利用了这样一个事实:我们知道*main()
里面只有一个Promise相关的步骤。如果我们想要能用Promise驱动一个generator而不管它有多少步骤呢?我们当然不想为每一个generator手动编写一个不同的Promise链!要是有这样一种方法该多好:可以重复(也就是“循环”)迭代的控制,而且每次一有Promise出来,就在继续之前等待它的解析。
另外,如果generator在it.next()
调用期间抛出一个错误怎么办?我们是该退出,还是应该catch
它并把它送回去?相似地,要是我们it.throw(..)
一个Promise拒绝给generator,但是没有被处理,又直接回来了呢?
带有Promise的Generator运行器
你在这条路上探索得越远,你就越能感到,“哇,要是有一些工具能帮我做这些就好了。”而且你绝对是对的。这是一种如此重要的模式,而且你不想把它弄错(或者因为一遍又一遍地重复它而把自己累死),所以你最好的选择是把赌注压在一个工具上,而它以我们将要描述的方式使用这种特定设计的工具来 运行 yield
Promise的generator。
有几种Promise抽象库提供了这样的工具,包括我的 asynquence 库和它的runner(..)
,我们将在本书的在附录A中讨论它。
但看在学习和讲解的份儿上,让我们定义我们自己的名为run(..)
的独立工具:
// 感谢Benjamin Gruenbaum (@benjamingr在GitHub)在此做出的巨大改进!
function run(gen) {
var args = [].slice.call( arguments, 1), it;
// 在当前的上下文环境中初始化generator
it = gen.apply( this, args );
// 为generator的完成返回一个promise
return Promise.resolve()
.then( function handleNext(value){
// 运行至下一个让出的值
var next = it.next( value );
return (function handleResult(next){
// generator已经完成运行了?
if (next.done) {
return next.value;
}
// 否则继续执行
else {
return Promise.resolve( next.value )
.then(
// 在成功的情况下继续异步循环,将解析的值送回generator
handleNext,
// 如果`value`是一个拒绝的promise,就将错误传播回generator自己的错误处理g
function handleErr(err) {
return Promise.resolve(
it.throw( err )
)
.then( handleResult );
}
);
}
})(next);
} );
}
如你所见,它可能比你想要自己编写的东西复杂得多,特别是你将不会想为每个你使用的generator重复这段代码。所以,一个帮助工具/库绝对是可行的。虽然,我鼓励你花几分钟时间研究一下这点代码,以便对如何管理generator+Promise交涉得到更好的感觉。
你如何在我们 正在讨论 的Ajax例子中将run(..)
和*main()
一起使用呢?
function *main() {
// ..
}
run( main );
就是这样!按照我们连接run(..)
的方式,它将自动地,异步地推进你传入的generator,直到完成。
注意: 我们定义的run(..)
返回一个promise,它被连接成一旦generator完成就立即解析,或者收到一个未捕获的异常,而generator没有处理它。我们没有在这里展示这种能力,但我们会在本章稍后回到这个话题。
ES7: async
和 await
?
前面的模式——generator让出一个Promise,然后这个Promise控制generator的 迭代器 向前推进至它完成——是一个如此强大和有用的方法,如果我们能不通过乱七八糟的帮助工具库(也就是run(..)
)来使用它就更好了。
在这方面可能有一些好消息。在写作这本书的时候,后ES6,ES7化的时间表上已经出现了草案,对这个问题提供早期但强大的附加语法支持。显然,现在还太早而不能保证其细节,但是有相当大的可能性它将蜕变为类似于下面的东西:
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}
async function main() {
try {
var text = await foo( 11, 31 );
console.log( text );
}
catch (err) {
console.error( err );
}
}
main();
如你所见,这里没有run(..)
调用(意味着不需要工具库!)来驱动和调用main()
——它仅仅像一个普通函数那样被调用。另外,main()
不再作为一个generator函数声明;它是一种新型的函数:async function
。而最后,与yield
一个Promise相反,我们await
它解析。
如果你await
一个Promise,async function
会自动地知道做什么——它会暂停这个函数(就像使用generator那样)直到Promise解析。我们没有在这个代码段中展示,但是调用一个像main()
这样的异步函数将自动地返回一个promise,它会在函数完全完成时被解析。
提示: async
/ await
的语法应该对拥有C#经验的读者看起来非常熟悉,因为它们基本上是一样的。
这个草案实质上是为我们已经衍生出的模式进行代码化的支持,成为一种语法机制:用看似同步的流程控制代码与Promise组合。将两个世界的最好部分组合,来有效解决我们用回调遇到的几乎所有主要问题。
这样的ES7化草案已经存在,并且有了早期的支持和热忱的拥护。这一事实为这种异步模式在未来的重要性上信心满满地投了有力的一票。
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异步代码要比其他的选择健康得多。虽然有所有这些建议,你仍然要注意你的特殊情况,并为你和你的团队做出合适的决策。