【JavaScript 3D 入門講座】 イベントデリゲーションって何?動的要素に効率よくイベントを付ける方法

はじめに

JavaScriptでゲーム制作をはじめた初期から、当たり前のように扱ってきた「イベントデリゲーション」。

早朝 6時から開発を続けて疲れ果て、合間で読み返す学習ネタ記事が欲しいとAIに懇願した中で、目に留まったこのネタについて、改めて再学習しようと思い、「初心忘るべからず」の精神で記事にしてみました。

1. イベントが効かない!?あるあるシチュエーション

WebアプリやゲームのUIを作っていると、こんな現象に出くわしたことはありませんか?

🚫 「新しく追加したボタンやカードをクリックしても、反応しない…?」

たとえば、こんなコードをよく書きますよね。

document.querySelectorAll(".card").forEach(card => {
  card.addEventListener("click", () => {
    console.log("カードがクリックされました!");
  });
});

これはページ読み込み時点に存在している .card 要素すべてにクリックイベントを付与するコードです。

❗ でも問題はここから

もしあとから、JavaScriptで .card 要素を追加したらどうなるでしょう?

const newCard = document.createElement("div");
newCard.className = "card";
newCard.textContent = "新しいカード";
document.querySelector("#card-container").appendChild(newCard);

これで画面上には確かに「新しいカード」が追加されますが……

👉 クリックしても反応しない!なぜ!?

🧩 原因:イベントリスナーが“その時点の要素”にしか効いていない

  • querySelectorAll() は静的なリストを返します
  • その .forEach() で付けたイベントリスナーは、「初期に存在していた要素」にしか反応しません

🧠 解決策は「親で受け取ること」

この問題を解決するのが、次章で紹介する「イベントデリゲーション(委譲)」というテクニックです。

新しく追加された子要素でも、親にイベントを付けておけば拾えるという仕組みです。

2. イベントデリゲーションの仕組みと原理

🧠 そもそも「イベント」はどこで発生して、どう伝わるの?

JavaScriptでは、クリックや入力などのイベントは以下のような流れで伝わっていきます:

👶 子要素で発生 → 親要素 → さらに上の祖先へと伝播(バブリング)する

この仕組みを利用すると、「直接その要素にイベントを付けなくても、親が代理で処理できる」ようになるんです。

これがまさに イベントデリゲーション(Event Delegation)。

🧭 イメージで理解

<div id="card-container">
  <div class="card">カード1</div>
  <div class="card">カード2</div>
  <!-- あとで追加されるカード -->
</div>

こで、子要素 .card に対して毎回 .addEventListener() を書く代わりに…

document.querySelector("#card-container").addEventListener("click", (e) => {
  // クリックされた要素が.cardかどうかをチェック
  if (e.target.matches(".card")) {
    console.log("カードがクリックされました!", e.target.textContent);
  }
});

✅ これがイベントデリゲーション!

特徴 内容
📌 親要素にイベントをつける .card を内包する #card-container にイベントを設定
🔍 クリック元を判定する e.target.matches(".card") を使って、実際にクリックされた要素が .card かを確認
新しく追加された要素にも対応 親は変わらないので、あとから追加された .card にもイベントが効く

🧑‍💻 なぜこの手法が有効か?

  • イベントを1か所に集中管理できる(保守性◎)
  • DOMが動的に変わってもイベントが消えない
  • .forEach() や .addEventListener() を無限に繰り返さずに済む

3.親でイベントを受けて処理する基本コード

💡 前提のHTML構造(例)

<div id="card-container">
  <div class="card" data-id="1">カード1</div>
  <div class="card" data-id="2">カード2</div>
  <!-- ここに動的にカードが追加される -->
</div>

🧑‍💻 JavaScriptの基本コード(イベントデリゲーション)

document.querySelector("#card-container").addEventListener("click", (e) => {
  if (e.target.matches(".card")) {
    const cardId = e.target.dataset.id;
    console.log("クリックされたカードのID:", cardId);
  }
});

🧩 解説

処理 説明
#card-container にイベントを設定 親がすべての .card を監視するイメージ
e.target はクリックされた実際の要素 直接クリックされた .card 要素を取得
.matches(".card") で絞り込み 他の要素(たとえば親の余白など)を誤って処理しないように
dataset.id を使って個別に識別 HTML上の data-id を利用することで柔軟に管理できる

💬 .closest() を使うパターン(より安全)

const card = e.target.closest(".card");
if (card) {
  console.log("カードをクリック:", card.dataset.id);
}
  • .closest() は、クリックされた要素の親をさかのぼって探す
  • .card の中にボタンや画像が入ってる場合でも対応可能

✅ 応用の第一歩:

card.classList.toggle("selected"); // 選択状態の切り替え
card.remove(); // カードを削除
playCardSound(card.dataset.id); // カードIDに応じた効果音を再生

4.実用例:カード選択や削除処理への応用

あなたが今取り組んでいるようなカードビューアやアセット管理UI、あるいはカードバトル系ゲームの盤面UIなどでは、ユーザーがクリックしたカードに対して様々な操作をしたいケースがよくあります。

例えば:

  • 🔘 カードをクリック → 選択状態を切り替える
  • 🗑️ カードをゴミ箱にドラッグ → 削除する
  • 🎵 カードごとに音やアニメーションを再生する
  • 🔄 カードを動的に追加/削除できる

🧑‍💻 例1:クリックでカードを選択(selectedクラス切り替え)

document.querySelector("#card-container").addEventListener("click", (e) => {
  const card = e.target.closest(".card");
  if (card) {
    card.classList.toggle("selected");
  }
});

💡 ポイント:

  • classList.toggle(“selected”) で選択状態を切り替え
  • CSSで .selected に視覚効果を付ければ、選択状態を一目でわかるようにできる

🎨 補足:選択状態のCSS例

.card.selected {
  outline: 3px solid #4caf50;
  background-color: #e8f5e9;
}

🧑‍💻 例2:カードの削除処理(確認付き)

document.querySelector("#card-container").addEventListener("click", async (e) => {
  const card = e.target.closest(".card");
  if (!card) return;

  const id = card.dataset.id;
  const confirmed = await showDialog(`カードID ${id} を削除しますか?`, {
    title: "カード削除",
    type: "warn"
  });

  if (confirmed) {
    card.remove();
    console.log(`カード ${id} を削除しました`);
  }
});

💡 このパターンで使える応用例:

  • カードのデータもIndexedDBから削除する
  • 削除後にスロットを埋め直す
  • undo 機能と組み合わせることで取り消し可能にする

✅ 実践ポイントまとめ

要素 効果
closest(".card") 安全にクリック対象を特定できる
classList.toggle() 状態の可視化に便利
dataset.id カード固有情報の識別に最適
showDialog() 安心感のあるユーザーインタラクション
remove() UIから要素を削除し、状態を即反映

5. イベントデリゲーションの注意点とよくある落とし穴

⚠️ イベントデリゲーションは強力ですが、万能ではありません。

使う際にはいくつかの注意点があります。

❌ バブリングしないイベントには使えない

一部のイベントは「バブリング(親へ伝播)」しません。

つまり、親要素で拾うことができません。

イベント名 状態 補足
click ✅ OK 最もよく使うパターン
input ✅ OK テキスト入力などに対応
change ✅ OK セレクトボックスなどにも
focus / blur ❌ NG バブリングしない(useCaptureなど特殊処理が必要)
mouseenter / mouseleave ❌ NG mouseover / mouseout はバブリング可

🧠 解決策の一例(focusなどが必要な場合)

バブリングしないイベントにどうしても対応したい場合は:

  • useCapture を有効にしてキャプチャフェーズで拾う(複雑)
  • または要素生成時に個別にイベントを付ける(部分的な妥協)

⚠️ .target と .currentTarget の違いに注意!

parent.addEventListener("click", (e) => {
  console.log("e.target:", e.target);         // 実際にクリックされた要素
  console.log("e.currentTarget:", e.currentTarget); // イベントを設定した親要素
});
  • e.target → 子要素(.card など)
  • e.currentTarget → 親要素(#card-container)

判断ミスの原因になることがあるので、用途を区別して使いましょう。

⚠️ 親に付けすぎ注意:イベントの「範囲」は広がる

イベントデリゲーションは便利ですが、1つの親要素に複雑なロジックを詰めすぎると保守性が下がります。

対策例:

  • 要素に data-action 属性を使って処理を分岐する
  • モジュール化して関数に分ける
  • 必要なら親を分割してイベントも分ける

💡 よくあるパターン別アドバイス

パターン ベストプラクティス
複数種類の子要素を処理したい e.target.closest(".xxx") で柔軟に対応
子要素にボタン・画像・テキストが混在 .matches() より .closest() が安全
削除・アニメーション・同期処理など複雑な処理 async/await との組み合わせで制御を明確に

✅ 結論:イベントデリゲーションは強力な武器!

  • 動的UIに強く、コードを簡潔に保てる
  • ただし、イベント伝播や要素構造をよく理解していないと予期しない動作になることもある
  • 小さく試しながら取り入れていくのがおすすめ!