AbortControllerと非同期ジェネレータでコールバック関数を置き換える

2021/10/23に公開

AbortController と非同期ジェネレータでコールバック関数を置き換えるパターンがおもしろかったのでご紹介します。

概要としては以下のような addEventListener の処理が、

button.addEventListener("click", (event) => {
  console.log(event.target);
});

こんなふうに書けます。

for await (const event of subscribeClick(button)) {
  console.log(event.target);
}

積極的に書き換える理由はとくにないと思うので、「こういうこともできる」程度の話です。


まずはイベントが発火したら解決する Promise を返す関数を作ります。

// 要素がクリックされたらイベントをもって解決する Promise を返す
function waitForClick(element: HTMLElement): Promise<MouseEvent> {
  return new Promise((resolve) => {
    element.addEventListener(
      "click",
      (event) => {
        resolve(event);
      },
      // 一度 resolve したら使えなくなるので once: true にしておく
      { once: true }
    );
  });
}

// こんなふうに使う
async function main() {
  const button = document.querySelector("button");
  const event = await waitForClick(button);
  console.log(event.target);
}

<input type="file"> を動的に作って File オブジェクトをいろいろ操作するような、一度しか使わないイベントを使う場合はこのパターンだけでも便利かもしれません)

一度 resolve すると Promise は用済みになるので、非同期のジェネレータを組み合わせて複数回起きるイベントに対応します。

async function* subscribeClick(element: HTMLElement) {
  // 無限ループにしておいて、
  while (true) {
    // クリックイベントが発生するのを待つ
    yield await waitForClick(element);
  }
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/function*

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for-await...of

ここまでで冒頭のコード例が実現できました。

async function main() {
  const button = document.querySelector("button");
  for await (const event of subscribeClick(button) {
    console.log(event.target);
  }
}

for を使っているため break でイベントの購読停止がやりやすいのがメリットかもしれません。イベントリスナを外すためだけにわざわざ関数に名前をつけたり、定義の場所を工夫したりするのはあるあるではないでしょうか。

// 3回だけ反応するイベントリスナ
async function main() {
  const button = document.querySelector("button");
  let count = 0;
  for await (const event of subscribeClick(button) {
    if (count === 3) {
      break;
    }
    console.log(event.target);
    count++;
  }
}

ですがじつは、Chrome と Firefox は addEventListener のオプションに signal という名前で AbortSignal を渡せるようになっているため、そうした用途にわざわざこんなことをする必要はありません。

// イベントリスナを簡単に外すだけならオプションに AbortSignal を渡せばいい
function main() {
  const button = document.querySelector("button");
  const controller = new AbortController();
  let count = 0;
  button.addEventListener(
    "click",
    (event) => {
      if (count === 3) {
        // controller.abort() でイベントの購読が止まる
        controller.abort();
        return;
      }
      console.log(event.target);
      count++;
    },
    { signal: controller.signal }
  );
}

https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

https://caniuse.com/mdn-api_eventtarget_addeventlistener_options_parameter_options_signal_parameter

https://developer.mozilla.org/en-US/docs/Web/API/AbortController

https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal

AbortController のイメージとしては、 AbortController を作る(new する)と、停止ボタンと、停止しているかどうかがわかるフラグ的なものがもらえるので、それを処理の中に渡してあげるような感じです。

ジェネレータの例に戻って、AbortSignal を受け取るように手を加えます。

// 第2引数に AbortSignal を追加
async function* subscribeClick(element: HTMLElement, signal: AbortSignal) {
  // while(true) の無限ループから変更
  // (AbortController.abort() が呼ばれたら終了するように)
  while (!signal.aborted) {
    yield await waitForClick(element);
  }
}

// イベントを扱う関数を独立させた
async function clickLogging(element: HTMLElement, controller: AbortController) {
  for await (const event of subscribeClick(element, controller.signal)) {
    console.log(event.target);
  }
}

function main() {
  const controller = new AbortController();
  const button = document.querySelector("button");
  // await しない
  clickLogging(button, controller);

  // ...
  //いろんな処理など
  // ...

  // イベントを扱う関数の外から止められる
  controller.abort();
}

いままでの例では addEventListener でやってきましたが、 同じようにコールバック関数を受け取る window.requestAnimationFrame でも同様のことができます。requestAnimationFrame をもとに Promise を返す関数を作り、上記の例と同様に非同期ジェネレータに組み込むような感じです。

function waitForRAF() {
  return new Promise<void>((resolve) => {
    window.requestAnimationFrame(() => {
      resolve();
    });
  });
}

async function* tickRAF() {
  while (true) {
    yield await waitForRAF();
  }
}

以下のように window.requestAnimationFrame でアニメーションの処理を再帰的に実行するパターンもこのやりかた(for await...of)でいけそうです。

function loop() {
  // ...
  // アニメーションの実装
  // ...
  window.requestAnimationFrame(loop);
}

async function animation() {
  for await (const _ of tickRAF()) {
    // ...
    // アニメーションの実装
    // ...
  }
}

実際に React (Preact) でやってみた例 ➡️

作ってみたもの ➡️


「このイベントの購読が終わったら〜〜をしたい」というようなときには便利かもしれませんが、やはり積極的にこんなことをする理由はあまりないように思います。

もっといい方法やパフォーマンスなどの問題があれば教えていただけるとうれしいです。

Discussion