イベント委譲とは何か
イベント委譲は、「たくさんの子要素それぞれにリスナーを付けず、親に1つだけ付けて子の操作に反応する」設計です。イベントがバブル(内側→外側)で伝わる仕組みを利用し、親のリスナー内で e.target と closest を使って“誰が操作されたか”を判定します。ここが重要です:委譲にすると、動的に追加された要素にも自動で対応でき、リスナーの数が増えず、性能と保守性が大幅に向上します。
なぜ委譲を使うのか(性能・動的要素・保守性)
規模に強い理由
- 少ないリスナー: 親1個で済むため、メモリ消費・登録コストが小さい。
- 動的追加に自動対応: 後から追加した子も、バブルで親に届くためそのまま動く。
- 保守性の高さ: 付け外しの管理が“一箇所”になり、二重登録や外し忘れが減る。
典型の悪い例と改善
- 悪い例: 1000件の行それぞれに click を付ける。スクロールや再描画で重くなる。
- 改善: UL に click を1つだけ付け、e.target.closest(‘button.delete’) で削除ボタンを判定して処理。
ここが重要です:イベントの数ではなく“伝播の仕組み”を使って集約する。これが委譲の本質です。
基本の書き方(closest と contains の型)
親で子の操作を選別する
<ul id="list"></ul>
<script>
const list = document.getElementById("list");
list.addEventListener("click", (e) => {
const del = e.target.closest(".delete");
if (del && list.contains(del)) {
del.closest("li")?.remove();
return;
}
const edit = e.target.closest(".edit");
if (edit && list.contains(edit)) {
console.log("編集開始");
return;
}
// それ以外のクリックは何もしない
});
// 動的追加でも動く
const li = document.createElement("li");
li.innerHTML = `りんご <button class="delete">削除</button> <button class="edit">編集</button>`;
list.appendChild(li);
</script>
HTMLここが重要です:判定は target+closest、範囲チェックに currentTarget.contains(…)。この二つで“意図した塊だけ”を安全に扱えます。
よく使う補助
- target と currentTarget:
基準: 判定は e.target(発生源)、範囲の安全確認は e.currentTarget.contains(…)(親が所有)。 - stopPropagation を極力使わない:
理由: 親で選別すれば不要。遮断は“どうしても衝突する箇所のみ”に限定する。
UI別の委譲パターン(リスト、タブ、メニュー)
リスト行の削除・編集
<ul id="items"></ul>
<script>
items.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn || !items.contains(btn)) return;
const row = btn.closest("li");
if (btn.classList.contains("delete")) row?.remove();
else if (btn.classList.contains("edit")) console.log("編集:", row?.textContent);
});
</script>
HTMLここが重要です:ボタン種別は class で分岐。closest で“ボタン → 行”へ辿るのが安定の型です。
タブ切り替え
<div id="tabs">
<button data-tab="a">A</button>
<button data-tab="b">B</button>
</div>
<div id="panel-a">A パネル</div>
<div id="panel-b" hidden>B パネル</div>
<script>
tabs.addEventListener("click", (e) => {
const btn = e.target.closest("#tabs [data-tab]");
if (!btn) return;
const tab = btn.dataset.tab;
document.querySelector("#panel-a").hidden = tab !== "a";
document.querySelector("#panel-b").hidden = tab !== "b";
});
</script>
HTMLここが重要です:親1箇所で“選択されたタブ”を判断し、表示の同期をまとめる。タブ追加にも強い。
メニューの外クリックで閉じる(委譲+範囲判定)
<div id="menu" class="open">…</div>
<script>
document.addEventListener("click", (e) => {
if (!menu.classList.contains("open")) return;
if (!menu.contains(e.target)) menu.classList.remove("open");
});
// メニュー内クリックは普通に動かす(原則 stopPropagation なしで設計)
</script>
HTMLここが重要です:contains で“外側クリック”だけを拾う。先に範囲判定できるなら stopPropagation は不要です。
高度なヒント(composedPath・キャプチャ・アクセシビリティ)
composedPath で経路を確認(シャドウDOM対策)
document.addEventListener("click", (e) => {
const path = e.composedPath(); // 実際に通過した要素配列
// path を使って、影響範囲や想定外のターゲットをデバッグ
});
JavaScriptここが重要です:SVG・シャドウDOM・疑似要素で target が想定外になる場面の診断に有効。
どうしても先に処理したいときはキャプチャ
document.addEventListener("click", (e) => {
// 監査やグローバル優先処理
}, { capture: true });
JavaScriptここが重要です:通常の UI はバブルで受け、監査・ロギングなど“先取りしたい”例外だけキャプチャ。強い手段は例外的に。
アクセシビリティを優先する構造
- ボタンは <button>:
理由: Enter/Space の既定動作が生き、委譲と両立しやすい。 - フォーカス設計:
指針: キーボード操作も委譲のロジックで同じ分岐に乗せる(keydown で data-* を使った同等判定など)。
落とし穴と回避策(乱用を避け、デバッグしやすく)
乱用する stopPropagation で親の正しい処理が死ぬ
- 対策: 親で“選別”する委譲を基本にし、衝突箇所だけに限定して遮断。
セレクタの曖昧さで誤判定
- 対策: closest に“明確なセレクタ”を渡す。範囲は currentTarget.contains で保証する。
二重登録・外し忘れ
- 対策: 親1箇所に集約。ページのマウント/アンマウントで付け外しをまとめる(AbortController で一括解除も可)。
重い処理を逐次で実行
- 対策: ライブ更新は軽く、重い処理はデバウンスや確定タイミング(change/submit)へ寄せる。
まとめ
イベント委譲は、親1箇所にリスナーを置いて“子の操作を選別”する設計です。判定は e.target+closest、範囲保証に currentTarget.contains を使い、stopPropagation は原則不要(衝突箇所だけに限定)。通常はバブルで受け、例外のみキャプチャ。動的追加に強く、リスナーを減らせるため性能・保守性が高い。composedPath で複雑な経路も診断し、アクセシブルな要素選びで“止めなくてよい”UIを作る。これを徹底すれば、初心者でも規模に強く、読みやすく壊れにくいイベント処理を実装できます。
