こんにちは、かつコーチです。
前回は addEventListener の基本的な使い方を解説しました。
今回は一歩進んで、「イベントがどう伝わっていくか」という仕組みを扱います。
このバブリングとキャプチャリングを理解していないと、「なぜかクリックしていない要素の処理まで動いてしまう」といった不具合の原因が分からず、ずっと悩み続けることになります。
少し難しく感じるテーマですが、図をイメージしながら順番に読み進めてみてください。
イベント伝播とは?
3つのフェーズがある
DOM上で何かイベントが発生すると、そのイベントは3つのフェーズを経て処理されます。
- キャプチャリングフェーズ:一番外側の要素から、イベントが発生した要素に向かって伝わっていく
- ターゲットフェーズ:実際にイベントが発生した要素に到達する
- バブリングフェーズ:イベントが発生した要素から、一番外側の要素に向かって伝わっていく
このうち、デフォルトの挙動として意識する機会が多いのは3のバブリングです。
なぜこの仕組みが必要なのか
「なぜわざわざ親要素にまでイベントが伝わる必要があるのか」と疑問に思うかもしれません。
理由は、この仕組みを使うことで「親要素1つにイベントを設定するだけで、子要素すべての操作を検知できる」というメリットがあるからです。
これをイベント委譲(イベントデリゲーション)と呼びます。
たとえば、リストに100個の項目があったとして、1つ1つの項目に個別にイベントを設定するのは非効率です。
親要素に1つイベントを設定しておけば、どの子要素がクリックされてもそのイベントを検知できます。
バブリングの基本
基本の書き方と挙動
親要素と子要素の両方に click イベントを設定して、挙動を確認してみましょう。
const parent = document.querySelector("#parent");
const child = document.querySelector("#child");
parent.addEventListener("click", () => {
console.log("親要素がクリックされました");
});
child.addEventListener("click", () => {
console.log("子要素がクリックされました");
});
このとき、子要素(#child)をクリックすると、コンソールには次の順番でログが表示されます。
子要素がクリックされました
親要素がクリックされました
子要素で発生したクリックイベントが、そのまま親要素にも伝わっている(バブリングしている)ことが分かります。
イベント委譲で子要素の処理をまとめる
バブリングの性質を利用すると、子要素が動的に増える場合でも柔軟に対応できます。
const list = document.querySelector("#itemList");
list.addEventListener("click", (event) => {
if (event.target.tagName === "LI") {
console.log(`クリックされた項目: ${event.target.textContent}`);
}
});
このコードでは、#itemList の中に <li> 要素が後から何個追加されても、親要素にイベントを1つ設定しておくだけで対応できます。
event.target で実際にクリックされた要素を判定しているのがポイントです。
つまずきやすいポイント:意図しない伝播
かつコーチが実際につまずいた話
以前、モーダルウィンドウを実装していたときのことです。
「モーダルの外側をクリックしたら閉じる」という仕様で、次のようなコードを書きました。
const overlay = document.querySelector("#overlay");
const modal = document.querySelector("#modal");
overlay.addEventListener("click", () => {
overlay.classList.add("hidden");
});
#overlay が画面全体を覆う背景で、その中央に #modal が配置されている構造です。
ところが実際に動かすと、モーダルの中身をクリックしただけで、モーダルごと閉じてしまう不具合が発生しました。
原因は、#modal が #overlay の子要素だったため、モーダル内のクリックイベントがバブリングで #overlay にまで伝わっていたことでした。
❌ Before:バブリングを止めずに親要素までイベントが伝わる
const overlay = document.querySelector("#overlay");
overlay.addEventListener("click", () => {
overlay.classList.add("hidden");
// モーダル内をクリックしても、ここまでイベントが伝わってきてしまう
});
✅ After:stopPropagationでモーダル内のクリックを止める
const overlay = document.querySelector("#overlay");
const modal = document.querySelector("#modal");
overlay.addEventListener("click", () => {
overlay.classList.add("hidden");
});
modal.addEventListener("click", (event) => {
event.stopPropagation();
// ここでバブリングを止めるので、overlayのクリック処理まで伝わらない
});
event.stopPropagation() を呼び出すと、それ以上イベントが親要素へ伝わらなくなります。
モーダル側のクリックイベントで伝播を止めることで、「モーダルの外側をクリックしたときだけ閉じる」という本来の仕様どおりに動くようになりました。
このバグに気づくまで、console.log をあちこちに仕込んで、イベントがどこで発生しているのかを1つずつ追いかける羽目になりました。
伝播の仕組みを先に理解していれば、もっと早く原因にたどり着けたはずです。
キャプチャリングを使う場面
第3引数でキャプチャリングを有効にする
addEventListener の第3引数に true を渡すと、バブリングではなくキャプチャリングのタイミングでイベントを実行できます。
const parent = document.querySelector("#parent");
parent.addEventListener(
"click",
() => {
console.log("キャプチャリングフェーズで実行されます");
},
true
);
通常の第3引数を省略した場合(またはfalseを指定した場合)は、バブリングフェーズで実行されます。
使い分けの判断軸
実務でキャプチャリングを明示的に使う場面は、正直そこまで多くありません。
多くのケースでは、デフォルトのバブリングとイベント委譲の組み合わせで十分対応できます。
キャプチャリングが役立つのは、「子要素側の処理より先に、親要素側で何かを検知・制御したい」という特殊なケースです。
たとえば、特定の操作を親要素で先にキャッチして、子要素の処理を止めたいような場合が該当します。
判断に迷ったら、まずはバブリング(デフォルト)で実装し、意図しない挙動が出たときに stopPropagation やキャプチャリングを検討する、という順番がおすすめです。
まとめ
この記事のポイント
- イベントは「キャプチャリング → ターゲット → バブリング」の3フェーズで伝わる
- デフォルトではバブリングで、子要素のイベントが親要素にも伝わっていく
- バブリングを利用したイベント委譲で、子要素のイベント処理を1箇所にまとめられる
- 意図しない伝播は
event.stopPropagation()で止められる - キャプチャリングは
addEventListenerの第3引数にtrueを渡すと使える
次に読むべき記事
伝播の仕組みが分かったところで、次はフォームの入力値を取得する方法を見ていきましょう。
→ 次の記事:フォームの入力値を取得する方法