2022年2月4日

非同期イテレーターとジェネレーター

非同期イテレーションにより、オンデマンドで非同期的に取得されるデータに対してイテレートできます。例えば、ネットワーク経由でチャンク単位で何かをダウンロードする場合などです。非同期ジェネレーターを使用すると、さらに便利になります。

まずは簡単な例を見て構文を理解し、その後、実際のユースケースを確認しましょう。

イテラブルの復習

イテラブルに関するトピックを復習しましょう。

ここでは`range`のようなオブジェクトがあり、

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

…`for(value of range)`のように`for..of`ループで使用して、`1`から`5`までの値を取得したいと考えています。

言い換えれば、オブジェクトに *イテレーション機能* を追加したいということです。

`Symbol.iterator`という名前の特別なメソッドを使用して実装できます。

  • このメソッドは、ループが開始されると`for..of`構成によって呼び出され、`next`メソッドを持つオブジェクトを返す必要があります。
  • 各イテレーションで、次の値に対して`next()`メソッドが呼び出されます。
  • `next()`は`{done: true/false, 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.iterator`の代わりに`Symbol.asyncIterator`を使用します。
  2. `next()`メソッドはPromiseを返す必要があります(次の値で解決されます)。
    • `async`キーワードがそれを処理します。単に`async next()`とすることができます。
  3. このようなオブジェクトをイテレートするには、`for await (let item of iterable)`ループを使用する必要があります。
    • `await`という単語に注意してください。

最初の例として、前のものと同様のイテラブル`range`オブジェクトを作成しますが、今度は1秒間に1つずつ非同期的に値を返します。

必要なのは、上記のコードでいくつかの置換を行うだけです。

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. このメソッドは、Promiseを返す`next()`メソッド `(2)` を持つオブジェクトを返す必要があります。
  3. `next()`メソッドは`async`である必要はありません。Promiseを返す通常のメソッドでも構いませんが、`async`を使用すると`await`を使用できるので便利です。ここでは1秒間遅延させています `(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.asyncIterator`ではなく`Symbol.iterator`を探しているからです。

`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`構成で必要とされるように、すべての値は同期的に取得される必要があります。

値を非同期的に生成したい場合はどうすればよいでしょうか?例えば、ネットワークリクエストから。

それを可能にするために、非同期ジェネレーターに切り替えましょう。

非同期ジェネレーター(ついに)

ほとんどの実用的なアプリケーションでは、非同期的に値のシーケンスを生成するオブジェクトを作成したい場合、非同期ジェネレーターを使用できます。

構文は簡単です。`function*`の前に`async`を付け加えます。これにより、ジェネレーターが非同期になります。

そして、このように`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`で動作します。

非同期イテラブルrange

通常のジェネレーターは`Symbol.iterator`として使用して、イテレーションコードを短縮できます。

これと同様に、非同期ジェネレーターは`Symbol.asyncIterator`として使用して、非同期イテレーションを実装できます。

例えば、同期的な`Symbol.iterator`を非同期的な`Symbol.asyncIterator`に置き換えることで、`range`オブジェクトが1秒間に1回非同期的に値を生成するようにすることができます。

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.iterator`と`Symbol.asyncIterator`の両方を追加して、同期的に(`for..of`)と非同期的に(`for await..of`)イテラブルにすることができます。

しかし、実際には、これは奇妙なことです。

実例:ページ分割されたデータ

これまで、理解を深めるための基本的な例を見てきました。今度は、実際のユースケースを確認しましょう。

ページ分割されたデータを配信するオンラインサービスは数多くあります。例えば、ユーザーのリストが必要な場合、リクエストはあらかじめ定義された数(例:100ユーザー)、「1ページ」を返し、次のページへの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. 次に、受信したコミットを1つずつyieldし、完了すると次の`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..of`の代わりに`for await..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開発では、大規模なファイルのダウンロードやアップロードなど、データがチャンク単位で流れるデータストリームによく出会います。

非同期ジェネレーターを使用してこのようなデータを処理できます。また、ブラウザなどの環境では、ストリームと呼ばれる別のAPIがあり、このようなストリームを操作するための特別なインターフェースを提供し、データを変換してストリーム間で渡すことができます(例:ある場所からダウンロードしてすぐに別の場所に送信する)。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善のご提案がありましたら、コメントする代わりにGitHubのissueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • コードを数単語挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。