Edited at

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



まとめ

便利。