Going Async With ES6 Generators
作者简介:Kyle Simpson is an Open Web Evangelist from Austin, TX, passionate about all things JavaScript. He's an author, workshop trainer, tech speaker, and OSS contributor/leader.
现在你对 generator 函数已经有了深入的了解了,是时候用它们来改进现实世界中的代码了。
Generator 函数的主要优点是它们提供了单线程、同步编码样式的代码风格,同时允许你通过实习细节隐藏异步操作。这让我们可以非常自然地去表达我们程序的 步骤/语句 的流程,而不是像以前一样要同时兼顾着异步语法和错误。
换句话说,通过将值(我们 generator 的逻辑 -- yield)的消费与异步实现这些值(通过 generator 迭代器的 next() 方法)的实现细节分开,我们可以达到 功能/关注点 分离的目的。
结果?功能强大的异步代码具有了和同步代码一样的易读性和可维护性。
那么我们如何完成这个壮举呢?
Simplest Async
在最简单的情况下,generator 不需要额外的程序不具备的异步处理能力。
例如,我们假定你已经有如下代码:
function makeAjaxCall(url,cb) {
// do some ajax fun
// call `cb(result)` when complete
}
makeAjaxCall( "http://some.url.1", function(result1){
var data = JSON.parse( result1 );
makeAjaxCall( "http://some.url.2/?id=" + data.id, function(result2){
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
});
} );
使用 generator (没有任何额外的东西)来表达这个相同的程序,可以这样操作:
function request(url) {
// this is where we're hiding the asynchronicity,
// away from the main code of our generator
// `it.next(..)` is the generator's iterator-resume
// call
makeAjaxCall( url, function(response){
it.next( response );
} );
// Note: nothing returned here!
}
function *main() {
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
var result2 = yield request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
}
var it = main();
it.next(); // get it all started
我们来看看这是如何工作的。
request() 函数包装了我们常规的 makeAjaxCall(),以确保它的回调函数会调用 generator 迭代器的 next() 方法。
你会注意到,request() 函数的调用并没有返回值(换句话说,它的返回值为 undefined)。这并不是什么大不了的事情。但是,与我们文章后面的代码(函数方法)作 比较/联系 的话,这又是一件很重要的事情,因为我们在这里有效地 yield undefined
。
因此,我们认为 yield ..
(yield undefined) 在这一点上,除了暂停我们的 generator 函数外,没有做任何其他事情。它将处于等待状态,直到我们的 Ajax 调用完成,然后调用 it.next()
(作为回调)来恢复我们 generator 的执行。
但是,对于 yield ..
的结果发生了什么?我们把它赋值给了变量 result1
。那么它是如何拥有第一个 Ajax 请求结果的呢?
因为在 Ajax 的回调中,我们调用了 it.next()
,并且将 Ajax 的 response 传入了其中,这就意味着 response 会在暂停点传回到我们的 generator 中,就是通过 result1 = yield ..
这一语句实现的。
这真的很酷、很强大。实质上,result1 = yield request(..)
正在请求值,但是对于我们来说,这一行为(几乎)完全是隐蔽的,至少在这里我们不用去担心它。隐藏的实现造成了这一步的异步操作。通过 yield 隐藏的暂停能力来实现异步,并将 generator 的 恢复/重启 能力分离到另一个函数中,以便我们的主干代码进行的只是同步(看起来)值请求。
对于 result2 = yield result(..)
来说是同样的剧情,暂停&恢复执行,返回我们的请求值,这个过程中你不用担心异步实现细节对你的代码造成影响。
当然,yield 关键字是会存在于代码中的,以便给你一个微妙的提示 —— 在这个点上可能会发生神奇的事情(又称为异步)哦。但是,与嵌套回调的地狱噩梦(又或者是无穷无尽的 promise
链)相比,yield 真的只是一个相当小的语法 标记/开销。
你也应该注意到我说的是"可能发生"。这本身就是一个非常强大的事情。上面的程序都是在做一个异步的 Ajax 请求,但是如果没有呢?如果我们的程序稍后要更改为获取先前(预存) Ajax 请求结果的缓存,该怎么办呢?或者我们应用的 URL 路由中有一些复杂的情况,它们在某些情况下可能要立即执行完成 Ajax 请求,而不需要真正的从服务器上获取它?
我们可以将 request(..) 的实现修改为如下:
var cache = {};
function request(url) {
if (cache[url]) {
// "defer" cached response long enough for current
// execution thread to complete
setTimeout( function(){
it.next( cache[url] );
}, 0 );
}
else {
makeAjaxCall( url, function(resp){
cache[url] = resp;
it.next( resp );
} );
}
}
注意:这里有一处微妙的、狡猾的细节(就是一个非常容易忽略而造成错误的坑),当请求的缓存存在的情况下,需要 setTimeout(.. 0)
来延时。如果我们只是立即去调用 it.next(..)
的话,它就会产生一个错误,因为(这就是棘手的部分)generator 函数从技术上来说它还没有处于暂停状态。我们的函数首先会对 request(..)
进行完全地计算,然后才会 yield
暂停。所以,我们不能在 request(..)
中立即调用 it.next(..)
,因为在那一刻,generator 依然在运行(yield 还没有被处理)。但是我们可以在“稍稍等一下”之后,也就是当前执行线程完成之后,立即调用 it.next(..)
,我们的黑科技 setTimeout(.. 0)
随后完成。我们下面会有一个更好的答案。
对于上面提到的那个棘手的部分,可以看看这两篇文章,应该会有一个更好的理解:
深入探讨JavaScript的执行环境和栈
什么是 Event Loop?
现在,我们的主 generator() 函数的代码仍然如下所示:
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
..
看!?我们 generator 的逻辑(也称为控制流)根本不需要从上面的非缓存版本进行更改。
*main()
的代码仍然是请求一个值,直到得到返回值,然后再继续执行。在我们目前的情况下,“pause”可能是一个相对较长的时间(实际的服务器请求可能是 300-800 ms),或者几乎可以立即执行完成(setTimeout(.. 0)
的黑科技)。但我们的控制流不在乎这些。
这就是将异步作为一个实现细节抽象出来的真正强大之处。
Better Async
上述方法对于简单的异步 generators 是非常好的。但是很快就会发现它的局限性,所以我们需要一个更强大的异步机制与我们的 generators 配合,这样可以处理更多繁重的工作。这个机制就是——Promise。
如果你对 ES6 Promises 不太清楚呢,我写了一系列关于它的文章,去阅读一下吧。我将会等着你归来。<chuckle, chukle>。可怕的异步笑话!(外国友人的幽默...)
早期的 Ajax 代码示例都遭受着所有相同的 Inversion of Control
问题(通俗的来讲就是“回调地狱”),它们会作为我们初始的嵌套回调示例。到目前为止,我们缺少的是以下这些:
-
没有明确的错误处理路径。正如我们上篇文章提到的,我们可以检测到 Ajax 调用的错误(以某种方式),并使用
it.throw()
将错误传回到我们的 generator 中,然后在我们的 generator 逻辑中使用try .. catch
处理它。但是,这相当于在 "back-end" 做了很多手动的工作(这些代码处理我们 generator 的迭代器),如果在我们的程序中要做大量的 generators 的话,代码可能是无法复用的。 -
如果
makeAjaxCall(..)
的功能不在我们的控制之下,它可能调用回调很多次,又或者是同时发出成功和错误信息,等等,那么我们的 generator 将会变的乱七八糟(无法捕获错误,没有期望的输出值,等等)。处理和防止这些问题是需要很多重复的手动工作的,而且代码也可能不可移植。 -
通常,我们需要“并行”执行多个任务(例如,同时进行两个 Ajax 调用)。由于 generator 的 yield 语句每个都是单独的暂停点,所以两个或更多个不能同时运行——它们必须按照顺序一个一个地运行。所以,没有大量的手动代码去处理逻辑,在单个 generator yield 点是无法很好地去发起多个任务的。
正如你所看到的,所有这些问题都是可以解决的,但是我们时刻都想发明一个更加完美的解决方案。我们需要一个更强大的模式,专门为我们基于 generator 异步编码提供可信赖的、可重复使用的解决方案。
那个模式?yield 语句生成 promises,让它们履行恢复 generator 的职能。
回想一下,我们确实进行了 request(..) 的请求,而 request(..) 没有返回任何值,所以它实际上只是 yield undefined?
我们来调整一下。让我们以 promises 为基础改造我们的 request(..) 的实现,以便它能够返回一个 promise 对象,这样我们 yield 实际上是一个 promise 对象(而不是 undefined)。
function request(url) {
// Note: returning a promise now!
return new Promise( function(resolve,reject){
makeAjaxCall( url, resolve );
} );
}
request(..) 现在构建了一个 promise 对象用来进行 Ajax 调用,并在 Ajax 调用完成时 resolve promise,并且返回该 promise 对象,以便我们可以得到这个对象。接下来是什么?
我们需要一个控制 generator 的迭代器,它将接收到这些 yield 产生的 promise 对象,并将它们连接起来以恢复 generator (通过迭代器的 next(..))。现在我将调用 runGenerator(..)。
// run (async) a generator to completion
// Note: simplified approach: no error handling here
function runGenerator(g) {
var it = g(), ret;
// asynchronously iterate over generator
(function iterate(val){
ret = it.next( val );
if (!ret.done) {
// poor man's "is it a promise?" test
if ("then" in ret.value) {
// wait on the promise
ret.value.then( iterate );
}
// immediate value: just send right back in
else {
// avoid synchronous recursion
setTimeout( function(){
iterate( ret.value );
}, 0 );
}
}
})();
}
要注意的主要事项:
- 我们自动初始化 generator (创建它的迭代器),然后我们异步地将其运行到完成状态(done: true)。
- 我们会得到一个 promise 对象(也就是 yield 点,it.next() 调用的返回值)。如果是这样,我们通过在 promise 对象上注册 then() 方法等待它的完成。
- 如果值是立即(也就是 non-promise )返回的,我们只需要将该值返回 generator,以便它立即继续执行。
现在,我们如何使用它?
runGenerator( function *main(){
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
var result2 = yield request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
} );
Bam!! 等等。。。这是与之前完全相同的 generator 函数代码?是的,这就是 generator 的力量。事实上,我们现在正在生成 promise 对象,yield 返回它们,并恢复 generator 的执行到执行完成 -- 所有这些都是“隐藏”来实现细节!它不是真的隐藏来,它只是与业务代码(我们 generator 中的控制流代码)分离了。
通过等待 yield 的 promise,并将它的完成返回值传回 it.next()
,然后 result1 = yield request("http://some.url.1")
会获得与之前完全相同的值。
但是现在我们正在使用 promise 来管理 generator 代码的异步部分,通过回调函数解决所有的 inversion/trust 问题。我们通过使用 generator + promise ,“免费的” 解决了上面提到的那些问题。
我们现在拥有内置的错误处理,而且易扩展。我们没有在 runGrenerator()
中展示这些处理,但是在 promise 中监听错误是不能的。然后可以通过 it.throw() 抛出 —— 我们可以在 generator 中使用 try .. catch 捕获并处理错误。我们可以得到所有 promise 的 控制/可依赖 性。不用担心,也不用大惊小怪。promise 对象上面有很多强大的抽象,可以自动处理多个“并行”任务的复杂性。
例如,yield Promise.all([ .. ]) 将会处理一个 promise 对象数组,然后生成一个单一的 promise 对象(为了让 generator 处理),等所有的 sub-promises 完成(以任何顺序),然后继续执行。你从 yield 表达式处获得的返回值(当所有 promise 完成后)是一个所有 su-promises 的 responses 组成的数组,元素的顺序按照它们的请求顺序排列(因此,无论完成顺序如何,结果都是可以预测的)。
首先,我们来探讨错误处理:
// assume: `makeAjaxCall(..)` now expects an "error-first style" callback (omitted for brevity)
// assume: `runGenerator(..)` now also handles error handling (omitted for brevity)
function request(url) {
return new Promise( function(resolve,reject){
// pass an error-first style callback
makeAjaxCall( url, function(err,text){
if (err) reject( err );
else resolve( text );
} );
} );
}
runGenerator( function *main(){
try {
var result1 = yield request( "http://some.url.1" );
}
catch (err) {
console.log( "Error: " + err );
return;
}
var data = JSON.parse( result1 );
try {
var result2 = yield request( "http://some.url.2?id=" + data.id );
} catch (err) {
console.log( "Error: " + err );
return;
}
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
} );
如果在请求 URL 时发生 promise rejection(或者任何其他类型的错误/异常),则 promise rejection 将映射到一个 generator error(在 runGenerator 中使用未显示的 it.throw(..)),error 将被 try .. catch 语句所捕获。
现在,我们来看一个更复杂的例子,它使用 promise 来管理更多的异步操作:
function request(url) {
return new Promise( function(resolve,reject){
makeAjaxCall( url, resolve );
} )
// do some post-processing on the returned text
.then( function(text){
// did we just get a (redirect) URL back?
if (/^https?:\/\/.+/.test( text )) {
// make another sub-request to the new URL
return request( text );
}
// otherwise, assume text is what we expected to get back
else {
return text;
}
} );
}
runGenerator( function *main(){
var search_terms = yield Promise.all( [
request( "http://some.url.1" ),
request( "http://some.url.2" ),
request( "http://some.url.3" )
] );
var search_results = yield request(
"http://some.url.4?search=" + search_terms.join( "+" )
);
var resp = JSON.parse( search_results );
console.log( "Search results: " + resp.value );
} );
Promise.all([ .. ])
构建来一个由三个 sub-promise
组成的 promise,这个 promise 是 runGenerator( .. ) 监听的 generator 恢复执行时 yield 返回的主 promise。如果 sub-promise 收到的 response 是另一个重定向的 URL,那么就再次 request(URL) 。
你可以使用 promise 处理任何类型的 capability/complexity 异步请求,通过在 generator 中的 yield 点使用 promise,你还可以像写同步逻辑代码一样去处理异步业务。Generator 和 Promise 真是世界上最好的两个东西了。
runGenerator( .. ): Library Utility
我们必须定义自己的 runGenerator( .. ) 函数,以便能够启用和平滑的发挥出 generator + promise 的强大之处。 为了简洁起见,我们省略了程序的实现细节,和更多的错误处理的相关细节。
但是,你不想自己编写 runGenerator( .. ) 吗?
我不这么想。
各种 promise/async 库提供了这样的实用程序,我们这里就会不讲解了,但是你可以看看 Q.spawn( .. )
、co( .. )
等等这样的库。
然而,我将简要介绍我自己库的 utility: asynquence runner( .. )
插件,因为我认为它提供了独一无二的功能。想要了解详情可以阅读我的这篇文章。
首先,asynquence 提供的 utilities 会优先处理上述代码片段中的错误回调。
function request(url) {
return ASQ( function(done){
// pass an error-first style callback
makeAjaxCall( url, done.errfcb );
} );
}
这样很不错,对不对?
接着,asynquence 的 runner( .. )
插件执行异步序列(一系列异步操作)中的 generator,你可以从前面的步骤传递消息,generator 也可以传回消息,在下一步中,所有的 errors 都会自动如你所期的一般去传递。
// first call `getSomeValues()` which produces a sequence/promise,
// then chain off that sequence for more async steps
getSomeValues()
// now use a generator to process the retrieved values
.runner( function*(token){
// token.messages will be prefilled with any messages
// from the previous step
var value1 = token.messages[0];
var value2 = token.messages[1];
var value3 = token.messages[2];
// make all 3 Ajax requests in parallel, wait for
// all of them to finish (in whatever order)
// Note: `ASQ().all(..)` is like `Promise.all(..)`
var msgs = yield ASQ().all(
request( "http://some.url.1?v=" + value1 ),
request( "http://some.url.2?v=" + value2 ),
request( "http://some.url.3?v=" + value3 )
);
// send this message onto the next step
yield (msgs[0] + msgs[1] + msgs[2]);
} )
// now, send the final result of previous generator
// off to another request
.seq( function(msg){
return request( "http://some.url.4?msg=" + msg );
} )
// now we're finally all done!
.val( function(result){
console.log( result ); // success, all done!
} )
// or, we had some error!
.or( function(err) {
console.log( "Error: " + err );
} );
asynquence 的 runner( .. )
utility 接收(可选)调用 generator 时的参数,参数来自之前的步骤中,并且可以在 generator 中通过 token.message
数组访问到。
然后,类似上面使用的 runGenerator( .. ) 所演示的,runner( .. ) 监听 yield 返回的 promise 对象或一系列异步操作(这个例子中是 ASQ().all( .. )
“并行”步骤),并在 gennerator 中等待它的完成。
当 generator 完成时,yield 的最终值传递到序列中的下一步。
而且,如果任何错误发生在这个序列中,即使再 generator 内部,这个错误也会冒泡到注册的错误处理器那里。
asynquence 试图将 promises 和 generators 混合配合使用的尽可能的简单。你可以基于 promise 逻辑处理流程进行扩展,只要你认为合适就 OK 了。
ES7 async
现在 ES7 有一个提议,从时间线上看起来相当可能被接受,这个提议创造另一类函数:异步函数,它就像 generator 一样,自动包装一个 utility ,如 runGenerator()
(或者 asynquence 的 runner())。这样,你可以传递出 promise 对象,并且异步函数自动将其连接起来,以便在完成时恢复自身执行(甚至不需要迭代器)。
他可能看起来像这样:
async function main() {
var result1 = await request( "http://some.url.1" );
var data = JSON.parse( result1 );
var result2 = await request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
}
main();
正如你所看到的,async function
可以被直接调用(就像 main()
),不需要像 runGenerator()
和 ASQ().runner()
那样去包装它。在函数内部,不是使用 yield,而是使用 await (另一个新的关键字)告诉异步函数等 promise 完成后再继续执行。
基本上,我们将拥有大量的类似包装 generators 库提供的功能,但是是直接有原生语法提供的。
很酷,对不对?
与此同时,像 asynquence 给我们 runner 函数能够更加容易地利用异步的 generators。
Summary
简单地说:generator + yield promise 的组合可以让你像写同步逻辑的代码一样去处理异步逻辑的代码。使用一个简单的包装器(很多库已经提供的),我们能够自动执行我们 generator 函数到完成状态,包括像处理同步代码一样的错误处理机制。
在 ES7 的领地里,我们可能将会有原生的 异步函数 可以使用,这样我们可以不用库提供的实现去处理这些逻辑(至少一般情况下是不需要的)。
JavaScript 异步的未来是光明的,而且只会越来越光明!我们必须穿过阴影。
但这并不止于此。我们还要讨论最后一个部分:
如果你将两个或更多的 generator 绑在一起,让它们“并行”地独立运行,并让它们在进行时传递回来消息?这将会是一种强大的能力,对不对?这种模式称为“CSP”(通信顺序过程)。我们将在下一篇文章中探索和解锁 CSP 的力量。留心了!