🫥

イベントをPromise化する

2025/03/04に公開2

はじめに

アニメーションの制御でフェードイン&フェードアウトを任意の時間で実装しようとしたときにコールバック地獄になりそうだったので、それを回避したい。

コード

const onceEvent = async (element, event, options = {}) => {
  const { promise, resolve, reject } = Promise.withResolvers();
  element.addEventListener(event, resolve, { ...options, once: true });
  return promise;
};

簡単な使用例

(async () => {
    let mouseEvent = await onceEvent(document.body, 'click');
    alert('1回クリックされました。');
})();

より具体的な使用例

123と書かれている要素を追加する。各要素は1, 5, 10秒後にフェードアウトする。
ここで重要な点は、インデントが浅い点とフェードアウトを開始するまでの時間を関数から指定できること。

sample.html
<body />
sample.css
.box {
  opacity: 0;
  width: 30px;
  height: 30px;
  background-color: green;
}

.fadein {
  animation-name: fadeIn;
  animation-fill-mode: forwards;
  animation-duration: 1s;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.fadeout {
  animation-name: fadeOut;
  animation-fill-mode: forwards;
  animation-duration: 1s;
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
sample.js
// 〇ms停止
const wait = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const onceEvent = async (element, event, options = {}) => {
  const { promise, resolve, reject } = Promise.withResolvers();
  element.addEventListener(event, resolve, { ...options, once: true });
  return promise;
};

// elementに適応されているいずれかのアニメーションがキャンセル・終了するまで待機する
const waitAnimation = async (element) => {
  const controller = new AbortController();
  const events = [
    onceEvent(element, "animationend", { signal: controller.signal }),
    onceEvent(element, "animationcancel", { signal: controller.signal })
  ];
  const resolvedEvent = await Promise.any(events);
  controller.abort();
  return resolvedEvent;
};

(() => {
  const appendShrinkDiv = async (ms) => {
    const div = document.createElement("div");
    div.innerText = "123";
    div.classList.add("box");
    document.body.appendChild(div);

    let event;
    event = waitAnimation(div);
    div.classList.add("fadein");
    await event;

    await wait(ms);
    div.classList.remove("fadein");

    event = waitAnimation(div);
    div.classList.add("fadeout");
    await event;

    div.remove();
  };

  appendShrinkDiv(1000);
  appendShrinkDiv(5000);
  appendShrinkDiv(10000);
})();

コールバック地獄の例

(animationcancelに対応してないことなど簡略化している)

(() => {
  const appendShrinkDiv = async (ms) => {
    const div = document.createElement("div");
    div.innerText = "123";
    div.classList.add("box");
    document.body.appendChild(div);
    div.addEventListener("animationend", async () => {
        await wait(ms);
        div.classList.remove("fadein");
        div.addEventListener("animationend", () => {
            div.remove();
        }, { once: true });
        div.classList.add("fadeout");
    }, { once: true });
    div.classList.add("fadein");
  };

  appendShrinkDiv(1000);
  appendShrinkDiv(5000);
  appendShrinkDiv(10000);
})();

更新履歴

2025/3/4 signal, animationcancelに関して修正 + ちょっと見た目変えた

Discussion

junerjuner

引数の options に signal を許容して やった方がいいのでは……?みたいなところあります。
animation イベントは 最後は animationend もしくは animationcancel が動くので

参考:
https://codepen.io/juner/pen/ZENezxL
※ただ、この参考ページの signal は class を除去するのに使っているので signal は入れずに自己解放しています。入れるなら多段 signal しないとならない

トーテムトーテム

animationcancelイベントとsignalなんてものがあったんですね初知りです。
せっかくなんで他のオプションも許容してみました。(影響は知らないですが)
更新しました。