4.5 生成器委托
在前面一节中,我们展示了从生成器内部调用常规函数,以及这如何对于把实现细节(就像异步 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" ); // 通过 run(..) "委托"给*foo() var r3 = yield run( foo ); console.log( r3 ); } run( bar );
我们再次通过 run(..) 工具从 *bar() 内部运行 *foo() 。这里我们利用了如下事实:我们前面定义的 run(..) 返回一个 promise,这个 promise 在生成器运行结束时(或出错退出时)决议。因此,如果从一个 run(..) 调用中 yield 出来一个 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委托! yield 5; } var it = bar(); it.next().value; // 1 it.next().value; // 2 it.next().value; // *foo()启动 // 3 it.next().value; // 4 it.next().value; // *foo()完成 // 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() 迭代器,it 就会自动转回控制 *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" ); // 通过 yeild* "委托"给*foo() var r3 = yield *foo(); console.log( r3 ); } run( bar );
这段代码和前面版本的唯一区别就在于使用了 yield *foo() ,而不是前面的 yield run(foo) 。
yield * 暂停了迭代控制,而不是生成器控制。当你调用 *foo() 生成器时,现在 yield 委托到了它的迭代器。但实际上,你可以 yield 委托到任意 iterable,yield *[1,2,3] 会消耗数组值 [1,2,3] 的默认迭代器。
4.5.1 为什么用委托
yield 委托的主要目的是代码组织,以达到与普通函数调用的对称。
想像一下有两个模块分别提供了方法 foo() 和 bar() ,其中 bar() 调用了 foo() 。一般来说,把两者分开实现的原因是该程序的适当的代码组织要求它们位于不同的函数中。比如,可能有些情况下是单独调用 foo() ,另外一些地方则由 bar() 调用 foo() 。
同样是出于这些原因,保持生成器分离有助于程序的可读性、可维护性和可调试性。在这一方面,yield * 是一个语法上的缩写,用于代替手工在 *foo() 的步骤上迭代,不过是在 *bar() 内部。
如果 *foo() 内的步骤是异步的话,这样的手工方法将会特别复杂,这也是你可能需要使用 run(..) 工具来做某些事情的原因。就像我们已经展示的,yield *foo() 消除了对 run(..) 工具的需要(就像 run(foo) )。
4.5.2 消息委托
你可能会疑惑,这个 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) 调用之后的执行步骤。
(1) 值 3 (通过 *bar() 内部的 yield 委托)传入等待的 *foo() 内部的 yield "C" 表达式。
(2) 然后 *foo() 调用 return "D" ,但是这个值并没有一直返回到外部的 it.next(3) 调用。
(3) 取而代之的是,值 "D" 作为 *bar() 内部等待的 yield*foo() 表达式的结果发出——这个 yield 委托本质上在所有的 *foo() 完成之前是暂停的。所以 "D" 成为 *bar() 内部的最后结果,并被打印出来。
(4) yield "E" 在 *bar() 内部调用,值 "E" 作为 it.next(3) 调用的结果被 yield 发出。
从外层的迭代器(it )角度来说,是控制最开始的生成器还是控制委托的那个,没有任何区别。
实际上,yield 委托甚至并不要求必须转到另一个生成器,它可以转到一个非生成器的一般 iterable。比如:
function *bar() { console.log( "inside *bar():", yield "A" ); // yield委托给非生成器! 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
注意这个例子和之前那个例子在消息接收位置和报告位置上的区别。
最显著的是,默认的数组迭代器并不关心通过 next(..) 调用发送的任何消息,所以值 2 、3 和 4 根本就被忽略了。还有,因为迭代器没有显式的返回值(和前面使用的 *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(); // 注:不会到达这里! 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
这段代码中需要注意以下几点。
(1) 调用 it.throw(2) 时,它会发送错误消息 2 到 *bar() ,它又将其委托给 *foo() ,后者捕获并处理它。然后,yield "C" 把 "C" 发送回去作为 it.throw(2) 调用返回的 value 。
(2) 接下来从 *foo() 内 throw 出来的值 "D" 传播到 *bar() ,这个函数捕获并处理它。然后 yield "E" 把 "E" 发送回去作为 it.next(3) 调用返回的 value 。
(3) 然后,从 *baz() throw 出来的异常并没有在 *bar() 内被捕获——所以 *baz() 和 *bar() 都被设置为完成状态。这段代码之后,就再也无法通过任何后续的 next(..) 调用得到值 "G" ,next(..) 调用只会给 value 返回 undefined 。
4.5.3 异步委托
我们终于回到前面的多个顺序 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 * 机制直接返回。
除此之外的行为非常相似。
4.5.4 递归委托
当然,yield 委托可以跟踪任意多委托步骤,只要你把它们连在一起。甚至可以使用 yield 委托实现异步的生成器递归 ,即一个 yield 委托到它自身的生成器:
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 ) 调用,因为它支持额外的参数和生成器一起传入。但是,这里使用了没有参数的 *bar() ,以展示 yield * 的灵活性。
这段代码后面的处理步骤是怎样的呢?坚持一下,接下来的细节描述可能会非常复杂。
(1) run(bar) 启动生成器 *bar() 。
(2) foo(3) 创建了一个 *foo(..) 的迭代器,并传入 3 作为其参数 val 。
(3) 因为 3 > 1 ,所以 foo(2) 创建了另一个迭代器,并传入 2 作为其参数 val 。
(4) 因为 2 > 1 ,所以 foo(1) 又创建了一个新的迭代器,并传入 1 作为其参数 val 。
(5) 因为 1 > 1 不成立,所以接下来以值 1 调用 request(..) ,并从这第一个 Ajax 调用得到一个 promise。
(6) 这个 promise 通过 yield 传出,回到 *foo(2) 生成器实例。
(7) yield * 把这个 promise 传出回到 *foo(3) 生成器实例。另一个 yield * 把这个 promise 传出回到 *bar() 生成器实例。再有一个 yield * 把这个 promise 传出回到 run(..) 工具,这个工具会等待这个 promsie(第一个 Ajax 请求)的处理。
(8) 这个 promise 决议后,它的完成消息会发送出来恢复 *bar() ;后者通过 yield * 转入 *foo(3) 实例;后者接着通过 yield * 转入 *foo(2) 生成器实例;后者再接着通过 yield * 转入 *foo(3) 生成器实例内部的等待着的普通 yield 。
(9) 第一个调用的 Ajax 响应现在立即从 *foo(3) 生成器实例中返回。这个实例把值作为 *foo(2) 实例中 yield * 表达式的结果返回,赋给它的局部变量 val 。
(10) 在 *foo(2) 中,通过 request(..) 发送了第二个 Ajax 请求。它的 promise 通过 yield 发回给 *foo(1) 实例,然后通过 yield * 一路传递到 run(..) (再次进行步骤 7)。这个 promise 决议后,第二个 Ajax 响应一路传播回到 *foo(2) 生成器实例,赋给它的局部变量 val。
(11) 最后,通过 request(..) 发出第三个 Ajax 请求,它的 promise 传出到 run(..) ,然后它的决议值一路返回,然后 return 返回到 *bar() 中等待的 yield * 表达式。
噫!这么多疯狂的脑力杂耍,是不是?这一部分你可能需要多读几次,然后吃点零食让大脑保持清醒!
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论