[ES6深度解析]10:Generators 续集

一个简短的舞台剧

之前我们关注了生成器的基本行为。这可能有点奇怪,但并不难理解。生成器函数很像常规函数。主要的区别是生成器函数体不会一次全部运行。它每次运行一点,每次执行到yield表达式时暂停。在上一篇关于Generator的文章中有详细的解释,但我们从未做过一个完整的示例,说明所有部分是如何组合在一起的。我们现在就开始吧。

function* someWords() {
  yield "hello";
  yield "world";
}

for (var word of someWords()) {
  alert(word);
}

这个脚本非常简单。但如果你能观察这里发生的一切,就好像所有不同的代码位都是戏剧中的角色,那将是一个相当不同的剧本。它可能是这样的:

场景:	电脑内部,白天
角色:FOR LOOP(for循环);GENERATOR(生成器函数);ALERT(alert函数)

FOR LOOP独自站在台上,戴着安全帽,拿着剪贴板,一切都是公事。

FOR LOOP (大喊): someWords()!

GENERATOR 出现了:一个高个子,黄铜,有时钟装置的绅士。它看起来很友好,但仍然是一座雕像。

FOR LOOP(轻快地拍手):好吧!让我们做些事情吧。(对GENERATOR说):.next()!(执行next函数)

GENERATOR 突然活跃起来。输出:{value: "hello", done: false} 后,它以一个愚蠢的姿势僵住了。

FOR LOOP:alert!(执行alert函数)

ALERT以冲刺的速度进入警戒状态,睁大眼睛,屏住呼吸。我们能感觉到他总是那样。

FOR LOOP(命令alert):告诉用户“Hello”。

ALERT (朝着台下,大声喊):全都停下!

浏览器网页上说,“hello”!

几秒钟的暂停,然后ALERT跑回来,穿过所有的路到FOR LOOP和滑行到停止。

ALERT:用户说OK!

FOR LOOP:(轻快地拍手)好吧!让我们做些事情吧。(又转过身对GENERATOR说),.next()!(执行next函数)

GENERATOR 再次活跃起来。输出:{value: "world", done: false} 后,它以一个愚蠢的姿势僵住了。

FOR LOOP:alert!(执行alert函数)

ALERT:(已经在跑步前进了)我来了!(朝着台下,大声喊):全都停下!

浏览器网页上说,“world”!

又一次,停顿了一下,然后,ALERT艰难地回到舞台上,突然垂头丧气。

ALERT:用户又说OK,但是…但是请阻止这个页面创建额外的对话。他退出舞台,撅着嘴

FOR LOOP:(轻快地拍手)好吧!让我们做些事情吧。(又转过身对GENERATOR说),.next()!(执行next函数)

GENERATOR 第三次活跃起来。庄重的输出:{value: undefined, done: true}后,它的头停在胸前,眼睛里的光也熄灭了。它再也不会动了。

FOR LOOP:我的午休时间到了。她退出了舞台。

过了一会儿,垃圾收集器进入,拿起没有生命的GENERATOR,并把它带出舞台。

好吧,这不完全是《哈姆雷特》。但你能明白generator执行时内部的一些场景。正如在舞台剧中所看到的,当生成器对象首次出现时,它将被暂停。每次调用它的.next()方法时,它都会唤醒并运行一段时间。

该操作是同步的和单线程的。请注意,在任何给定的时间(同一时刻)里,只有一个角色实际上在活动。角色们从不打断对方,也不会影响对方。他们轮流发言,任何人都可以想说多久就说多久。

每次generator被送到for-of循环时,这出戏的某些场景就会展开。在你的代码中,总是有一个.next()方法调用的序列没有出现。这里我已经把这些都放在了台上,但对于您和您的程序来说,所有这些都将在幕后进行,因为生成器和for of循环是通过迭代器接口设计成协同工作的。

所以总结一下到目前为止的一切:

  • 生成器对象就是产生值的礼貌的黄铜机器人。
  • 每个机器人的程序都由一段代码组成:创建它的生成器函数体。

生成器有一些在上一篇文章中没有提到的额外功能:

  • generator.return()
  • generator.next()的可选参数
  • generator.throw(error)
  • yield*

跳过它们的主要原因是,如果不理解这些功能存在的原因,就很难去真正理解它们,更不用说在脑海中记住它们了。但是,当我们更多地思考我们的程序将如何使用生成器时,我们将看到原因。

如何终止一个Generator - generator.return()

这里有一个你可能在某些时候使用过的模式:

function doThings() {
  setup();
  try {
    // ... do some things ...
  } finally {
    cleanup();
  }
}

doThings();

清理可能包括关闭连接或文件、释放系统资源,或者只是更新DOM以关闭“正在进行中的”spinner。无论我们的工作是否成功完成,我们都希望它发生,所以finally块中执行了清理工作。在generator里会是什么样子?

function* produceValues() {
  setup();
  try {
    // ... yield some values ...
  } finally {
    cleanup();
  }
}

for (var value of produceValues()) {
  work(value);
}

这个看起来不错。但是这里有一个微妙的问题:调用work(value)不在try块中。如果它抛出异常,我们的清理(cleanup)步骤会发生什么?或者假设for-of循环包含一个breakreturn语句。那么清理步骤(cleanup)会发生什么呢?

cleanup会执行。ES6替你处理了这些问题。

当我们第一次讨论迭代器for-of循环时,我们说迭代器接口包含一个可选的.return()方法,每当迭代器在表示结束之前退出时,JS都会自动调用该方法。生成器支持此方法。调用myGenerator.return()会导致生成器运行任何finally块,然后退出,就像当前的yield点被神秘地转换成了return语句一样。

请注意,JS在所有上下文中不会自动调用.return(),只有在该语言使用迭代协议的情况下才会调用。因此,生成器可能在没有运行其finally块的情况下被垃圾回收。

这个功能在舞台上会如何发挥?generator在执行任务的过程中被冻结了,就像建造一座摩天大楼,突然有人抛出一个错误!for循环捕获它并把错误放在一边。然后告诉生成器.return()。generator平静地拆除所有脚手架并关闭。然后for循环拾取错误,并继续正常的异常处理。

与调用者互动 - generator.next()的可选参数

到目前为止,我们看到的generator和它的用户之间的对话是相当单方面的(没有交互)

image

像上面这样,用户发送.next(),generator做出回应。用户是主动的。generator按需运转。但这并不是使用生成器编程的唯一方法。

生成器可以用于异步编程。你现在用异步回调或承诺链(promise chaining)来做的事情可以用生成器来代替。你可能想知道这到底是怎么回事。为什么yield的能力(毕竟这是generator唯一的特殊能力)就足够了?毕竟,异步代码不只是产生(yield)。它让事情发生。它需要从文件和数据库中获取数据。它向服务器发出请求。然后它返回事件循环,等待这些异步进程完成。生成器究竟是如何做到这一点的?如果没有回调,生成器如何从这些文件、数据库和服务器接收数据?

要开始寻找答案,请考虑如果我们只有一种方法.next()调用者将值传递回生成器,会发生什么情况。有了这个改变,我们可以有一种全新的对话方式:

image

生成器的.next()方法实际上接受一个可选参数,其聪明之处在于,参数随后作为yield表达式返回的值出现在生成器中。也就是说,yield不同于return语句;一旦生成器被消费,yield表达式就是一个具有值的表达式

var results = yield getDataAndLatte(request.areaCode);

这一行代码做了很多事情:

  • 它调用getDataAndLatte()。让我们假设该函数返回字符串“get me the database records for area code...”(如上屏幕截图中看到)。
  • 它暂停生成器,生成字符串值。(It pauses the generator, yielding the string value.)
  • 此时此刻,执行过程耗费的时间是不确定的。
  • 最终,有人调用.next({data: ..., coffee: ...})。我们将该对象存储在局部变量result中,并继续执行下一行代码。
  • 为了说明这一点,下面是整个对话的代码:
function* handle(request) {
  var results = yield getDataAndLatte(request.areaCode);
  results.coffee.drink();
  var target = mostUrgentRecord(results.data);
  yield updateStatus(target.id, "ready");
}

请注意yield的含义仍然与之前的含义完全相同:暂停生成器并将一个值传递回调用者。但是事情已经改变了!这个生成器期望调用者提供非常具体的支持行为。它似乎希望打电话的人表现得像一个行政助理。

普通函数通常不是这样的。它们的存在往往是为了满足调用者的需要。但是生成器是可以与之进行对话的代码,这使得生成器与其调用者之间可能存在更广泛的关系。

这个管理助理(generator运行器)可能是什么样子的?没必要那么复杂。可能是这样的:

// g是一个generator对象
function runGeneratorOnce(g, result) {
  var status = g.next(result);
  if (status.done) {
    return;  // phew!
  }
  // 生成器要求我们获取一些东西,并在我们完成时将其返回
  doAsynchronousWorkIncludingEspressoMachineOperations(status.value, (error, nextResult) => runGeneratorOnce(g, nextResult));
}

为了让上面代码运转起来,我们需要创建一个生成器并运行它一次,如下所示:

runGeneratorOnce(handle(request), undefined);

之前提到过Q.async()作为一个将生成器视为异步进程并在需要时自动运行它们的JS库示例。runGeneratorOnce就是Q.async()简略版。在实践中,生成器不会生成字符串来说明它们需要调用者做什么。它们可能会产生Promise对象。

如果您已经理解了promises,现在又理解了生成器,那么您可能想尝试修改unGeneratorOnce支持promises。这是一个困难的练习,但一旦你完成了,你将能够编使用简单几行代码利用promises写复杂的异步算法,同时不使用.then()或回调。

如何破坏一个Generator - generator.throw(error)

你注意到runGeneratorOnce是如何处理错误的吗?它忽略了他们!这可不妙。我们希望以某种方式将错误报告给生成器。生成器也支持这一点:你可以调用generator.throw(error)而不是generator.next(result)。这将导致yield表达式抛出异常。与.return()类似,生成器通常会被终止,但如果当前yield点位于try块中,则会执行catchfinally块,因此生成器可能会恢复。

修改runGeneratorOnce以确保适当地调用.throw()是另一个很好的练习。请记住,在生成器内部抛出的异常总是传递给调用者。所以generator.throw(error)会直接将错误抛出给你,除非生成器捕捉到它!

下面是当生成器到达yield表达式并暂停时,代码执行的各种可能性:

  • 有人可能调用generator.next(value)。在这种情况下,生成器在停止的地方继续执行。
  • 有人可能会调用generator.return(),并可选地传递一个值。在这种情况下,生成器不会恢复它正在做的任何事情。它只执行finally块。
  • 有人可能会调用generator.throw(error)。生成器的行为就好像yield表达式是对抛出错误的函数的调用。
  • 或者,也许没有人会做这些事情。generator可能会永远冻结。(是的,生成器有可能进入try块而不执行finally块。在此状态下,生成器甚至可以被垃圾回收器回收。)
    这并不复杂多少

这并不比普通的旧函数调用复杂多少。只有.return()是一种新的可能性。

实际上,yield与函数调用有很多共同之处。当你调用一个函数时,你会被暂时暂停,对吧?你调用的函数在控制之中。它可能会返回。它可能丢。或者它可能永远循环。

多个generators合作 - yield*

再展示一个特性。假设我们编写一个简单的生成器函数来连接两个可迭代对象:

function* concat(iter1, iter2) {
  for (var value of iter1) {
    yield value;
  }
  for (var value of iter2) {
    yield value;
  }
}

ES6提供了一个简写:

function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

一个普通的yield表达式只产生一个值;yield*表达式消耗整个迭代器并生成所有值。

同样的语法还解决了另一个有趣的问题:如何在一个生成器内部调用另一个生成器。在普通函数中,我们可以从一个函数中提取一组代码,并将其重构为一个单独的函数,而不改变行为。显然,我们也想重构生成器。但我们需要一种方法来调用因式分解子程序并确保之前产生的值仍然被产生,即使它是现在产生这些值的子程序。yield*是实现这一点的方法。

function* factoredOutChunkOfCode() { ... }

function* refactoredFunction() {
  ...
  yield* factoredOutChunkOfCode();
  ...
}

想象一个黄铜机器人将子任务委托给另一个机器人。您可以看到,这个想法对于编写基于生成器的大型项目和保持代码整洁有序是多么重要,就像函数对于组织同步代码是至关重要的一样。

posted @ 2021-08-24 17:32  Max力出奇迹  阅读(62)  评论(0编辑  收藏  举报
返回顶部↑