2022 年 2 月 4 日

异步迭代和生成器

异步迭代允许我们按需迭代异步生成的数据。例如,当我们通过网络分块下载某些内容时。异步生成器使这一过程更加便捷。

让我们先看一个简单的示例,以掌握语法,然后回顾一个实际用例。

回顾可迭代对象

让我们回顾一下可迭代对象这个主题。

我们的想法是,我们有一个对象,例如这里的 range

let range = {
  from: 1,
  to: 5
};

…我们希望对它使用 for..of 循环,例如 for(value of range),以获取从 15 的值。

换句话说,我们希望为对象添加一个迭代能力

可以使用名为 Symbol.iterator 的特殊方法来实现此目的

  • 当循环开始时,for..of 构造会调用此方法,它应返回一个带有 next 方法的对象。
  • 对于每次迭代,都会调用 next() 方法以获取下一个值。
  • next() 应返回一个形式为 {done: true/false, value:<loop value>} 的值,其中 done:true 表示循环结束。

以下是可迭代 range 的实现

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() { // called once, in the beginning of for..of
    return {
      current: this.from,
      last: this.to,

      next() { // called every iteration, to get the next value
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for(let value of range) {
  alert(value); // 1 then 2, then 3, then 4, then 5
}

如果有任何不清楚的地方,请访问章节 可迭代,它提供了有关常规可迭代的所有详细信息。

异步可迭代

当值异步出现时,需要异步迭代:在 setTimeout 或其他类型的延迟之后。

最常见的情况是对象需要发出网络请求以传递下一个值,我们稍后会看到一个真实的示例。

要使对象异步可迭代

  1. 使用 Symbol.asyncIterator 而不是 Symbol.iterator
  2. next() 方法应返回一个 promise(以下一个值实现)。
    • async 关键字会处理它,我们可以简单地编写 async next()
  3. 要遍历这样的对象,我们应使用 for await (let item of iterable) 循环。
    • 请注意 await 字。

作为一个入门示例,让我们创建一个可迭代 range 对象,类似于前面的对象,但现在它将异步返回值,每秒一个。

我们只需要在上面的代码中进行一些替换

let range = {
  from: 1,
  to: 5,

  [Symbol.asyncIterator]() { // (1)
    return {
      current: this.from,
      last: this.to,

      async next() { // (2)

        // note: we can use "await" inside the async next:
        await new Promise(resolve => setTimeout(resolve, 1000)); // (3)

        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

(async () => {

  for await (let value of range) { // (4)
    alert(value); // 1,2,3,4,5
  }

})()

正如我们所见,结构类似于常规迭代器

  1. 要使对象异步可迭代,它必须有一个方法 Symbol.asyncIterator (1)
  2. 此方法必须返回一个带有 next() 方法的对象,该方法返回一个 promise (2)
  3. next() 方法不必是 async,它可以是返回 promise 的常规方法,但 async 允许我们使用 await,因此很方便。这里我们仅延迟一秒 (3)
  4. 要进行迭代,我们使用 for await(let value of range) (4),即在 “for” 后添加 “await”。它调用 range[Symbol.asyncIterator]() 一次,然后调用其 next() 以获取值。

下面是一个带有差异的小表格

迭代器 异步迭代器
提供迭代器的对象方法 Symbol.iterator Symbol.asyncIterator
next() 的返回值是 任何值 Promise
要进行循环,请使用 for..of for await..of
展开语法 ... 异步不起作用

需要常规同步迭代器的功能不适用于异步迭代器。

例如,展开语法将不起作用

alert( [...range] ); // Error, no Symbol.iterator

这是很自然的,因为它希望找到 `Symbol.iterator`,而不是 `Symbol.asyncIterator`。

对于 `for..of` 也是如此:没有 `await` 的语法需要 `Symbol.iterator`。

回顾生成器

现在让我们回顾一下生成器,因为它们可以使迭代代码更短。大多数情况下,当我们想要制作一个可迭代对象时,我们会使用生成器。

为了简单起见,省略一些重要内容,它们是“生成(yield)值的函数”。它们在 生成器 一章中有详细解释。

生成器用 `function*` 标记(注意星号),并使用 `yield` 生成一个值,然后我们可以使用 `for..of` 对它们进行循环。

此示例生成从 `start` 到 `end` 的值序列

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

for(let value of generateSequence(1, 5)) {
  alert(value); // 1, then 2, then 3, then 4, then 5
}

正如我们所知,要使对象可迭代,我们应该向其中添加 `Symbol.iterator`。

let range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    return <object with next to make range iterable>
  }
}

对于 `Symbol.iterator`,一种常见的做法是返回一个生成器,它可以使代码更短,如您所见

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // a shorthand for [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

for(let value of range) {
  alert(value); // 1, then 2, then 3, then 4, then 5
}

如果您想要更多详细信息,请参阅 生成器 一章。

在常规生成器中,我们不能使用 `await`。所有值都必须同步出现,正如 `for..of` 结构所要求的那样。

如果我们想要异步生成值怎么办?例如,从网络请求中。

让我们切换到异步生成器以使其成为可能。

异步生成器(最后)

对于大多数实际应用,当我们想要制作一个异步生成值序列的对象时,我们可以使用异步生成器。

语法很简单:用 `async` 预置 `function*`。这使得生成器异步。

然后使用 `for await (...)` 对其进行迭代,如下所示

async function* generateSequence(start, end) {

  for (let i = start; i <= end; i++) {

    // Wow, can use await!
    await new Promise(resolve => setTimeout(resolve, 1000));

    yield i;
  }

}

(async () => {

  let generator = generateSequence(1, 5);
  for await (let value of generator) {
    alert(value); // 1, then 2, then 3, then 4, then 5 (with delay between)
  }

})();

由于生成器是异步的,因此我们可以在其中使用 `await`,依赖于 Promise,执行网络请求等。

底层差异

从技术上讲,如果您是一位高级读者,并且还记得有关生成器的详细信息,那么内部存在差异。

对于异步生成器,`generator.next()` 方法是异步的,它返回 Promise。

在常规生成器中,我们将使用 `result = generator.next()` 来获取值。在异步生成器中,我们应该添加 `await`,如下所示

result = await generator.next(); // result = {value: ..., done: true/false}

这就是异步生成器可以与 `for await...of` 一起使用的原因。

异步可迭代范围

常规生成器可以用作 `Symbol.iterator`,以缩短迭代代码。

与此类似,异步生成器可以用作 `Symbol.asyncIterator` 来实现异步迭代。

例如,我们可以让 range 对象异步生成值,每秒一次,通过用异步 Symbol.asyncIterator 替换同步 Symbol.iterator

let range = {
  from: 1,
  to: 5,

  // this line is same as [Symbol.asyncIterator]: async function*() {
  async *[Symbol.asyncIterator]() {
    for(let value = this.from; value <= this.to; value++) {

      // make a pause between values, wait for something
      await new Promise(resolve => setTimeout(resolve, 1000));

      yield value;
    }
  }
};

(async () => {

  for await (let value of range) {
    alert(value); // 1, then 2, then 3, then 4, then 5
  }

})();

现在值之间有 1 秒的延迟。

请注意

从技术上讲,我们可以将 Symbol.iteratorSymbol.asyncIterator 都添加到对象中,这样它既可以同步(for..of)又可以异步(for await..of)迭代。

但在实践中,那将是一件奇怪的事情。

真实示例:分页数据

到目前为止,我们已经看到了基本示例,以获得理解。现在让我们回顾一下现实生活中的用例。

有许多在线服务提供分页数据。例如,当我们需要用户列表时,请求会返回预定义的数量(例如 100 个用户)——“一页”,并提供指向下一页的 URL。

这种模式非常常见。它不只是关于用户,而是关于任何事情。

例如,GitHub 允许我们以相同的分页方式检索提交

  • 我们应该以 https://api.github.com/repos/<repo>/commits 的形式向 fetch 发出请求。
  • 它用 30 个提交的 JSON 响应,并在 Link 头中提供指向下一页的链接。
  • 然后我们可以使用该链接进行下一个请求,以获取更多提交,依此类推。

对于我们的代码,我们希望有一种更简单的方法来获取提交。

让我们创建一个函数 fetchCommits(repo),它为我们获取提交,并在需要时发出请求。并让它处理所有分页内容。对于我们来说,它将是一个简单的异步迭代 for await..of

因此,用法如下

for await (let commit of fetchCommits("username/repository")) {
  // process commit
}

这是一个这样的函数,实现为异步生成器

async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url, { // (1)
      headers: {'User-Agent': 'Our script'}, // github needs any user-agent header
    });

    const body = await response.json(); // (2) response is JSON (array of commits)

    // (3) the URL of the next page is in the headers, extract it
    let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for(let commit of body) { // (4) yield commits one by one, until the page ends
      yield commit;
    }
  }
}

关于它如何工作的更多解释

  1. 我们使用浏览器的 fetch 方法下载提交。

    • 初始 URL 是 https://api.github.com/repos/<repo>/commits,下一页将在响应的 Link 头中。
    • fetch 方法允许我们在需要时提供授权和其他头——这里 GitHub 需要 User-Agent
  2. 提交以 JSON 格式返回。

  3. 我们应该从响应的 Link 头中获取下一页的 URL。它有一个特殊的格式,所以我们为此使用正则表达式(我们将在 正则表达式 中学习此功能)。

    • 下一页的 URL 可能看起来像 https://api.github.com/repositories/93253246/commits?page=2。它是由 GitHub 本身生成的。
  4. 然后我们一个接一个地生成接收到的提交,当它们完成时,下一个 while(url) 迭代将触发,发出一个请求。

一个使用示例(在控制台中显示提交作者)

(async () => {

  let count = 0;

  for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) {

    console.log(commit.author.login);

    if (++count == 100) { // let's stop at 100 commits
      break;
    }
  }

})();

// Note: If you are running this in an external sandbox, you'll need to paste here the function fetchCommits described above

这正是我们想要的。

分页请求的内部机制从外部是不可见的。对于我们来说,它只是一个返回提交的异步生成器。

摘要

常规迭代器和生成器适用于无需花费时间生成的数据。

当我们希望数据以异步方式延迟出现时,可以使用它们的异步对应项,使用 `for await..of` 而不是 `for..of`。

异步迭代器和常规迭代器之间的语法差异

可迭代对象 异步可迭代对象
提供迭代器的方法 Symbol.iterator Symbol.asyncIterator
next() 的返回值是 {value:…, done: true/false} 解析为 `{value:…, done: true/false}` 的 `Promise`

异步生成器和常规生成器之间的语法差异

生成器 异步生成器
声明 function* async function*
next() 的返回值是 {value:…, done: true/false} 解析为 `{value:…, done: true/false}` 的 `Promise`

在 Web 开发中,我们经常会遇到数据流,即数据块状流动。例如,下载或上传大文件。

我们可以使用异步生成器来处理此类数据。同样值得注意的是,在某些环境中,例如在浏览器中,还有另一个称为 Streams 的 API,它提供了特殊接口来处理此类流,以转换数据并将其从一个流传递到另一个流(例如从一个地方下载并立即发送到其他地方)。

教程地图

评论

在评论之前阅读此内容…
  • 如果你有改进建议 - 请 提交 GitHub 问题 或提交拉取请求,而不是发表评论。
  • 如果你无法理解文章中的内容 - 请详细说明。
  • 要插入一些代码,请使用 <code> 标记,对于多行 - 将其包装在 <pre> 标记中,对于超过 10 行 - 使用沙盒 (plnkrjsbincodepen…)