お久しぶりです。GMOインサイトの天河です。
ついこの間、JavaScriptのジェネレータについて社内勉強会で発表したのでその内容をまとめます。
※ 注意
本記事で言及している「ジェネレータ」はJavaScriptでの言語仕様です。一部通ずる箇所もあると思いますが、Python や C# など他の言語での使われ方についてはしかるべき文献を見てください。
目的
- ジェネレータ が何かわかるようになる
- ジェネレータについて面接で聞かれても余裕で答えられるようになる
- 実装時にジェネレータを選択肢として持てるようになる
対象読者
- ジェネレータ が何か全くわかっていない人
- ジェネレータ について認知はしているものの、どういうものかは把握していない人
- ジェネレータ を知ってはいるものの、使い所がわからない人
はじめに
- ジェネレータを理解するためには、「イテレータ」と「イテラブル」について知る必要があります。なので、この二つの概念から説明していきます。
- 「イテレータ」「イテラブル」「プロトコル」など難しい単語が出てきますが、付いて来てください。
- プロトコルとは簡単に言うと「〇〇 する時は △△ しましょう」というお約束ごとです。何かをするにあたり定められている手順です。
- プロトコルを満たしていることを「プロトコルに準拠している」と言います。
例1:インターネット上で電子メールを送り合う時は 〇〇 という通信手順を踏みましょう
→ SMTP(Send Mail Transfer Protocol)
例2:インターネット上でファイルの送受信をするときは 〇〇 という手順を守りましょう
→ FTP(File Transfer Protocol)
目次
イテーレタとは?
有限個 or 無限個の値を一つずつ生成 / 抽出することができるオブジェクトです。
正確には「イテレータプロトコルに準拠しているオブジェクト」です。
イテレータプロトコルとは「有限個 or 無限個の値を生成する方法を定義した」プロトコルです。反復子プロトコルとも言います。
しつこいですが、プロトコルとは「約束事や手順」のことなので、超噛み砕いて説明すると、
「有限個、または無限個の値を生成するオブジェクトを定義する時はこの手順に従ってね!」
というお約束ごとです。
そしてそのプロトコルの内容は以下です。
- 0個または1個の引数を持つ
next
メソッドがあること next
メソッドがIteratorResult
を返すこと
これらを満たしているオブジェクトは「イテレータプロトコルに準拠している」ため、イテレータオブジェクト(略してイテレータ)になります。IteratorResult
は、done
+value
というプロパティを持つオブジェクトです。
1 2 3 4 |
interface IteratorResult<T> { done: boolean; value: T; } |
done | イテレータが一連の値を全て生成した or 取り出したか。まだなら false 、完了なら true |
---|---|
value | イテレータの値。next を呼ぶ度に次の値を生成する or 取り出す。※ done が true の場合 undefined になる。 |
IteratorResult
によって、値だけでなく生成 / 取り出しが完了したかどうかの情報も取得することができます。
以下はイテレータが一連の値を「生成する」サンプルになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// イテレータ サンプル let n = 0; const iterator = { next() { n += 1; if (n > 3) { return { value: undefined, done: true }; } return { value: n, done: false }; }, }; iterator.next(); // { "value": 1, "done": false } iterator.next(); // { "value": 2, "done": false } iterator.next(); // { "value": 3, "done": false } iterator.next(); // { "value": undefined, "done": true } iterator.next(); // { "value": undefined, "done": true } |
next
メソッドが呼ばれることで一連の値が一つずつ生成されて、全て生成されたら done
を true
にしてイテレータを終了させます。
下の例は、値を生成するのではなくあらかじめ用意されてある要素から一つずつ取り出す例です(巷で紹介されているイテレータの例で多いのはこのパターン)。
1 2 3 4 5 6 7 |
const array = [1,2,3]; const fetchSample = array[Symbol.iterator](); // イテレータを返す関数の呼び出し fetchSample.next(); // { "value": 1, "done": false } fetchSample.next(); // { "value": 2, "done": false } fetchSample.next(); // { "value": 3, "done": false } fetchSample.next(); // { "value": undefined, "done": true } |
ただし、こちらの例ではイテレータの良さが出ていません。記事後半でこの件について説明しているので見てみてください。
まとめると、イテレータは next
メソッドを使って一連の値を生成する / 取り出すとともに、その一連の値を使い切ったかの情報を取得することができます。
※ イテレータプロトコル「だけ」を満たしているケースは(自分で作らない限り)ほぼ存在しません。同時にイテラブルプロトコルを満たしていることがほとんどです。
でも生成する要素が大量にあった場合、全部の要素を next
で呼ぶとなると面倒ですね。そこでイテラブルの話に移ります。
イテラブルとは?
イテラブルとは、イテラブルプロトコルを満たすオブジェクトです。イテラブルプロトコルは「反復可能プロコトル」とも言い、名前通り
「オブジェクトを反復処理できるようにしたかったらこれを守ってね!」
というお約束ごとです。そのプロトコルの内容は、
- 0個の引数を持つ
[Symbol.iterator]
メソッドを持っていること [Symbol.iterator]
メソッドがイテレータオブジェクトを返すこと
です。先程作ったイテレータを返す [Symbol.iterator]
メソッドを作ると、イテラブルの完成です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// イテラブル サンプル let n = 0; const iterator = { next() { n += 1; if (n > 3) { return { value: undefined, done: true }; } return { value: n, done: false }; }, }; // 以降を追加 const iterable = {}; iterable[Symbol.iterator] = () => { return iterator }; for(const i of iterable) { console.log(i); } // 1 // 2 // 3 |
この例のように、イテラブルであることで for...of
などの反復処理のインターフェースが使えるようになります。
反復処理を行うインターフェースは反復処理する際に [Symbol.iterator]
メソッドを呼び出して得られるイテレータを利用するからです。
このようにイチから自作するイテラブルなオブジェクトもあれば、元から用意されているイテラブルなオブジェクトもあります。「組み込み反復可能オブジェクト」と言います。
組み込み反復可能な型 | 呼び出し例 | ||
String |
|
||
Array |
|
||
TypedArray |
|
||
Map |
|
||
Set |
|
||
Segments |
|
||
arguments |
|
||
NodeList |
|
||
ReadableStream |
|
ここでイテレータとイテラブルについてまとめます。
イテレータ | 引数を0または1個持ち、 IteratorResult を返すnext メソッドを持つオブジェクト。値の [生成 / 抽出] の [停止 / 再開] が next メソッドによって可能。 |
イテラブル | 引数が0個を持つ、イテレータを返す [Symbol.iterator] メソッドを持つオブジェクト。反復処理のインターフェースが使用できる。 |
IteratorResult |
イテレータの next メソッドから返る、value +done のペアを持つオブジェクト。value はイテレータの値、done は全てのイテレータの値の生成/取り出しが完了したかの情報を示す。 |
そしていよいよ本題に入ります。
ジェネレータとは?
ジェネレータとは、イテレータかつイテラブルであるオブジェクトのことです。IteratorResult
を返す next
メソッドを持ち、[Symbol.iterator]
メソッドがイテレータを返すオブジェクトです。
イテレータであることにより、値の生成の開始 / 中断 をコントロールでき、イテラブルであることにより反復処理が可能になっています。
作り方は超簡単です。イテラブルなイテレータを作れば良いので、[Symbol.iterator]
メソッドでイテレータである自分自身を返すだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// ジェネレータ サンプル let n = 0; // カスタムジェネレータ const generator = { next() { n += 1; if (n > 3) { return { value: undefined, done: true }; } return { value: n, done: false }; }, [Symbol.iterator]() { return this; // <- イテレータである自分自身を指定する } }; for(const g of generator) { console.log(g); } // 1 // 2 // 3 |
ただ、ジェネレータを作るのに next
メソッドと[Symbol.iterator]
メソッドを定義したオブジェクトをいちいち作るのはめんどくさいですよね。
そこでジェネレータ関数というジェネレータを一発で作ってくれる便利な関数が用意されています。
ジェネレータ関数
ジェネレータ関数とは、返り値がジェネレータである関数です。
以下のように function* ()
と記述します。yield
とセットで使用します。yield
については後ほど説明します。
1 2 3 4 5 6 7 8 9 |
// ジェネレータ関数 サンプル const foo = function* () { yield 1; yield 2; yield 3; }; const g = foo(); console.log(g) // [object Generator] |
ジェネレータ関数を呼び出した段階ではコードは実行されません。ジェネレータオブジェクトが返却されます。それを操作する形でジェネレータの中身を実行していきます。
ジェネレータはイテレータなので next
メソッドを使うことができます。試しに4回連続で next
メソッドを使ってみましょう。
1 2 3 4 5 |
// 呼び出し console.log(g.next()); // Object { value: 1, done: false } console.log(g.next()); // Object { value: 2, done: false } console.log(g.next()); // Object { value: 3, done: false } console.log(g.next()); // Object { value: undefined, done: true } |
yield
に続く値を value
に持つ IteratorResult
が順番に出力され、最後は value
が undefined
の IteratorResult
が表示されれ、イテレーションが終了しました。
また、ジェネレータはイテラブルなので反復処理を簡単に書けます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// ジェネレータ イテラブルである例 function* gen() { yield 1; yield 2; yield 3; } let sum = 0; for(const g of gen()) { sum += g; } console.log(g); // 6 |
このようにジェネレータは next
メソッドを呼ぶことで yield
に続く値を value
に持つ IteratorResult
を順に出力することができて、反復処理もできます。
さて、この yield
は何者でしょう。
yieldとは?
yield
式はジェネレータ関数の実行を一時停止するものです。そして、ジェネレータの呼び出し元に yield
キーワードに続く値を返します。
ジェネレータの next
メソッドが呼ばれると yield
式までジェネレータ関数が実行され、そこでコードの実行が一時停止します。
再度 next
メソッドを実行すると、停止した yield
の位置から処理が再開し、次の yield
まで処理が進み、yield
に続く値を返してまた一時停止します。だから next
を呼ぶ度に値が変わっていたわけです。
そして yield
はジェネレータ関数の中でしか使えません。
また、next
メソッドに引数を入れると「再開時に」その値から処理を再開することができます。
以下のジェネレータ関数を実行すると、g.next(1)
はどういう値になるでしょうか。
1 2 3 4 5 6 7 8 9 10 |
// 出力はどうなる function* gen() { while (true) { let value = yield null; console.log(value); } } const g = gen(); g.next(1); // <- ここの返り値は? |
{value: 1, done: false}
になるのでは?と思ったそこのあなた。ありがとうございます。違います。
正解は {value: null, done: false}
です。next
メソッドの引数は「再開値」を指定するものです。
1 2 3 4 |
// yield式の書き方 [左辺の値] = yield[値]; ※ 左辺は省略可能 |
next
メソッドの引数の値は、「処理の再開時に」「左辺の値を」書き換えるものです。つまりnext
メソッドの初回呼び出し時に引数を入れるのは全く意味がない行為です。
1 2 3 4 5 6 7 |
// 1回目のnext呼び出し(停止) function* gen() { while (true) { let value = yield null; // <- ここで停止。{value: null, done: false} になる console.log(value); } } |
もう一回 next
メソッドに引数を指定して呼び出すと、出力はどうなるでしょう。
1 2 |
// 引数を指定して再開する g.next(2); |
yield
の位置から再開し、左辺の value
が2になって「2」が出力されます。
1 2 3 4 5 6 7 |
// 2回目のnext呼び出し時 function* gen() { while (true) { let value = yield null; // value=2で再開 console.log(value); // 2 } } |
そして IteratorResult
は常に {value: null, done: false}
です。
1 2 3 4 5 6 7 |
// 2ループ目(2回目の停止) function* gen() { while (true) { let value = yield null; // <- ここで停止。{value: null, done: false} になる console.log(value); } } |
また、ジェネレータには return
メソッドが存在しており、IteratorResult
の値をreturn
メソッドの引数で指定したものでジェネレータを終了させることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// ジェネレータのreturn 使用例 function* gen() { yield 1; yield 2; yield 3; // 一生呼ばれない } const g = gen(); console.log(g.next()); console.log(g.next()); console.log(g.return(10)); // 10を返すように指定してジェネレータを終わらせる console.log(g.next()); // > Object { value: 1, done: false } // > Object { value: 2, done: false } // > Object { value: 10, done: true } <- 引数の値になり done が true になる // > Object { value: undefined, done: true } |
yield
の処理の流れをまとめると、以下のシーケンス図のようになります。
イメージ的に「戻って来られるreturn
文みたいなやつ」ですね。
yield* 式
yield*
式というものもあります。yield*
にイテラブルなオブジェクトを渡すと、その渡したオブジェクトに反復処理を移すことができます。
yield*
はまず、渡されたイテラブルなオブジェクトの[Symbol.iterator]
メソッドを呼び出して、イテレータを取得します。そしてジェネレータのnext
メソッドが呼び出されるたびに、yield*
は取得したイテレータの next
メソッドを呼び出して、IteratorResult
を呼び出し元に返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// yield* 例 function* gen2() { yield "a"; yield "b"; yield "c"; } function* gen() { yield 1; yield* gen2(); yield 3; } const g = gen(); console.log(g.next().value); // 1 console.log(g.next().value); // a <- yield* console.log(g.next().value); // b <- yield* console.log(g.next().value); // c <- yield* console.log(g.next().value); // 3 // 以下でも同じ結果 for (const g of gen()) { console.log(g); } |
yield*
の処理中に、呼び出し元でジェネレータの return
メソッドを呼び出すとどうなるでしょうか。
結論、yield*
に渡したイテレータからもreturn
メソッドが呼ばれ、done
が true
のIteratorResult
を返してイテレータは終了します。そして呼び出し元にもその IteratorResult
が返り、ジェネレータも終了します。
引数を付けて return
メソッドを呼ぶと { value: 引数の値, done: true }
の IteratorResult
が返って来ます。付けなかったら value
が undefined
で返されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// yield* return呼び出し例 function* gen2() { yield "a"; yield "b"; yield "c"; // 一生呼ばれない } function* gen() { yield 1; yield* gen2(); yield 3; // ここも一生呼ばれない } const g = gen(); console.log(g.next()); console.log(g.next()); console.log(g.next()); console.log(g.return(10)); console.log(g.next()); // Object { value: 1, done: false } // Object { value: "a", done: false } // Object { value: "b", done: false } // Object { value: 10, done: true } <- 引数で指定した値になる // Object { value: undefined, done: true } |
yield*
の処理の流れは以下のようになります。
ちなみに yield*
先で return
が呼ばれたらどうなるかと言うと、return
した値が yield*
式の評価値として、yield*
先のジェネレータは終了します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// yield*の評価値 function* g1() { yield* [1, 2, 3]; return "finish"; } function* g2() { const gReturnValue = yield* g1(); console.log(gReturnValue) return gReturnValue; } const gen = g2(); console.log(gen.next()); console.log(gen.next()); console.log(gen.next()); console.log(gen.next()); // returnされて g1() で作成したジェネレータが終了する console.log(gen.next()); // g2()で returnする -> { value: return値, done: true } になる // Object { value: 1, done: false } // Object { value: 2, done: false } // Object { value: 3, done: false } // "finish" // Object { value: "finish", done: true } |
続いて、非同期の処理に対応している非同期ジェネレータ関数というのもあります。
非同期ジェネレータ関数
非同期ジェネレータ関数はそのままの意味で、非同期処理を扱うためのジェネレータ関数です。
「非同期反復可能プロトコル」と「非同期イテレータプロトコル」に準拠したオブジェクトを返す関数です。
非同期反復可能プロトコル | 非同期イテレータを返す引数ゼロの [Symbol.asyncIterator] メソッドを持つ |
非同期イテレータープロトコル | 0 または 1 個の引数を受け入れ、プロミスを返すnext メソッドを持つ。プロミスはIteratorResult インターフェイスに準拠したオブジェクトとして解決される |
色々書いていますが、非同期ジェネレータ関数は以下のように定義します。
1 |
async function* foo() { } |
ジェネレータ関数の記法の先頭に async
を付けるだけです。注意として反復処理をするときは for...of
の代わりに for await ... of
を使います。
お粗末な例ですが、以下に「数秒経ったらvalue
の値でresolve
される関数」の非同期ジェネレータを書きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// 非同期ジェネレータ関数の例 // 非同期処理を行う関数 function delayedValue(time, value) { return new Promise((resolve ,reject) => { setTimeout(() => resolve(value), time); }); } // 非同期ジェネレータ関数 async function* generate() { yield await delayedValue(1000, 1); yield await delayedValue(100, 2); yield await delayedValue(500, 3); yield await delayedValue(250, 4); yield await delayedValue(125, 5); console.log("All done!"); } // 非同期ジェネレータの呼び出し関数の定義 async function main() { for await (const value of generate()) { console.log("value", value); } } // 呼び出し main().catch((e) => console.error(e)); |
await
を使っているので async
関数内に処理を定義して、そのasync
関数を呼び出す必要があります。
出力は以下のようになります。
1 2 3 4 5 6 7 |
yield で処理を一時停止し値を受けとる ↓ 値を加工する ↓ 加工した値をnextの引数に入れて再開 ↓ ... |
といった自在度の高い処理も書くことができます。
ジェネレータの遅延評価について
ジェネレータは必要なときに必要な分だけメモリを消費するので、メモリ使用量を節約できます。10,000個、100,000個、1,000,000個、10,000,000個の連続するデータを配列とジェネレータで定義したときで比較してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// メモリ使用量 実験用コード // メモリ使用量測定関数 function measureMemoryUsage(description) { const used = process.memoryUsage().heapUsed / 1024 / 1024; console.log(`${description} メモリ使用量: ${Math.round(used * 100) / 100} MB`); } // ジェネレータ関数 function* createGenerator(n) { for (let i = 0; i < n; i++) { yield i; } } let n = 10000; // 10000, 100000, 1000000, 10000000 measureMemoryUsage("初期メモリ使用量"); /* 配列での生成 */ const array = [...Array(n)].map((v,i) => i); // 要素生成 measureMemoryUsage("配列生成時のメモリ使用量"); /* ジェネレータでの生成 */ const gen = createGenerator(n); for (const value of gen) { if (value+1 === n) { measureMemoryUsage(`ジェネレータで最後の要素を参照した時のメモリ使用量`); } } |
結果は以下のグラフになります。
ジェネレータは必要になったタイミングでのみメモリ確保するため、メモリ使用量はほぼ一定です。要素が多くなるほど配列とのメモリ使用量の差が顕著に現れます。上記グラフで言うと、100,000個以上からその差が如実に現れ始めますね。
少ない要素数ではそこまで顕著な差が現れないので、普段使いでメモリ使用量を意識してyield
を選択するというケースはほとんどないかと思われます。
配列 | データが一度にメモリにロードされるため、要素数が多いと大量にメモリを消費する |
ジェネレータ | 必要なタイミングでデータを「逐次」生成するので、必要な分だけメモリに読み込む |
AtCoderなどのプログラミングコンテストでJavaScriptを使っている人は、メモリを考慮して解く問題の時のために頭の片隅に置いておくと幸せになるかもしれません。
僕はたまに yield
に助けられるケースがあります。
自分的にジェネレータの大きなメリットはメモリよりも「処理の途中で必要なデータを取り出す処理が簡単に書ける」ことかと思っています。
このメリットを活かしたジェネレータのユースケースを紹介します。
ジェネレータのユースケース
注意(言い訳):結構無理やり使っているかつ最低限のUI実装しかしていないため、ブラッシュアップは各自でお願いします。ここまで書くのに10時間くらいかかったので疲れました。
ChatGPTの出力のようなリアルタイムレスポンスを表示する
昨今、生成AIの発展に従ってリアルタイムにレスポンスを受け取る技術が注目されています。それに従って、SSEの処理が簡単に書けるインターフェースがいろんなプログラミング言語で実装されています。
※ SSE(サーバサイドイベント)とは、サーバがクライアントに対してリアルタイムでイベントを送信することができるインターフェースのことです。Hono
とOpenAI
のパッケージをインストールしたバックエンドと、React
を使ったフロントエンド間でやりとりして、ChatGPTのUIを実装してみましょう。yield
をフル活用します。
バックエンド
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
// ユースケース Backend Sample import OpenAI from 'openai'; import { Hono } from 'hono' import { cors } from 'hono/cors' import { streamSSE } from 'hono/streaming' const app = new Hono() app.use(cors()) // ChatCompletion APIからリアルタイムで出力を受け取ってフロントに送る非同期ジェネレータ関数 async function* streamCompletion(prompt: string) { const client = new OpenAI({ apiKey: process.env['REACT_APP_OPENAI_API_KEY'], // OpenAIのAPIキーは.envで設定 }); const stream = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], stream: true, // ★★★ 必須 ★★★ }); // streamは非同期イテラブル for await (const chunk of stream) { if (chunk.choices[0].delta.content) { // ストリームの出力を整形 const cleanedContent = chunk.choices[0].delta.content .replace(/data:/g, '') .replace(/\s+/g, ' ') if (cleanedContent) { yield cleanedContent; } } } } app.post('/api/chat', async (c) => { const { prompt } = await c.req.json() return streamSSE(c, async (stream) => { for await (const content of streamCompletion(prompt)) { await stream.writeSSE({ data: content }) } }) }) export default app; |
フロントエンド
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
// ユースケース1 Frontend Sample import React, { useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react' import './App.css'; // サーバからリアルタイムで結果を受け取る非同期ジェネレータ関数 async function* fetchStreamData(prompt: string) { const response = await fetch('http://localhost:3000/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ prompt }), }); // fetch APIで取得したレスポンスは読み取り可能なストリームとして使える // ただしPOSTメソッドで取得したレスポンスにはReadableStream APIは使えない const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (reader) { while (true) { const { value, done } = await reader.read(); if (done) break; const buffer = decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); for (const line of lines) { // ストリームの出力を整形 if (line.startsWith('data: ')) { const cleanedContent = line.slice(6) .replace(/data:/g, '') .replace(/\s+/g, ' ') if (cleanedContent) { // 取得したレスポンスをとりあえず返し中断→再開を繰り返す yield cleanedContent; } } } } } } function App() { const [input, setInput] = useState<string>(''); const [result, setResult] = useState<string>(''); const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setResult(''); for await (const chunk of fetchStreamData(input)) { setResult((prevResult) => prevResult + chunk); // 結果の繋ぎ合わせ } }; const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { setInput(e.target.value); }; return ( <div className="App"> <header className="App-header"> <p> 質問してください </p> <main> <form onSubmit={(e) => handleSubmit(e)}> <input type="text" className={""} id="input" value={input} onChange={(e) => handleInputChange(e)} placeholder="Enter your message" /> <button type="submit">Send</button> </form> <div id="result" className={"prompt-result"}> {result} </div> </main> </header> </div> ); } export default App; |
成果物
プロンプトを投げると回答がリアルタイムで出力されるUIができました。
データの処理進捗をサーバから受け取るUI
バックエンド
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// UseCase2 Backend Sample import OpenAI from 'openai'; import { Hono } from 'hono' import { cors } from 'hono/cors' import { streamSSE } from 'hono/streaming' const app = new Hono() app.use(cors()) function getRandomInt(max: number) { return Math.floor(Math.random() * max); } // ダミー処理をする非同期ジェネレータ関数。処理完了率を計算し返す。 async function* fetchSampleData() { let processed = 0; const dataCount = 100; for (let i = 1; i <= dataCount; i++) { // 処理に100〜300msくらいかかるデータを100個擬似的に取得 if (i < 50) { await new Promise((resolve) => setTimeout(resolve, getRandomInt(300))); } else { await new Promise((resolve) => setTimeout(resolve, getRandomInt(100))); } processed++; yield { percentage: processed / dataCount * 100 }; // 進捗とデータをクライアントに送信 } } app.get('/api/get-dummy-data', async (c) => { return streamSSE(c, async (stream) => { for await (const { percentage } of fetchDummyData()) { await stream.writeSSE({ data: percentage.toString() }); } }); }); export default app; |
フロントエンド
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
// UseCase2 Frontend Sample import React, { useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react' /** * 以下のライブラリから拝借しました * https://github.com/kevinsqi/react-circular-progressbar */ import { CircularProgressbar } from 'react-circular-progressbar'; import 'react-circular-progressbar/dist/styles.css'; import './App.css'; // サーバからリアルタイムでデータ処理の進捗を受け取る async function* fetchStreamData() { const response = await fetch('http://localhost:3000/api/bulk-process', { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (reader) { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data:')) { const newPercentage = parseInt(line.substring(5).trim(), 10); yield newPercentage; } } } } } const App = () => { const [percentage, setPercentage] = useState<number>(0); const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); for await (const progress of fetchDummyData()) { setPercentage(progress); // 進捗セット } }; return ( <div className="App"> <header className="App-header"> <main> <div className={"execute-process"}> <form onSubmit={(e) => handleSubmit(e)}> <button type="submit">データ取得</button> </form> </div> </main> <div id="result" style={{ width: 200, height: 200, textAlign:"center", marginTop: "16px" }}> <CircularProgressbar value={percentage} text={`${percentage}%`} /> </div> </header> </div> ); } export default App; |
成果物
データ取得の進捗を表現するUIができました。
まとめ
- ジェネレータを一言で表すなら「処理の中断・再開と反復処理が簡単にできるオブジェクト」
yield
によってジェネレータ関数の処理の中断と再開が可能
補足
Iterator 抽象クラス
Iterator
抽象クラスというイテレータを作るインターフェースがJavaScriptには存在していますが、2024年10月現在 Safari、Node.js と Denoでは未対応、firefoxではまたプレビューの状態です。
このIterator
抽象クラスにはすでにプロトタイプに this
を返す [Symbol.iterator]
メソッドが搭載しており、Iterator
抽象クラスを継承したクラスから作られたインスタンスはすでにジェネレータになっています。
@@Iterator ってなんだ?
よくイテレータ関連の情報を調べてくる @@iterator
は、Symbol.iterator
に置き換えても同じ意味です。このアットマーク2つで書くシンボルを「ウェルノウンシンボル(well-known symbol)」と言い、あらかじめJavaScript側で定義されているシンボル名です。(例: @@iterator
→ Symbol.iterator
)
yield ← なんて呼ぶの
イールドです。イェールドではありません。
参考文献
mdn web docs:iterator#解説
mdn web docs:反復処理プロトコル/言語と反復処理プロトコルの対話
mdn web docs:Generator
mdn web docs:Generator/next
mdn web docs(en-US版):yield*
mdn web docs:AsyncGenerator