[JavaScript] コールバック関数を非同期イテレータで置き換える

2024/01/07に公開

1. 経緯

先日読んだNostrの記事が面白く、仕様を読んでクライアントを実装して遊んでみようと思いました。
https://zenn.dev/mattn/articles/cf43423178d65c
https://github.com/nostr-protocol/nips

Nostrのクライアントとリレー(サーバー)間は、WebSocketで通信するのですが、JavaScriptの WebSocket は受信処理を message イベント用のハンドラを設定する方式でした。
https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications

const socket = new WebSocket("wss://example.com");

socket.onmessage = e => {
    // ...
};

これはこれで良いのですが、Promise を使う非同期処理に書き換えたら、見通しが良くなったりしないだろうかという興味が湧いて (当初の目的から脇に逸れて) 実装を考えてみました。

先に書いておくと、この方式により見通しが良くなったり実装が良くなったかと言うと微妙に感じています。とはいえ、何か使い道はあるかもしれないので、供養も兼ねて記事にしておこうと思います。

2. 実装

Promise は一度しか使えないので、今回のように後からどんどんやってくる場合には、その数だけ順次 Promiseを生成する必要があり、非同期イテレータ を満たすクラスを実装することにしました。[1]
{ value, done } を出力する Promise を返す next() メソッドを実装し、[Symbol.asyncIterator]() メソッドで、this を返すようにすれば、最低限の実装はできます。

コールバック用のメソッドを生やしておき、イベントハンドラとして登録する事で、Promiseが解決されます。[2]

利用側または解決側が呼びだされ、必要になった時点で、新しいPromiseを生成して、キューに積み順番に利用していきます。

class Callback2AsyncGenerator {
    constructor(){
        this.resolve = [];
        this.promise = [];

        this.done = false;
    }

    #generate(){
        this.promise.push(new Promise(
            this.done ?
                r => r({ done: true }) :
                r => this.resolve.push(r)
        ));
    }

    [Symbol.asyncIterator](){
        // Implement async iterable
        return this;
    }

    next(){
        if(this.promise.length === 0){
            this.#generate();
        }

        return this.promise.shift();
    }

    callback({ value, done }){
        if(this.done){
            return;
        }

        if(this.resolve.length === 0){
            this.#generate();
        }

        // When `value` is `undefined`, `done` should be `true`,
        // otherwise default `done` is `false`.
        done ??= value === undefined;

        this.resolve.shift()({ value, done });

        if(done){
            this.done = done;

            // Clean up all remained promiseses.
            for(const r of this.resolve){
                r({ done: true });
            }
            this.resolve = [];
        }
    }
};
const cb2ag = new Callback2AsyncGenerator();

socket.onmessage = e => {
    const value = /* ... */
    const done = /* ... */
    cb2ag.callback({ value, done });
};

for await (const value of cb2ag){
    // ...
}

3. まとめ

イベントハンドラのコールバックAPIを、for await ... of で使える非同期イテレータに置き換えるためのクラスを実装してみました。
使うことで、設計が良くなるかと言われると微妙に感じるので、もう少し何か良い改善案があればいいなと感じています。

参考

https://zenn.dev/boarwell/articles/callback-by-generator
https://stackoverflow.com/questions/74256266/convert-function-using-callbacks-into-async-iterator-variant

脚注
  1. 初めは async function*yield で実装しようと思いましたが、うまい実装が思いつかず、完全にスクラッチで、非同期イテレータクラスを実装に切り替えました。 ↩︎

  2. 今回はインターフェースを汎用的に書いているので、ラッパー関数をイベントハンドラにしていますが、用途に特化したインターフェースにしたら、そのまま渡せるかもしれません。 ↩︎

Discussion