非同期反復(async iteration)が年初にStage 4入りし、ES2018に追加されそうな見込みです。「先にもっと他のを!」という人もいそうですが、何が美味しいのか、少し書いてみたいと思います。
- Async Iteration プロポーザル (参考: 審議中 / Stage 4)
どんなやつ?
実際に使うには、非同期ジェネレータを定義して、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-helpers
とpolyfill
も必要になりますが、それぞれの環境に合わせてください。
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
まとめ
便利。