📞

addEventListenerでリッスンしているイベントをPromise化する

2022/06/03に公開

概要

addEventListenerはブラウザ組み込みのAPIの中でも最もよく使われるメソッドの中の一つだと思います。このメソッドはぱっと見で処理がわかりにくく引数にコールバック関数を取るため、初心者の頃に物凄く読みにくいコードを書いた人も多いのではないでしょうか?

addEventListenerが読みにくくなってしまう簡単な例を挙げます。以下のコードのようにAというイベントが発生しないとBというイベントを購読する処理をかけないといったイベント間に依存関係がある場合は、処理が増えるにつれ直感的なコードを書くことが難しくなっていくと思われます。

// <body>より上の位置で<script>にdefer属性なしで書かれてあると思ってください。
document.addEventListener("DOMContentLoaded", () => {
  console.log("DOMContent Loaded !!");
  const img = document.getElementById("img");
  img.addEventListener("load", (e) => {
    console.log(`${e.target.src} Loaded !!`);
    });
});

そこで、この記事では上記のようなコードをasync/awaitを使ってスッキリ書く方法を紹介します。

結論

いきなり結論なのですが、addEventListenerで発生したイベントをPromiseで返却するという処理は汎用的な関数として切り出すことができます。実装例をeventPromisify関数として以下に示します。この関数の中身はイベントハンドラーを設定している処理をPromiseでラップして指定のイベントが発生したらコールバック関数に渡される引数をそのままの形でPromiseの成功値といて返してくれるような構成になっています。こちらの関数はopenloadといった一回しか発生しないイベントと特に相性が良く、逆に二回以上発生する可能性があるイベントは最初の一回しか返却することができないので注意が必要です。

/**
 * @param {EventTarget} eventTarget
 * @param {string} eventName
 */
const eventPromisify = (eventTarget, eventName) => {
  return new Promise((resolve, reject) => {
    eventTarget.addEventListener(eventName, (...args) => resolve(...args));
  });
};

この関数を使うことによってaddEventListenerをネストさせて書いていた処理をasync/await構文を使って同期処理のように記述することができます。冒頭で書いたサンプルコードは以下のように書き換えることができます。

await eventPromisify(document, "DOMContentLoaded");
console.log("DOMContent Loaded !!");
const img = document.getElementById("img");
const event = await eventPromisify(img, "load");
console.log(`${event.target.src} Loaded !!`);

どうでしょうか?特定のイベントの発生を待ってから何か処理をしているということが直感的に理解しやすくなったと思います。また、これから先でまた別のイベントに依存することになったとしてもコードをクリーンに保つことができるのではないでしょうか。ニッチな要望を実現するための関数なので、どこでも使えるという感じではないのですが、生DOM等のイベント処理を頻繁に行うシーンなどで役に立ってくれるかもしれません。

最後に今回使用したコードが最小構成で動くHTMLを載せておきます。イマイチよく分からなかった, 腑に落ちなかったという方はコピペしてコードを修正しながら挙動を確認してみてください。

コールバック版
callback.html
<!DOCTYPE html>
<html lang="ja">
  <script>
    document.addEventListener("DOMContentLoaded", () => {
      console.log("DOMContent Loaded !!");
      const img = document.getElementById("img");
      img.addEventListener("load", (e) => {
      	console.log(`${e.target.src} Loaded !!`);
      });
    });
  </script>
  <body>
    <!-- 適当な画像を用意してください -->
    <img id="img" alt="sample" src="./sample.png" />
  </body>
</html>
promise版
promise.html
<!DOCTYPE html>
<html lang="ja">
  <script>
    // <body>の読み込みが始まっていないので、Elementオブジェクトが取得できない。
    const img = document.getElementById("img");
    console.log(img, "これはnullになる");
    const eventPromisify = (eventTarget, eventName) => {
      return new Promise((resolve, reject) => {
        eventTarget.addEventListener(eventName, (...args) => resolve(...args));
      });
    };
    (async () => {
      await eventPromisify(document, "DOMContentLoaded");
      console.log("DOMContent Loaded !!");
      // DOMContentLoaded後に実行されるため、Elementオブジェクトが取得できる。
      const img = document.getElementById("img");
      const event = await eventPromisify(img, "load");
      console.log(`${event.target.src} Loaded !!`);
    })();
  </script>
  <body>
    <!-- 適当な画像を用意してください -->
    <img id="img" alt="sample" src="./sample.png" />
  </body>
</html>

まとめ

この記事ではイベントをPromiseで受け取れるようにして同期処理っぽく書く方法を紹介しました。筆者は最近になって「コールバック関数をPromiseでラップする」という手法を覚えましたが色々なところで応用が利いて、視野が広がったような感覚があります。この記事の内容もその過程の中で見つけたものになります。非同期処理は奥が深いですね。

余談

今回紹介したeventPromisifyはTypeScriptで書こうとすると型の定義が難しいなと思いました。第一引数のEventTargetから第二引数にとれるイベント名を制限できたり、引数から返り値の型をいい感じに推論できるようになるのがベストだと思っているのですが、TS力が足りず妥協したコードを使っていたりします。型安全に書ける方法を知っている方がいましたらコメントで教えて頂けるととても喜びます。

const eventPromisify = (eventTarget: EventTarget, eventName: string): Promise<any> => {
  return new Promise((resolve, reject) => {
    eventTarget.addEventListener(eventName, (...args) => resolve(...args));
  });
};
GitHubで編集を提案

Discussion