TL

ブラウザイベントの伝播モデル(キャプチャと委譲)

なぜ親要素1つのリスナで子の動きを全部拾えるのか、その原理がはっきり分かる。キャプチャ・ターゲット・バブリングの3フェーズと委譲・passiveの効きどころを内部から解説します。

応用JavaScriptDOMイベントブラウザパフォーマンス最終更新: 2026-06-21
TL;DR要点だけ先に
  • 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 をクリックすると ❶ documentdiv のキャプチャリスナ、❷ button 自身のリスナ、❸ divdocument のバブリングリスナ、という順で呼ばれます。上から降りて、的に当たり、また上へ昇る――この往復が1回のディスパッチの全体像です。

capture を指定したリスナだけがキャプチャ相で動く

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 が要素を作り直す環境でも、親リスナは生き続けるため相性が良いのです。

バブリングしないイベントは委譲できない

すべてのイベントが昇ってくるわけではありません。focusblurmouseentermouseleave、多くの load/errorbubbles が false で、親では拾えません。委譲したいときは ❶ バブリングする代替(focusin/focusoutmouseover/mouseout)を使う、❷ あるいはキャプチャ相で親に登録する({ capture: true } なら降りる経路で必ず親を通る)、のいずれかを取ります。「親で focus を委譲したい」が動かない事故の大半はこれです。

合成イベント(フレームワークの SyntheticEvent)

React などのフレームワークが渡してくる event は、ブラウザのネイティブイベントそのものではなく、それを包んだ**合成イベント(SyntheticEvent)**です。狙いは、ブラウザ差を吸収した統一インターフェースを提供することと、委譲を内部で活用して性能を上げることにあります。

歴史的に React は、各 onClick を実 DOM の各要素に付けるのではなく、ルートコンテナ1か所にネイティブリスナをまとめて登録し、そこへ届いたイベントを自前の経路に沿って各コンポーネントへ配送していました(実体は大規模なイベント委譲)。onClick は実際には document/ルートでの委譲として処理される、という内部構造です。

“合成”でも伝播の意味論は素の DOM に揃えてある

SyntheticEvent でも stopPropagation()preventDefault()targetcurrentTarget は素の DOM と同じ意味で使えます。ただしネイティブの伝播とフレームワークの委譲は別レイヤです。素の addEventListenerstopPropagation しても、ルートで委譲しているフレームワーク側のハンドラがそれより手前で動くか後で動くかは、登録位置とフェーズに依存します。両方を混在させるときは、どちらの層で止めているのかを意識してください。

passive リスナとスクロール性能

touchstarttouchmovewheel のリスナには、スクロール性能に直結する落とし穴があります。これらのリスナは event.preventDefault()スクロール自体をキャンセルできるため、ブラウザは「このリスナが preventDefault するかもしれない」と身構え、リスナの実行が終わるまでスクロールを開始できません。リスナが重ければ、その分スクロールが指に追従せずカクつきます。

そこで { passive: true } です。これは「このリスナは preventDefault を呼ばない」とブラウザに約束する宣言で、ブラウザはリスナの完了を待たずに即座にスクロールを始められます。スクロールは別スレッド(コンポジタ)で進み、ハンドラはそれと並行に走ります。

登録方法preventDefaultスクロール開始向く用途
passive: true無視される(呼ぶと警告)リスナを待たず即時解析・ロギングなど描画を妨げない処理
passive: false(明示)有効リスナ完了を待つプルリフレッシュ等スクロールを抑止したい場合
既定値の差に注意:ドキュメントレベルは passive 既定

性能対策として、主要ブラウザは windowdocumentdocument.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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

JavaScriptDOMイベントブラウザパフォーマンスJavaScriptDOMイベント
参考: 公式情報