异步 JavaScript 的传奇:生成器

2025-06-07

异步 JavaScript 的传奇:生成器

简介

现代 JavaScript 程序中最复杂的功能之一就是异步性。我们已经了解了一些现有的模式,例如CallbacksThunksPromises。尽管它们设法解决了一些关键问题,但所有这些模式都有一个共同点——它们看起来不像同步代码。我们编写和理解异步代码的方式始终存在差异。这听起来可能有些不切实际,但时间证明,我们可以非常接近它。

我们将学到什么

在今天的文章中,我们将讨论Generators。它是 ES6 中引入的一种新函数类型。乍一看,我们并不会立刻明白它与异步编程有何关联。对你们中的许多人来说,它很可能看起来很奇怪。但随着我们慢慢地进行解释和示例,我们最终会完全理解为什么我们的代码需要它们。你会发现Generators真正突出的地方,以及它们为我们解决了哪些问题。最后,希望你能够自信地表达Generators ,并证明它们在你的代码中的合理性。

运行至完成语义

JavaScript 中所有常规函数都有一个显著的共同特征。在编写同步代码时,我们知道,当函数开始执行时,它总是会运行到最后,并在其他函数有机会执行之前完成。在任何给定的秒数内,只有一个函数能够主动执行。这也意味着没有任何东西可以预先中断我们的函数来运行其他程序。完美描述上述所有内容的学术术语是“运行至完成语义”。这有助于我们不必担心两个函数相互中断或破坏共享内存。通过在 JavaScript 中遵循这条“规则”,我们能够以纯单线程的方式推理代码。

发电机不是这样的

生成器是一种截然不同的东西。它们完全不符合“运行至完成”的规则。表面上看,它应该会给我们的代码带来不少混乱。但看起来,它们提供了另一种解决问题的方法,尽管这种方法本身可能看起来有点奇怪。解释生成器的一种方法是,在当前的 JavaScript 中,它们允许我们定义一个状态机——一系列从一个状态到另一个状态的流程,并能够以声明式的方式列出这些转换。我相信你们大多数人都创建过不少状态机,甚至可能不知道它是这样调用的。以前,使用 JavaScript 中的现有工具来实现状态机需要投入大量的精力和时间。我们经常使用闭包来维护执行所有这些转换的函数中的当前状态和先前状态,但这样做会使得代码变得复杂,编写闭包也很耗时。生成器添加了语法糖,可以让你以更简单、更清晰的方式解决同样的问题。但这对异步代码有什么帮助呢?为了实现这一目标,我们首先需要很好地掌握发电机的内部管道。

使用 yield 暂停

生成器引入了一个名为 的新关键字yield,它的作用很像一个暂停按钮。因此,当生成器函数在运行时遇到一个yield关键字时,它会表现出一种有趣的行为。无论在哪里遇到这个 yield ,它都会表现出一种有趣的行为。即使它发生在表达式的中间,生成器也会暂停。从那时起,生成器本身将不会发生任何事情,它将处于完全阻塞状态。它实际上被冻结了。重要的是,整个程序本身不会被阻塞,可以继续运行。由 yield 引起的阻塞是完全局部的。它可以无限期地保持这种“暂停”状态,直到有人来告诉它继续运行。你可以将生成器视为一个可以根据需要多次暂停和恢复的函数,而不会丢失任何内部状态。

一个例子

现在我们来看一个生成器的例子,看看所有这些概念是如何组合在一起的。这是我们的第一个生成器:

function* helloWorldGenerator() {
  console.log('Hello world');
  yield; // pausing
  console.log('Hello again!')
}
Enter fullscreen mode Exit fullscreen mode

在第一行,星号告诉 JavaScript 我们定义的函数确实是一个生成器。你会注意到,第三行有一个 yield 关键字,它是我们的暂停按钮。通过使用 yield,生成器本身声明了它何时、何地以及以何种方式暂停。这也被称为协作式多任务处理。外部任何人都无法介入并中断它的执行。这在多线程语言中经常导致灾难。幸运的是,我们没有这样的问题。

调用生成器

调用生成器时,它的行为与其他函数略有不同。继续上面的例子,让我们说明如何使用该生成器:

const iterator = helloWorldGenerator();

iterator.next() // Hello world
iterator.next() // Hello again!
Enter fullscreen mode Exit fullscreen mode

当我们调用生成器函数时,生成器内部不会执行任何代码。执行生成器实际上并不会运行任何代码。真正发生的是我们得到了一个迭代器。你可能知道什么是迭代器,但以防万一,让我们回顾一下它们的定义。迭代器是一种一次一个结果地遍历数据集的方法。在这种情况下,迭代器的目的不是遍历项目集合,而是通过逐个执行这些 yield 语句从外部控制我们的生成器。可以将其视为一个方便的 API,帮助我们控制生成器的流程。我们无法暂停生成器,但使用迭代器,我们可以让它运行,直到它想要暂停自己。因此在第 1 行没有任何代码运行,但在第 2 行,通过调用.next迭代器对象,我们启动了生成器的执行。然后它将执行console.log('Hello world')语句,在 yield 处暂停并将控制权返回给客户端代码。无论何时发生下一次调用.next,它都会恢复生成器,执行最后一条console.log('Hello again!')语句,此时我们的生成器就完成了。

产生值

看起来,除了将控制权交给我们的代码之外,生成器还可以yield 值。在我们之前的例子中,我们什么都没有 yield。让我们举一个虚拟的例子来说明这一点:

function* authorDossierGenerator () {
  const author = {
    name: "Roman",
    surname: "Sarder",
    age: 23,
  }

  yield author.name;
  yield author.surname;
  yield author.age;
}

const iterator = authorDossierGenerator();
iterator.next() // { value: "Roman", done: false }
iterator.next() // { value: "Sarder", done: false }
iterator.next() // { value 23, done: false }
iterator.next() // { value: undefined, done: true }
Enter fullscreen mode Exit fullscreen mode

在上一个例子中,我们假设生成器产生了一个undefined,但现在我们返回的是实际值。您会注意到,每次.next调用都会返回一个具有 value 和 done 属性的对象。该值对应于我们从生成器中产生的值,在本例中,它是一组对象属性值。done 标志指示生成器是否完成。一开始这可能有点棘手。我们的第三次iterator.next调用在视觉上看起来像是生成器已经完成了,但事实并非如此。虽然它是生成器的最后一行,但实际上生成器在最后一个表达式 处暂停了yield author.age。如果它暂停了,它可以恢复,这就是为什么只有在第四个.next 之后我们才会得到done: false。但是如果最后一个值是 undefined 怎么办?与简单函数一样,如果生成器末尾没有 return 语句,JavaScript 会假定它返回 undefined。在任何时候,您都可以从生成器返回,它将立即完成并返回一个值(如果有)。将 return 视为“退出”按钮。

传递值

我们成功地说明了生成器确实有办法将消息传递给客户端代码。但我们不仅可以输出.next消息,还可以在调用方法时将消息传入,并且该消息会直接进入生成器。

function* sumIncrementedNumbers () {
  const x = 1 + (yield);
  const y = 1 + (yield);
  yield x + y
}

const iterator = sumIncrementedNumbers();

iterator.next() // { value: undefined, done: false } 
iterator.next(5) // { value: undefined, done: false }
iterator.next(2) // { value: 9, done: false }
iterator.next() // { value: undefined, done: true }
Enter fullscreen mode Exit fullscreen mode

请注意,我们将 yield 关键字放在两个表达式的中间。从内部角度来看,可以将这些 yield 视为问号。当生成器到达第一个表达式时,它基本上会问一个问题:哪个值应该放在这里?没有答案,它就无法完成表达式。此时,它会暂停并等待某人提供这个值。我们通过调用.next并传递一个值来实现这一点5。现在它可以继续下一个了。这些 yield 充当值的占位yield,这些值将在某个时间点传递给生成器并替换 yield 来完成表达式。

转换为异步

现在,你应该已经准备好看下面的例子了,不用担心脑子完全炸了。我们将尝试使用Generator来处理异步代码,并转换我们之前的一个示例。由于代码提升,它可能看起来有点糟糕,但可以将其视为概念验证。我们一定会将其重构得更加美观。

function getData (number) {
  setTimeout(() => {
    iterator.next(number);
  }, 1000)
}

function* sumIncrementedNumbersAsync() {
  const x = 1 + (yield getData(10));
  const y = 1 + (yield getData(20))

  console.log(x + y) // 32
}

const iterator = sumIncrementedNumbersAsync();
iterator.next();
Enter fullscreen mode Exit fullscreen mode

呼,你还在那儿吗?让我们逐行浏览代码以了解发生了什么。首先,我们调用生成器来生成迭代器并通过调用开始执行.next。到目前为止一切顺利,没有出现什么高深莫测的科学。我们的生成器开始计算的值x并遇到第一个yield。现在生成器暂停并提出一个问题:这里应该放什么值?答案在于函数调用的结果getData(10)。有趣的部分来了:我们自制的 getData 函数是一个伪异步函数,在完成值计算后恢复生成器。这里它只是一个setTimeout,但它可以是任何东西。因此,1000 毫秒后,我们的伪造函数getData会给我们一个响应并使用响应的值恢复生成器。下一个yield getData(20)以类似的方式处理。我们在这里得到的是同步的异步代码。我们的生成器现在能够暂停自身并在以与同步值完全相同的方式计算异步值时恢复。这很重要。

魔法钥匙

因为生成器使用了暂停/恢复机制,它能够阻塞自身,等待某个后台进程完成,然后使用我们等待的值恢复执行。将实现细节抽象出来,因为大多数情况下,这些细节隐藏在库中。重要的是生成器内部的代码。将其与我们在使用Promises的代码中看到的进行比较。Promises的流程控制将回调垂直组织成一个链。想想回调和Thunk——它们嵌套了相同的回调。生成器也提供了自己的流程控制。但这种流程控制的一个非常特别之处是它看起来完全同步。异步和同步代码彼此相邻,彼此平等。我们既看不到任何区别,也不再需要考虑以不同的方式组织异步代码。异步本身现在是一个我们不再关心的实现细节。这是因为生成器引入了一种语法方法来隐藏状态机(在我们的例子中是异步状态机)的复杂性。您还可以获得同步代码的所有好处,例如错误处理。您可以使用 try-catch 块以相同的方式处理异步代码中的错误。是不是很棒?

清洗国际奥委会

当你更仔细地看这个例子时,你可能会注意到这种方法有一个问题。我们的 getData 函数正在控制生成器的执行,这会导致控制反转。这个函数以一种意想不到的方式调用.next生成器上的方法,把一切都搞乱了,而当前的代码库对此没有解决方案。猜猜怎么着?我们不再害怕这个以前可怕的问题了。我们只需要回想一下哪种模式已经为我们解决了这个问题。我们将把 Promise 和 Generator 混合在一起!为了实现这种结合,我们必须yield 一个 promsie而不是undefined

终极二人组

让我们想象一下如何让它工作。我们已经说过,在我们的生成器内部,我们需要产生一个承诺。但是谁来负责解决这个承诺呢?好吧,这将由驱动生成器的代码来完成,即调用.next。一旦它得到一个承诺,它就应该对其做些什么,它将不得不等待承诺解决 并恢复生成器。我们需要一个额外的抽象来为我们完成这项工作,很可能这将由框架、库或 JavaScript 本身提供。这不太可能是一个实际的事情——每次你想要使用承诺化的生成器时,都要重新发明轮子。但出于教育目的,我们将自己找出一个并研究它。

构建我们的 Promises Generator 运行器

我将为你提供一个这样的生成器运行器的实现。显然,它缺少一些在生产环境中使用时绝对必要的功能,例如正确的处理方式,但它满足了我们的需求,完美地演示了概念,同时保持了相当简单的功能。

function runner (generatorFunction) {
  const iterator = generatorFunction();

  function nextStep(resolvedValue) {
    const { value: nextIteratorValue, done } = iterator.next(resolvedValue);

    if (done) return nextIteratorValue;

    return nextIteratorValue.then(nextStep)
  }

  return Promise.resolve().then(nextStep)
}
Enter fullscreen mode Exit fullscreen mode

我们的运行器接受一个生成器函数并像往常一样生成一个迭代器。然后它返回一个已解析的 Promise,并且在.then方法中我们传递了我们的工作函数nextStep。它完成了获取下一个迭代器值并检查生成器是否完成的全部工作。如果没有,我们假设调用的结果.next是一个 Promise。因此,我们自己返回了一个新的 Promise,方法是等待迭代器值 Promise 解析并将该值传递给我们的工作函数。如果需要,工作函数会将结果值传递给迭代器,并重复其工作直到生成器完成。其实没什么复杂的。

与我们的 Generator Runner 合作

我们将进一步修改我们的sumIncrementedNumbers示例以纳入我们的新运行器,并看看我们如何使用承诺的生成器。

function getData (data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(data);
    }, 1000)
 })
}

function* sumIncrementedNumbersAsync () {
  const x = 1 + (yield getData(10));
  const y = 1 + (yield getData(20));
  return x + y;
}

runner(sumIncrementedNumbersAsync).then(value => {
  console.log(value) // After ~2000ms prints 32
});
Enter fullscreen mode Exit fullscreen mode

这里的所有内容对你来说应该已经很熟悉了。由于我们的运行器最终会产生一个 Promise,所以从外界的角度来看,我们包装的生成器只不过是另一个 Promise。我们已经设法使用生成器解决非本地、非顺序推理问题,使异步代码看起来像同步代码。我们让 Promises 来完成解决控制反转问题的脏活,并创建了简单的Promises Generator runner。最后,我们得到了一个干净的 Promise 接口,并且 Promises 的所有优点都适用于我们包装的生成器。这就是生成器如此强大的原因。它们彻底改变了你编写异步代码的方式。它们最终让你能够编写对我们的大脑来说直观且不与我们的思维方式相矛盾的代码。

异步/等待?

事实上,这种模式被证明非常有用,以至于 ECMAScript 在 2017 年推出了自己的异步生成器实现,引入了async/await关键字。别被它迷惑了,因为这个功能完全基于生成器,而且概念完全相同。区别在于,现在它已经成为我们语言中的一等公民,拥有适当的语法支持,我们不再需要使用任何辅助库来完成这项工作。但是,目前async/await 的工作方式存在一些需要注意的地方。

纯生成器 vs async/await

如何取消异步函数并阻止其进一步执行?问题是没有办法这样做。目前async/await只返回一个 Promise。这很酷,但是取消功能太重要了,不容忽视。当前的实现并没有提供足够的工具来更精细地控制执行。我不是评判他们的设计决策的人,但我的观点是,API 可以进一步改进,例如,同时返回一个 promise 和一个取消函数。归根结底,我们正在使用实现拉取接口的生成器。我们可以控制如何使用迭代器。您可以轻松想象,如果收到取消信号,我们如何在运行器中停止使用它。为了证明这一点,我们可以引入一个简单的更改来实现一个非常原始的取消机制。您可以想象有人使用回滚策略制作一个更复杂、更防错的变体。

function runner (generatorFunction) {
  let isCancelled = false;
  const iterator = generatorFunction();

  function nextStep(resolvedValue) {
    const { value: nextIteratorValue, done } = iterator.next(resolvedValue);

    if (done) return nextIteratorValue;

    if (isCancelled) {
      return Promise.resolve();
    }

    return nextIteratorValue.then(nextStep)
 }

return {
  cancel: () => isCancelled = true,
  promise: Promise.resolve().then(nextStep)
}
Enter fullscreen mode Exit fullscreen mode

这充分说明了我上面的观点。我们通过PromiseCancel方法返回一个对象。Cancel 方法只是切换一个通过闭包包含的标志变量。非常简洁,并且为进一步的增强提供了许多可能性。

结尾

这次要学习和讨论的内容非常丰富。但这个主题本身并不简单,你不可能只花 5 分钟就能理解。我不指望你们中的任何人读完这篇文章就能成为生成器专家,但我确信我已经为你们提供了一个良好的开端,激励你们自己进一步探索这个主题。通过生成器,我们似乎已经回答了关于异步编程的每一个问题。我们解决了控制反转,现在能够编写看似同步的异步代码,并且似乎已经融合了所有先前模式的最佳特性。但是,正如软件工程中经常发生的那样,同一个问题通常有不止一个可能的答案。从这一点来看,我们接下来看到的模式将为你提供全新的解决问题的方法,每种方法都可能或多或少地适合你的情况。最终的决定权在你,作为一名工程师。如果你在读完本系列的这一部分就放弃,完全没问题,因为对于我们大多数人来说,这已经足够了解 JavaScript 中的异步编程了。但如果你决定继续看下去,我们将探讨一些高级模式,例如CSPObservables。下次我们一定会讨论其中之一。感谢你的阅读!

致谢

非常感谢Kyle Simpson和他的资料。他的异步 JavaScript课程给了我很大的启发,它促使我比平时更加​​深入地研究这些主题。

文章来源:https://dev.to/romansarder/the-saga-of-async-javascript-generators-5dhi
PREV
学习像程序员一样思考。
NEXT
番茄工作法的失败如何让我成为更好的程序员