Help us understand the problem. What is going on with this article?

JSの新機能、Async Iterationの使いどころ

More than 1 year has passed since last update.

非同期反復(async iteration)が年初にStage 4入りし、ES2018に追加されそうな見込みです。「先にもっと他のを!」という人もいそうですが、何が美味しいのか、少し書いてみたいと思います。

どんなやつ?

実際に使うには、非同期ジェネレータを定義して、for await (...)構文で呼び出します。(他の書き方もありますが、使用頻度が低そうなので省略)

// 指定のミリ秒待つ非同期関数 sleep(msec) が定義済みとして...

async function* wakeUp () { // 「*」を付けて、ジェネレータであることを示す
  yield 'Zzzz...' // ジェネレータなので、yieldが使える
  await sleep(2000) // asyncなので、awaitが使える
  yield 'Mornin!' // ジェネレータは複数回、結果を返せる
}

これは、次のように呼び出せます。

async function main () {
  for await (const message of wakeUp()) {
    console.log(message)
  }
}
main()

上記を実行すると、即座に'Zzzz...'と返ったあと、2000ミリ秒待って'Mornin!'が返ります。なお、for awaitも非同期なので、使えるのはasyncな関数の中だけです。

誰が使うのか?

(Asyncではない)ジェネレータについては、ライブラリの製作者でもないかぎり、日常的に使うものではありませんでした。特にasync/awaitの構文がES2017に入って以来、ユーザがジェネレータについて意識するシーンは激減したように思います。

一方、非同期ジェネレータは、ジェネレータというより 非同期関数の延長 として、捉えたほうが良さそうです。理由(というか使いとごろ)を次節に書きますが、実はむしろユーザサイドで使うと便利な機能です。(少なくとも筆者の所感としては)

使いどころ

説明のため、fetchで2つのデータを取得する非同期関数を考えます。ジェネレータにしなくても、次のように書くことは可能です。

// 非同期関数のみ
async function getData () {
  const data1 = await (await fetch(url1)).json()
  const data2 = await (await fetch(url2)).json()
  return {data1, data2}
}

ここで、途中の取得状況を示すにはどうしましょうか? これは結構厄介な問題で、ステータス更新の関数を途中に挟むなどの必要が生じます。しかし、この書き方ではあっという間に複雑化してしまいそうです。(ロジックの分割の点で不利)

// 複雑化してしまう...
async function getData () {
  updateStatus('0/2')
  const data1 = await (await fetch(url1)).json()
  updateStatus('1/2')
  const data2 = await (await fetch(url2)).json()
  updateStatus('2/2')
  return {data1, data2}
}

ここで、非同期ジェネレータを使うと、シンプルに書けるようになります。

// 非同期ジェネレータで、すっきり
async function* getData () {
  yield {status: '0/2'}
  const data1 = await (await fetch(url1)).json()
  yield {status: '1/2', data1}
  const data2 = await (await fetch(url2)).json()
  yield {status: '2/2', data2}
}

こう書きたかったシーン、UI周りでは結構多かったはず。領域として、redux-sagaで無理くりやっていたあたりが、JavaScriptの基本機能で書けるようになる感じでしょうか。(大雑把な話としては)

メモ: 参考まで、検索したら、Reduxのミドルウェアがありました。redux-thunk-generators

使い方入門

クラスやオブジェクト内で書く

独立した関数として書くなら、awaitをつけてfunctionの後にアスタリスク:

await function* simple () {
  ...
}

インスタンスメソッドとして書くなら、メソッド名の前にアスタリスク:

class Something {
  await *load () {
    ...
  }
  await *save () {
    ...
  }
}

オブジェクトメソッドとして書くなら:

const something = {
  await *load () {
    ...
  }, // ここのカンマを忘れずに
  await *save () {
    ...
  }
}

並列と直列

複数の非同期関数があって、同時に実行する(並列)、あるいは順番に実行する(直列)ケースを考えます。

const promises = [
  Promise.resolve('a'),
  Promise.resolve('b'),
  Promise.resolve('c')
]

並列のケースは以前からPromise.all()で書けました。

async function main () {
  const results = await Promise.all(promises)
  for (const result of results) {
    // do something on results
  }
}
main()

一方、直列についてキレイな書き方がなかったのですが、非同期ジェネレータで扱いやすくなります。

async function* series (promises) {
  for (const p of promises) yield await p
}

async function main () {
  for await (const result of series(promises)) {
    // do something on results
  }
}
main()

非同期ジェネレータを見分ける

次のような3つの関数があったとします:

function fA () {...}
await function fB () {...}
await function* fC () {...}

非同期ジェネレータであるかは、次のコードで見分けられます。

const result = fC() // 非同期ジェネレータは、実行すると非同期イテレータを返す
const isAsyncIterable = typeof result[Symbol.asyncIterator] === 'function'

非同期関数は、次のコードで。

const result = fB() // 非同期関数は、実行するとプロミスを返す
const isPromise = typeof result.then === 'function'

残りが普通の関数です。

種類 戻り値
関数 function fA () {...}
非同期関数 await function fB () {...} プロミス
非同期ジェネレータ await function* fC () {...} 非同期イテレータ

プリコンパイル

2018年4月現在のサポート状況は、次の通りです。詳しくはこちら

  • Google Chrome 63 以降
  • Firefox 57 以降
  • Safari Technical Preview 以降

つまり、しばらくはBabelとプラグインが必要です。

参考まで、Rollupする場合は、このあたり↓をざくっとインストールしておきます。おそらく、external-helperspolyfillも必要になりますが、それぞれの環境に合わせてください。

npm i -D \
  babel-core \
  babel-preset-env \
  babel-plugin-external-helpers \
  babel-polyfill \
  babel-plugin-transform-async-generator-functions \
  rollup \
  rollup-plugin-babel \
  rollup-plugin-commonjs \
  rollup-plugin-node-resolve

以下はrollup.config.jsの例です。

import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
import babel from 'rollup-plugin-babel'

export default {
  input: 'main.js',
  plugins: [
    resolve(),
    commonjs(),
    babel({
      babelrc: false,
      presets: [['env', {modules: false}]],
      plugins: [
        'transform-async-generator-functions',
        'external-helpers'
      ]
    })
  ],
  output: {
    file: 'bundle.js',
    format: 'iife',
    sourcemap: true
  }
}

Observableとともに

本格的に使うならおそらく必要になるので、Observableとの合わせ技をまとめます。

Observableとの互換性

非同期関数とプロミスの関係はシンプルでした。

async function asyncFunc () {...}
const promise = asyncFunc()

一方、非同期ジェネレータとプロミスの関係は一段遠くなります。

async function* asyncGenFunc () {...}
const asyncIterator = asyncGenFunc()
const promise = asyncIterator.next() // nextはプロミスを返す

プロミスの「約束」は一回限りですから、非同期イテレータの返す系列全体を扱うことはできません。複数回の「約束」に対応した概念としてObservableがあります。Observable自体の説明は省きますが、以下参考:

  • Observableはプロポーザルとしては現在Stage 1
  • 今のところ、zen-observableほかのライブラリが必要

非同期イテレータは次のように、Observableに変換が可能です。

/** 非同期イテレータ → Observable */
function toObservable(asyncIterator) {
  return new Observable(async observer => {
    try {
      for await (const data of asyncIterator) {
        if (data) observer.next(data)
      }
    } catch (e) {
      observer.error(e)
    }
    observer.complete()
  })
}

非同期ジェネレータは「pull」ですが、「push」で(イベント駆動的に)使いたいときはこのようにObservableに変換すると便利です。

async function* wakeUp () {
  yield 'Zzzz...'
  await sleep(2000)
  yield 'Mornin!'
}
toObservable(wakeUp())
  .map(msg => msg.toUpperCase()) // 大文字に
  .subscribe(msg => console.log(msg)) // ZZZZ..., MORNIN!

なお、時系列方向の拡がりと、push/pullで分類すると次のようになります。

「約束」 Pull Push
1回 非同期関数 Promise
N回 非同期ジェネレータ Observable

透過的に扱う

ここまでの話をすべて使うと、関数/非同期関数/非同期ジェネレータを透過的に扱うことも可能です。例えば、ユーザとしては、次のようなアクションを同列に扱いたいわけです。

const actions = {
  // 関数
  countUp (state) {
    return {count: state.count + 1}
  },
  // 非同期関数
  async countUp2 (state) {
    await sleep(2000)
    return {count: state.count + 1}
  },
  // 非同期ジェネレータ
  async *countUp3 (state) {
    yield {waiting: true}
    await sleep(2000)
    yield {waiting: false, count: state.count + 1}
  }
}

現時点でも、非同期関数(プロミス)に対応したライブラリは多いです。また、関数が渡されても非同期関数が渡されても「よしなに」扱ってくれる、つまり 透過的に 扱うライブラリも増えてきました。

それらと同様に、非同期ジェネレータまで扱えるようにすると、いろいろ表現力が一気に増します。(これ、今後、Reduxのような状態とアクションを扱うライブラリを開発する際には、重要になるんじゃないかと)

さっきのtoObservable()を拡張して、「透過的な」バージョンを作ってみましょう。

/** 値 or プロミス or 非同期イテレータ → Observable */
function toObservable(result) {
  return new Observable(async observer => {
    try {
      const isAsyncIterable = typeof result[Symbol.asyncIterator] === 'function'
      if (isAsyncIterable) {
        for await (const data of result) {
          if (data) observer.next(data)
        }
      } else {
        const data = result.then ? await result : result
        if (data) observer.next(data)
      }
    } catch (e) {
      observer.error(e)
    }
    observer.complete()
  })
}

これで、なんでもObservableで待ち受けられるようになりました。

追記: 同様の内容のライブラリを公開しました。
https://github.com/cognitom/fafgag

まとめ

便利。

cognitom
下北沢オープンソースCafeのマスターで、図書館サービス「リブライズ」のデザイン担当。Riot.jsのコア開発者。
https://github.com/cognitom
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした