ブラウザイベントの伝播モデル(キャプチャと委譲)
なぜ親要素1つのリスナで子の動きを全部拾えるのか、その原理がはっきり分かる。キャプチャ・ターゲット・バブリングの3フェーズと委譲・passiveの効きどころを内部から解説します。
- 1.ディスパッチは「キャプチャ(document→target)→ ターゲット → バブリング(target→document)」の3フェーズで進む。addEventListener の第3引数で capture を指定したリスナだけがキャプチャ相で呼ばれ、既定は false なのでバブリング相で呼ばれる。
- 2.イベント委譲は、子で発生したイベントがバブリングで親へ届く性質を使う。親1つのリスナで event.target を見て分岐すれば、動的に増減する子へリスナを付け直す必要がない。
- 3.passive: true は「このリスナは preventDefault しない」とブラウザに約束する宣言。touchstart や wheel でスクロールをブロックせず即スクロールできるため、スクロール性能が大きく改善する。
イベントは「点」ではなく「経路」を通る
クリックは、押された要素1つで完結する出来事ではありません。DOM 仕様では、イベントは ツリーの経路(propagation path)を伝わるものとして定義されます。<button> を <div> が囲み、それを <body>、document が囲んでいるとき、クリックは内側の button だけでなく、その祖先すべてに順番に通知されます。
この経路を流れる向きと順序が 伝播モデルです。順序が仕様で一意に決まっているからこそ、「親1つのリスナで子の操作を全部拾う」イベント委譲が成立します。基礎となるツリー構造は DOM を前提に、ここでは伝播の内部アルゴリズムを解きほぐします。
ディスパッチの3フェーズ
element.dispatchEvent()(クリック等の内部的な発火も同じ)が呼ばれると、ブラウザはまずターゲットから document(正確には Window)までの祖先列を確定し、これを伝播経路とします。そのうえで、イベントは次の3フェーズをこの順序で通過します。
| フェーズ | eventPhase | 進む向き | ここで呼ばれるリスナ |
|---|---|---|---|
| キャプチャリング | 1 (CAPTURING_PHASE) | 祖先 → ターゲット(上から下) | capture: true で登録したリスナ |
| ターゲット | 2 (AT_TARGET) | ターゲット本体 | ターゲットに付いた両方(capture/非capture) |
| バブリング | 3 (BUBBLING_PHASE) | ターゲット → 祖先(下から上) | capture: false(既定)で登録したリスナ |
つまり経路が document > div > button のとき、button をクリックすると ❶ document → div のキャプチャリスナ、❷ button 自身のリスナ、❸ div → document のバブリングリスナ、という順で呼ばれます。上から降りて、的に当たり、また上へ昇る――この往復が1回のディスパッチの全体像です。
addEventListener の第3引数は既定で false、すなわちバブリング相で呼ぶ設定です。キャプチャ相で受けたいときだけ el.addEventListener('click', fn, true) あるいは { capture: true } を渡します。同じ要素・同じ種類でも capture の真偽が違えば別リスナとして両方登録でき、それぞれ対応するフェーズで呼ばれます。
// 経路: document > div#box > button#btn
document.addEventListener('click', () => console.log('1: doc capture'), true);
box.addEventListener('click', () => console.log('2: div capture'), true);
btn.addEventListener('click', () => console.log('3: btn target'));
box.addEventListener('click', () => console.log('4: div bubble'));
document.addEventListener('click', () => console.log('5: doc bubble'));
// btn クリック時の出力: 1 → 2 → 3 → 4 → 5
target と currentTarget を取り違えない
伝播を理解する鍵は、event が持つ2つの「対象」を区別することです。これを混同すると委譲のコードが必ず壊れます。
event.target: イベントが実際に発生した最も内側の要素。経路の途中でリスナが呼ばれても、target は最初から最後まで変わりません。event.currentTarget: 今まさにリスナが付いている要素(=addEventListenerを呼んだ要素)。フェーズが進み別の祖先のリスナが動くたびに切り替わります。thisも通常これと一致します。
box.addEventListener('click', (e) => {
// box の内側にある span をクリックしたとき
e.target; // → <span>(実際に押された要素)
e.currentTarget; // → <div id="box">(リスナが付いている要素)
});
composedPath() を呼べば、その時点の伝播経路(ターゲットから上方向の要素列)を配列で取得できます。Shadow DOM をまたぐ場合の実際の経路確認にも使えます。
イベント委譲が成立する原理
子要素が増減する UI(リスト、テーブル、動的に挿入されるカード)で、子1つずつにリスナを付けるのは非効率です。バブリングを使えば、共通の祖先1つで全部受けられる――これがイベント委譲(event delegation)です。
// li が何個に増減しても、親の ul に付けたリスナ1つで処理できる
list.addEventListener('click', (e) => {
const li = e.target.closest('li'); // 押された要素から最も近い li を辿る
if (!li || !list.contains(li)) return; // list 配下の li でなければ無視
li.classList.toggle('done');
});
成立条件は3つに整理できます。第一に、対象イベントがバブリングすること(後述)。第二に、event.target でどの子で起きたかを判定できること。第三に、closest() などで意図した子の範囲に正規化できること。e.target は最深部の要素(例: li の中の <span>)になり得るため、closest('li') で「論理的な対象」へ引き上げるのが定石です。
リスナがツリー全体で1個になるので、要素数に比例してリスナが増えません(メモリと登録コストの削減)。さらに、後から appendChild で挿入した子にもリスナを付け直す必要がない――バブリングで自動的に親へ届くからです。仮想 DOM が要素を作り直す環境でも、親リスナは生き続けるため相性が良いのです。
すべてのイベントが昇ってくるわけではありません。focus・blur・mouseenter・mouseleave、多くの load/error は bubbles が false で、親では拾えません。委譲したいときは ❶ バブリングする代替(focusin/focusout、mouseover/mouseout)を使う、❷ あるいはキャプチャ相で親に登録する({ capture: true } なら降りる経路で必ず親を通る)、のいずれかを取ります。「親で focus を委譲したい」が動かない事故の大半はこれです。
合成イベント(フレームワークの SyntheticEvent)
React などのフレームワークが渡してくる event は、ブラウザのネイティブイベントそのものではなく、それを包んだ**合成イベント(SyntheticEvent)**です。狙いは、ブラウザ差を吸収した統一インターフェースを提供することと、委譲を内部で活用して性能を上げることにあります。
歴史的に React は、各 onClick を実 DOM の各要素に付けるのではなく、ルートコンテナ1か所にネイティブリスナをまとめて登録し、そこへ届いたイベントを自前の経路に沿って各コンポーネントへ配送していました(実体は大規模なイベント委譲)。onClick は実際には document/ルートでの委譲として処理される、という内部構造です。
SyntheticEvent でも stopPropagation()・preventDefault()・target・currentTarget は素の DOM と同じ意味で使えます。ただしネイティブの伝播とフレームワークの委譲は別レイヤです。素の addEventListener で stopPropagation しても、ルートで委譲しているフレームワーク側のハンドラがそれより手前で動くか後で動くかは、登録位置とフェーズに依存します。両方を混在させるときは、どちらの層で止めているのかを意識してください。
passive リスナとスクロール性能
touchstart・touchmove・wheel のリスナには、スクロール性能に直結する落とし穴があります。これらのリスナは event.preventDefault() でスクロール自体をキャンセルできるため、ブラウザは「このリスナが preventDefault するかもしれない」と身構え、リスナの実行が終わるまでスクロールを開始できません。リスナが重ければ、その分スクロールが指に追従せずカクつきます。
そこで { passive: true } です。これは「このリスナは preventDefault を呼ばない」とブラウザに約束する宣言で、ブラウザはリスナの完了を待たずに即座にスクロールを始められます。スクロールは別スレッド(コンポジタ)で進み、ハンドラはそれと並行に走ります。
| 登録方法 | preventDefault | スクロール開始 | 向く用途 |
|---|---|---|---|
| passive: true | 無視される(呼ぶと警告) | リスナを待たず即時 | 解析・ロギングなど描画を妨げない処理 |
| passive: false(明示) | 有効 | リスナ完了を待つ | プルリフレッシュ等スクロールを抑止したい場合 |
性能対策として、主要ブラウザは window・document・document.body に登録した touchstart/touchmove/wheel を 既定で passive: true にしています。このスコープでは preventDefault() が効かずスクロールを止められません。スクロールを抑止する必要があるなら { passive: false } を明示してください。逆に preventDefault が不要なら、対象要素のリスナにも passive: true を明示するのが推奨です。スクロール起因のカクつき対策は Core Web Vitals の内部 の INP/応答性とも直結します。
伝播を止める:3つの手段の違い
経路の途中で伝播やデフォルト動作を制御する API は紛らわしいので、効果を厳密に区別します。
| メソッド | 止めるもの | 同要素の残りリスナ | デフォルト動作 |
|---|---|---|---|
| preventDefault() | ブラウザ既定動作のみ(送信・遷移・スクロール等) | 通常どおり呼ばれる | 止まる |
| stopPropagation() | 次フェーズ・他要素への伝播 | 残りも呼ばれる | 止まらない |
| stopImmediatePropagation() | 伝播 + 同要素の残りリスナ | 呼ばれない | 止まらない |
preventDefault() は伝播を止めません――イベントは経路を進み続け、ただブラウザの既定動作(リンク遷移、フォーム送信、チェックボックスのトグル等)だけが抑止されます。stopPropagation() は逆に、既定動作はそのままで経路だけを断ち切ります。委譲は「親まで届くこと」が前提なので、子のリスナでうかつに stopPropagation() すると、親の委譲ハンドラに届かなくなり機能が壊れます。この相互作用は委譲設計で最も事故が多い箇所です。
押さえるべきは4点です。❶ 順序は「キャプチャ → ターゲット → バブリング」で、capture 指定リスナだけがキャプチャ相で動く。❷ target は発生源で不変、currentTarget はリスナが付いた要素で可変。❸ 委譲はバブリング前提で、focus/mouseenter など bubbles=false は委譲できない(focusin やキャプチャ相で代替)。❹ preventDefault は既定動作だけ、stopPropagation は伝播だけを止め、両者は独立。
まとめ
ブラウザのイベントは、ターゲットと祖先からなる経路を キャプチャ → ターゲット → バブリング の順で伝わります。capture 指定のリスナだけがキャプチャ相で、既定リスナはバブリング相で呼ばれ、target(発生源・不変)と currentTarget(リスナ位置・可変)を区別するのが要です。イベント委譲はバブリングを利用して親1つで子をまとめて処理する手法で、focus などバブリングしないイベントは focusin やキャプチャ相で代替します。合成イベントは委譲を内部活用した統一層、passive リスナは preventDefault しない約束でスクロールを即時化する性能策です。preventDefault(既定動作)と stopPropagation(伝播)の独立を取り違えないことが、壊れない委譲設計の前提になります。実行順序の土台は イベントループの内部構造、操作対象のツリーは DOM、言語側の基礎は JavaScript を参照してください。
Web/フロントエンド Article
ブラウザイベントの伝播モデル(キャプチャと委譲)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
イベント委譲は、子で発生したイベントがバブリングで親へ届く性質を使う。親1つのリスナで event.target を見て分岐すれば、動的に増減する子へリスナを付け直す必要がない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / DOM」に近いか確認する。
- 強みである「ディスパッチは「キャプチャ(document→target)→ ターゲット → バブリング(target→document)」の3フェーズで進む。addEventListener の第3引数で capture を指定したリスナだけがキャプチャ相で呼ばれ、既定は false なのでバブリング相で呼ばれる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。