イベントターゲットとリスナ登録の内部モデル
addEventListener が「効かない」「二重に呼ばれる」「消せない」謎が解ける。リスナリストの管理規則と capture/once/passive/signal の内部効果、重複判定の仕様を正確に解説します。
- 1.EventTarget は各オブジェクトに「リスナリスト」を持ち、登録は (type, callback, capture) の3項目で同一性を判定する。この組が既存と一致する追加は仕様上まるごと無視され、二重登録にはならない(once/passive/signal の違いは判定に含まれない)。
- 2.removeEventListener も (type, callback, capture) の一致でしか消せない。匿名関数や bind() で作った関数は毎回別物になるため参照が一致せず外せない。capture の真偽が違えば別リスナ扱いで消えない。
- 3.once は1回の呼び出し後に自動削除、passive は preventDefault を無効化してスクロールを即時化、signal は AbortSignal が abort された時点でリスナを一括除去する宣言的な解除手段。
EventTarget は「リスナの台帳」を持つ
addEventListener は「関数をどこかに渡しておく魔法」ではありません。DOM 仕様では、EventTarget を実装するすべてのオブジェクト(Element・document・Window・XMLHttpRequest・AbortSignal など)が、内部に イベントリスナリスト(event listener list) という台帳を持つ、と明確に定義されています。addEventListener はこの台帳に1行追記する操作、removeEventListener は1行削除する操作にすぎません。
この台帳の各エントリが何で構成され、どの項目で「同じリスナ」と判定されるかを知ると、現場で頻発する「同じ関数を2回登録したのに1回しか呼ばれない/逆に2回呼ばれる」「removeEventListener で消えない」「once を付けたのに残る」といった現象が、すべて仕様どおりの帰結として説明できます。伝播の経路そのものは ブラウザイベントの伝播モデル で扱うので、ここでは登録と管理の内部モデルに絞ります。
リスナエントリの構造と同一性の判定
台帳の1エントリは、概念的に次のフィールドを持ちます。
| フィールド | 意味 | 同一性判定に使う |
|---|---|---|
| type | イベント種別('click' など) | 使う |
| callback | 呼ばれる関数オブジェクトへの参照 | 使う |
| capture | キャプチャ相で呼ぶか(既定 false) | 使う |
| once | 1回呼んだら自動削除するか | 使わない |
| passive | preventDefault を無効化するか | 使わない |
| signal | 解除に使う AbortSignal | 使わない |
| removed | 削除済みフラグ(内部状態) | 使わない |
ここが核心です。同じリスナかどうかは (type, callback, capture) の3項目だけで決まり、once・passive・signal は判定に一切関与しません。仕様の add 手順は明示的にこう述べます。「listener list に、type が等しく、callback が等しく、capture が等しいエントリが既に存在するなら、何もしない(追加しない)」。つまり重複登録は黙って捨てられるのであって、エラーにもならず、2件にもなりません。
function onClick() { console.log('hi'); }
el.addEventListener('click', onClick); // 1件目: 追加される
el.addEventListener('click', onClick); // (type,callback,capture) 一致 → 無視
el.addEventListener('click', onClick, { once: true }); // once 違いでも判定に含めない → 無視
// 結果: リスナは1件だけ。click 1回につき onClick は1回呼ばれる
判定の3項目に capture が入っているため、capture: true と capture: false(既定)は別のエントリとして共存します。同じ関数・同じ type でも、キャプチャ相とバブリング相の両方に登録でき、1回のディスパッチで2回呼ばれます。なお { capture: true } という第3引数のオブジェクト形式と、true というブール形式は同じ capture を意味し等価です。「二重に呼ばれる」事故の多くは、片方をオブジェクト形式・片方をブール形式で書き、capture の値がズレているケースです。
匿名関数が消せない理由
removeEventListener も同じ (type, callback, capture) の一致でしか動きません。3項目が完全一致するエントリを台帳から探し、あれば削除、なければ何もしません(これもエラーになりません)。したがって「消すには、登録時とまったく同じ関数参照と同じ capture を渡す」のが絶対条件です。
// アンチパターン1: 匿名関数は参照を保持できない
el.addEventListener('click', () => doStuff());
el.removeEventListener('click', () => doStuff()); // 別の関数オブジェクト → 一致せず消えない
// アンチパターン2: bind() は毎回新しい関数を返す
el.addEventListener('click', handler.bind(this));
el.removeEventListener('click', handler.bind(this)); // 別物 → 消えない
// 正しいパターン: 参照を変数で固定する
const bound = handler.bind(this);
el.addEventListener('click', bound);
el.removeEventListener('click', bound); // 同一参照 → 消える
bind()・アロー関数・function リテラルは、評価のたびに新しい関数オブジェクトを生成します。これらは値として等しく見えても参照が異なるため、同一性判定(参照の同値比較)を通りません。クラスでハンドラを使うなら、コンストラクタで一度だけ this.onClick = this.onClick.bind(this) して参照を固定するのが定石です。
外せないリスナは、そのリスナが閉じ込めた変数(クロージャ)ごと DOM 要素を生かし続けます。要素を removeChild しても、document などに登録した委譲リスナがその要素を参照していれば回収されません。リスナ解除漏れは SPA で最も多いリーク原因の一つで、判定規則を理解していないと「消したつもり」で残り続けます。回収の前提は到達可能性で、ブラウザの GC 内部 の到達可能性の議論と直結します。
once/passive の内部効果
once: true は、リスナが1回呼ばれたら台帳から自動削除される指定です。仕様では、コールバックを呼ぶ手順の中で「once が真なら、コールバックを実行する直前にこのリスナを listener list から除去する」と定義されており、removeEventListener を自分で呼ぶのと同じ効果が自動で起きます(除去が実行の直前なので、ハンドラ内で同じイベントが再ディスパッチされても二重には呼ばれません)。注意点は、once でも一度も呼ばれないうちは台帳に残ること。一度も発火しないまま要素ごと破棄される場合、once は解除の役には立ちません。
passive: true は「このリスナは preventDefault() を呼ばない」というブラウザへの約束です。台帳エントリの passive フラグが真なら、ディスパッチ中にそのリスナ内で preventDefault() を呼んでも無視され(コンソール警告が出る)、ブラウザはリスナの完了を待たずにスクロール等の既定動作を進められます。touchstart・touchmove・wheel でスクロール性能に効くのはこのためです。once と同様、passive も同一性判定には関与しないので、passive 違いで重複登録し直すことはできません。
| オプション | 台帳での扱い | 効果が出る瞬間 | 同一性判定 |
|---|---|---|---|
| once | フラグとして保持 | コールバック実行の直前に自動除去 | 含めない |
| passive | フラグとして保持 | ディスパッチ中の preventDefault を無効化 | 含めない |
| signal | AbortSignal への参照を保持 | signal が abort された瞬間に除去 | 含めない |
| capture | フラグとして保持 | キャプチャ相で呼ぶ/削除キーの一部 | 含める |
signal による宣言的な一括解除
signal オプションは比較的新しく、AbortSignal を渡しておくと、その signal が abort された時点でリスナが自動的に外れる仕組みです。仕様の add 手順では、signal が渡されたとき、その signal に「abort されたらこのリスナを listener list から除去する」というアルゴリズムを登録します。これにより、複数のリスナを1つのコントローラでまとめて解除できます。
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', onResize, { signal });
window.addEventListener('scroll', onScroll, { signal });
document.addEventListener('keydown', onKey, { signal });
// 3つすべてを1行で解除(参照を1つずつ保持する必要がない)
controller.abort();
これは「removeEventListener を3回呼ぶ」と等価ですが、個々の関数参照を覚えておく必要がない点が決定的に楽です。コンポーネントのアンマウント時に controller.abort() を1回呼ぶだけで、そのコンポーネントが張った全リスナを確実に剥がせます。さらに fetch の中断にも同じ signal を使い回せるため、fetch のキャンセルとリスナ解除を1つのライフサイクルに束ねられます。同じ AbortSignal の仕組みは Fetch API の内部設計 でも中心的に使われています。
addEventListener に渡した signal がその時点で既に aborted だった場合、仕様上リスナは追加されません(即座に除去対象になるため、最初から登録されない)。古い AbortController を使い回すとリスナが「無言で付かない」ので、signal は fetch 同様ライフサイクルごとに作り直すのが安全です。なお AbortSignal 自体も EventTarget で、'abort' イベントを持ちます。リスナ管理の仕組みが解除の仕組みにそのまま再利用されている、という入れ子構造です。
ディスパッチ時のスナップショット
もう一つの重要な内部規則が、ディスパッチ開始時にリスナリストのコピー(スナップショット)を取ることです。あるイベントを配送している最中に、リスナの中で同じ type のリスナを新たに追加しても、その新リスナは今回のディスパッチでは呼ばれません。逆に、実行中に削除したリスナは、まだ呼ばれていなければ今回呼ばれなくなります(スナップショットを走査しつつ、各リスナ実行直前に「除去済みか」を確認するため)。
el.addEventListener('click', function a() {
// a の中で同じ click に b を足しても、今回のクリックでは b は呼ばれない
el.addEventListener('click', function b() { console.log('b'); });
});
// 次回のクリックからは a → b の順で両方呼ばれる
この規則があるおかげで、リスナ内でリスナを足し引きしても当回の走査が無限ループや不定挙動に陥らないことが保証されます。once の自動削除や signal による除去も、この「実行直前の除去確認」と整合して働きます。
押さえるべきは4点です。❶ リスナの同一性は (type, callback, capture) の3項目のみで決まり、once/passive/signal は判定に含まれないため、これらだけ変えた再登録は無視される。❷ removeEventListener は同一の関数参照と同一の capture が必須で、匿名関数・bind() の結果は毎回別物だから消せない。❸ once は呼び出し後に自動削除、passive は preventDefault を無効化、signal は abort で一括解除する。❹ ディスパッチはリストのスナップショットを走査するので、配送中に足したリスナは当回呼ばれない。
まとめ
EventTarget は各オブジェクトにイベントリスナリスト(台帳)を持ち、addEventListener/removeEventListener はその追記・削除操作です。リスナの同一性は (type, callback, capture) の3項目だけで判定され、once・passive・signal は判定に含まれません。だから同一の組を重ねて追加しても黙って無視され、削除は同じ関数参照と同じ capture がそろわなければ効きません(匿名関数・bind() が消せない理由)。once は1回実行後に自動削除、passive は preventDefault を無効化してスクロールを即時化し、signal は AbortSignal の abort で複数リスナを一括解除する宣言的な手段です。さらにディスパッチはリストのスナップショットを走査するため、配送中に足したリスナは当回呼ばれません。これらの規則は、伝播の経路を扱う ブラウザイベントの伝播モデル、リスナ解除漏れが関わる ブラウザの GC 内部、操作対象のツリーである DOM と合わせて理解すると、壊れない登録・確実な解除の設計につながります。
Web/フロントエンド Article
イベントターゲットとリスナ登録の内部モデルを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
removeEventListener も (type, callback, capture) の一致でしか消せない。匿名関数や bind() で作った関数は毎回別物になるため参照が一致せず外せない。capture の真偽が違えば別リスナ扱いで消えない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / DOM」に近いか確認する。
- 強みである「EventTarget は各オブジェクトに「リスナリスト」を持ち、登録は (type, callback, capture) の3項目で同一性を判定する。この組が既存と一致する追加は仕様上まるごと無視され、二重登録にはならない(once/passive/signal の違いは判定に含まれない)。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。