addEventListener は「要素でイベント(クリックやキー入力など)が起きたときに呼び出す関数を登録する」メソッドです。 要素.addEventListener('click', 関数) の形で書き、登録した関数をイベントリスナーと呼びます。

この記事では、addEventListener の基本から、主要イベントの一覧表(発火タイミングとバブリングの有無)、第3引数の4つのオプション、removeEventListener で解除できない原因、AbortController による一括解除、closest() を使ったイベント委譲までを扱います。バブリングの有無や解除の成否は Playwright で Chromium 141 を操作して実測した結果 を載せています。コードはそのまま貼って動かせる形にしました。

addEventListener の早見表

やりたいこと 書き方
クリックで処理する btn.addEventListener('click', (e) => { ... })
1回だけ実行する btn.addEventListener('click', fn, { once: true })
解除する btn.removeEventListener('click', fn)(同じ関数の参照が必要)
まとめて解除する { signal: controller.signal } で登録し controller.abort()
キャプチャ段階で受け取る el.addEventListener('click', fn, { capture: true })
スクロールを妨げないと宣言する el.addEventListener('touchstart', fn, { passive: true })
既定の動作を止める(送信・リンク移動) e.preventDefault()
親への伝わりを止める e.stopPropagation()
実際に操作された要素を知る e.target
リスナーを付けた要素を知る e.currentTarget
後から増える要素にも対応する 親に登録し e.target.closest('セレクタ') で判定(イベント委譲)
HTML の読み込み完了後に実行する document.addEventListener('DOMContentLoaded', fn)

構文は次のとおりです。

target.addEventListener(type, listener);
target.addEventListener(type, listener, options);     // options はオブジェクト
target.addEventListener(type, listener, useCapture);  // true / false(capture と同じ意味)
引数 内容
type イベント名の文字列。'click' のように on を付けずに書く(大文字小文字を区別)
listener 呼び出す関数。引数としてイベントオブジェクトを受け取る
options { capture, once, passive, signal } のオブジェクト。省略可

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「addEventListenerの使い方|イベント一覧と解除・イベント委譲」

addEventListener の基本の使い方

次の HTML をファイルに保存してブラウザで開くと、そのまま動きます。

<!doctype html>
<html lang="ja">
<head><meta charset="utf-8"><title>click の例</title></head>
<body>
  <button id="btn">押す</button>
  <p id="out">0</p>

  <script>
    const btn = document.getElementById('btn');
    const out = document.getElementById('out');
    let count = 0;

    btn.addEventListener('click', (event) => {
      count++;
      out.textContent = `${count} 回押されました(${event.type})`;
    });
  </script>
</body>
</html>

ポイントは3つです。

  • イベント名は 'click'。'onclick' や 'Click' と書くと、エラーにはならず何も起きません。
  • 関数は「渡す」だけで、呼び出さない。addEventListener('click', handler()) と括弧を付けると、登録時に実行されてしまい、その戻り値が登録されます。handler と書きます。
  • 要素が存在してから登録する。<script> を <body> の末尾に置くか、後述の DOMContentLoaded を使います。要素が見つからないと getElementById が null を返し、Cannot read properties of null (reading 'addEventListener') という TypeError になります。

要素の取得方法(querySelector など)は別記事「JavaScript DOM操作一覧」で詳しく扱っています。

イベント一覧(発火タイミングとバブリングの有無)

よく使うイベントを種類別にまとめました。「バブリング」は、イベントが親要素へ順に伝わっていくかどうか を表します。バブリングするイベントは親要素でまとめて受け取れる(イベント委譲ができる)ため、この列が重要です。

「バブリング」の列は、Chromium 141 を Playwright で実際に操作し、対象要素の親に付けたリスナーへ届いたかどうかと、イベントオブジェクトの bubbles プロパティを記録したものです。両者はすべて一致しました。

マウスのイベント

イベント 発火タイミング バブリング(実測)
click 押して離したとき(主ボタン) する
dblclick ダブルクリックしたとき する
mousedown ボタンを押したとき する
mouseup ボタンを離したとき する
mousemove 要素の上でポインターが動いたとき する
mouseover 要素または子孫に入ったとき する
mouseout 要素または子孫から出たとき する
mouseenter 要素に入ったとき(子孫間の移動では発火しない) しない
mouseleave 要素から出たとき(子孫間の移動では発火しない) しない
contextmenu 右クリックなどでメニューを開こうとしたとき する
auxclick 主ボタン以外(中ボタンなど)でクリックしたとき する
wheel ホイールを回したとき する

mouseover と mouseenter の違いは「バブリングするかどうか」 です。子要素にも反応させたい・親でまとめて受けたいなら mouseover、その要素への出入りだけを知りたいなら mouseenter を選びます。

キーボードのイベント

イベント 発火タイミング バブリング(実測)
keydown キーを押したとき(押し続けると繰り返す) する
keyup キーを離したとき する
keypress 文字キーを押したとき(非推奨。keydown を使う) する

押されたキーは e.key('Enter'、'Escape'、'a' など)で判定します。日本語入力の変換中は e.isComposing が true になるので、Enter キーで送信する処理では if (e.isComposing) return; を先頭に入れると、変換の確定で送信されるのを避けられます。

フォーム・入力のイベント

イベント 発火タイミング バブリング(実測)
input 値が変わるたび(1文字ごと) する
beforeinput 値が変わる直前 する
change 値の変更が確定したとき(テキスト欄はフォーカスが外れたとき、選択肢・チェックボックスは選んだとき) する
submit フォームが送信されるとき(form 要素で発火) する
reset フォームがリセットされるとき する
focus フォーカスを得たとき しない
blur フォーカスを失ったとき しない
focusin フォーカスを得たとき する
focusout フォーカスを失ったとき する
invalid 入力検証に通らなかったとき しない
select テキストが選択されたとき する
copy・cut・paste コピー・切り取り・貼り付けのとき する

focus と blur はバブリングしません。 フォーム全体でフォーカスの出入りをまとめて受けたいときは、バブリングする focusin・focusout を使います。input と change は発火のタイミングが異なり、入力中にリアルタイムで反応したいなら input、確定後に1回だけ処理したいなら change です。input イベントで検索などの重い処理を行う場合は、別記事「JavaScript debounce・throttle」の方法で呼び出し回数を抑えられます。

タッチ・ポインターのイベント

イベント 発火タイミング バブリング(実測)
touchstart 指が触れたとき する
touchmove 触れたまま動かしたとき する
touchend 指を離したとき する
touchcancel タッチが中断されたとき する
pointerdown マウス・指・ペンが押されたとき する
pointerup 離されたとき する
pointermove 動いたとき する
pointerover・pointerout 要素または子孫に入った・出たとき する
pointerenter・pointerleave 要素に入った・出たとき しない
pointercancel ポインター操作が中断されたとき する

ポインターイベントは、マウス・タッチ・ペンを1つの書き方で扱えるイベント です。新しく書くコードでは、mouse 系と touch 系を別々に登録する代わりに pointer 系を使うと、登録が1組で済みます。入力の種類は e.pointerType('mouse'・'touch'・'pen')で区別できます。

ウィンドウ・ドキュメント・その他のイベント

イベント 登録先 発火タイミング バブリング(実測)
DOMContentLoaded document HTML の解析が終わったとき する(document から window へ届く)
load window 画像なども含めて読み込みが終わったとき しない
resize window ウィンドウの大きさが変わったとき しない
scroll スクロールする要素・document スクロールしたとき しない(要素で発火した場合)
scrollend 同上 スクロールが止まったとき しない
hashchange window URL の # 以降が変わったとき しない
popstate window 履歴を戻る・進むで移動したとき しない
online・offline window ネットワーク接続が変わったとき しない
error img など 読み込めなかったとき しない
toggle details など 開閉したとき しない
animationstart・animationend 要素 CSS アニメーションの開始・終了 する
transitionend 要素 CSS トランジションの終了 する

次のイベントは今回の環境では発火を再現できなかったため、表に含めていません。使うときは MDN の各イベントのページでバブリングの有無を確認してください:img 要素の load、visibilitychange、compositionstart、beforeunload、drag 系。

バブリングしないイベント(focus、scroll、error など)を親でまとめて受けたい場合は、後述する { capture: true } を使うと、キャプチャ段階で親が受け取れます。

イベントが伝わる順番(キャプチャとバブリング)

イベントは、window から対象要素へ下りていく「キャプチャ段階」→ 対象要素 → 親へ上っていく「バブリング段階」 の順に伝わります。addEventListener は、何も指定しなければバブリング段階で呼ばれます。

outer > inner > btn > lbl(span) という入れ子で、outer・inner・btn にキャプチャ用とバブリング用のリスナーを付け、いちばん内側の span をクリックした結果です。

for (const id of ['outer', 'inner', 'btn']) {
  const el = document.getElementById(id);
  el.addEventListener('click', (e) =>
    console.log(`capture ${id} target=${e.target.id} currentTarget=${e.currentTarget.id}`), true);
  el.addEventListener('click', (e) =>
    console.log(`bubble  ${id} target=${e.target.id} currentTarget=${e.currentTarget.id}`));
}
document.getElementById('lbl').click();

実行結果(Chromium 141):

capture outer target=lbl currentTarget=outer
capture inner target=lbl currentTarget=inner
capture btn target=lbl currentTarget=btn
bubble  btn target=lbl currentTarget=btn
bubble  inner target=lbl currentTarget=inner
bubble  outer target=lbl currentTarget=outer

キャプチャは外側から内側へ、バブリングは内側から外側へ進んでいることがわかります。

event.target と event.currentTarget の違い

上の結果で、target はずっと lbl のまま、currentTarget はリスナーごとに変わっています。

プロパティ 意味 上の例での値
e.target 実際に操作された、いちばん内側の要素 常に lbl(span)
e.currentTarget いま実行中のリスナーが登録されている要素 outer・inner・btn と変わる

ボタンの中にアイコンや span があると、e.target はボタンではなく内側の要素になります。「リスナーを付けた要素そのもの」を使いたいときは currentTarget を使います。

this についても確認しました。

書き方 this の値(実測)
function (e) { ... } e.currentTarget と同じ
(e) => { ... }(アロー関数) 外側の this(e.currentTarget ではない)

アロー関数では this が要素にならないため、e.currentTarget を使うと書き方によらず同じ結果になります。また、currentTarget はリスナーの実行中だけ有効 です。setTimeout の中など、後から読むと null でした。後で使う場合は、先に const el = e.currentTarget; と変数に入れておきます。

第3引数のオプション(once・passive・capture・signal)

オプション 既定値 効果
once: true false 1回呼ばれたら自動で解除される
capture: true false キャプチャ段階で呼ばれる
passive: true false(一部を除く) preventDefault() を呼ばないと宣言する
signal: AbortSignal なし abort() されたら解除される

once:1回だけ実行する

let n = 0;
btn.addEventListener('click', () => n++, { once: true });
btn.click(); btn.click(); btn.click();
console.log(n);
1

3回クリックしても、呼ばれたのは1回でした。初回だけ行う初期化や、「閉じる」ボタンなどに向いています。removeEventListener を書かずに済むのが利点です。

capture:キャプチャ段階で受け取る

{ capture: true }(または第3引数に true)を指定すると、子要素より先に親が受け取ります。バブリングしないイベントを親でまとめて受けるときにも使えます。

// focus はバブリングしないが、capture なら親の form で受け取れる
form.addEventListener('focus', (e) => {
  console.log('フォーカス:', e.target.name);
}, { capture: true });

passive:スクロールを妨げないと宣言する

passive: true は、「このリスナーは preventDefault() を呼びません」とブラウザに伝えるオプション です。ブラウザはリスナーの終了を待たずにスクロールを始められるため、touchstart・touchmove・wheel で画面の動きが滑らかになります。

passive なリスナーの中で preventDefault() を呼ぶと、無視されます。

outer.addEventListener('wheel', (e) => {
  e.preventDefault();
  console.log('passive: true →', e.defaultPrevented);
}, { passive: true });

inner.addEventListener('wheel', (e) => {
  e.preventDefault();
  console.log('passive: false →', e.defaultPrevented);
}, { passive: false });

実行結果(Chromium 141):

passive: true → false
passive: false → true

コンソールには次のメッセージも出力されました。

Unable to preventDefault inside passive event listener invocation.

MDN によると、wheel・mousewheel・touchstart・touchmove は、window・document・html 要素・body に登録した場合、既定で passive: true として扱われます。document に付けた wheel リスナーで preventDefault() を呼んだところ、Chromium 141 では次のメッセージが出ました。

Unable to preventDefault inside passive event listener due to target being treated as passive.
目的 書き方
スクロールを見ているだけ(位置の記録など) { passive: true } を明示する
スクロールやピンチを自分で制御したい { passive: false } を明示して preventDefault() を呼ぶ

signal:AbortController で解除する

次の章の「一括解除」で詳しく扱います。

removeEventListener で解除する

解除は removeEventListener(type, listener) で行います。登録時と「同じ関数の参照」「同じ capture の値」を渡す ことが条件です。

function onClick() { console.log('clicked'); }

btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);   // 解除される

removeEventListener が効かない3つの原因

解除したはずなのにリスナーが呼ばれる場合、原因はほぼ次の3つです。それぞれ「登録 → 解除 → クリック」を行い、リスナーが呼ばれた回数を数えました。

パターン 解除後のクリックで呼ばれた回数(実測) 解除できたか
無名関数を両方に書いた 1 できていない
bind() を両方に書いた 1 できていない
capture の値が違う 1 できていない
capture の値をそろえた 0 できた
bind() の結果を変数に入れて両方に渡した 0 できた
passive だけが違う 0 できた(passive は照合に使われない)

原因1:無名関数(アロー関数)をその場に書いている

// 直す前:見た目が同じでも、2つは別の関数
btn.addEventListener('click', () => n++);
btn.removeEventListener('click', () => n++);   // 何も解除されない

// 直した後:変数に入れて同じ参照を渡す
const onClick = () => n++;
btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);

原因2:bind() をその場で呼んでいる

bind() は呼ぶたびに新しい関数を返すため、原因1と同じことが起きます。

// 直す前
btn.addEventListener('click', obj.handle.bind(obj));
btn.removeEventListener('click', obj.handle.bind(obj));   // 別の関数

// 直した後:bind の結果を1回だけ作って保持する
const bound = obj.handle.bind(obj);
btn.addEventListener('click', bound);
btn.removeEventListener('click', bound);

クラスの中では、コンストラクターで this.onClick = this.onClick.bind(this); としておくか、アロー関数のクラスフィールド(onClick = () => { ... })にすると、登録と解除で同じ参照を使えます。

原因3:capture の値が違う

// 直す前:capture: true で登録したのに、解除で指定していない
btn.addEventListener('click', onClick, { capture: true });
btn.removeEventListener('click', onClick);            // 解除されない

// 直した後:capture をそろえる
btn.removeEventListener('click', onClick, { capture: true });   // または true

照合に使われるのは type・listener・capture の3つ です。once や passive が違っていても解除できます。

同じ関数を2回登録するとどうなるか

同じ関数・同じ capture で2回 addEventListener を呼んでも、登録は1つのままです。

let q = 0;
const h = () => q++;
btn.addEventListener('click', h);
btn.addEventListener('click', h);         // 重複:追加されない
btn.addEventListener('click', h, true);   // capture が違うので別の登録
btn.click();
console.log(q);
2

一方、無名関数をその場に書いた場合は毎回別の関数として追加されます。「クリック1回で処理が2回走る」ときは、登録処理そのものが複数回実行されていないか(描画のたびに登録している、など)を確認します。

AbortController でまとめて解除する

{ signal } オプションを使うと、関数の参照を保持しなくても解除できます。 1つの AbortController の signal を複数のリスナーに渡しておけば、abort() の1回で、要素やイベントの種類が違っていてもすべて解除されます。

const controller = new AbortController();
const { signal } = controller;
let a = 0, c = 0, d = 0;

btn.addEventListener('click', () => a++, { signal });
window.addEventListener('resize', () => c++, { signal });
document.addEventListener('keydown', () => d++, { signal });

fire();                       // click・resize・keydown を1回ずつ発生させる
console.log([a, c, d]);

controller.abort();           // 3つまとめて解除

fire();
console.log([a, c, d]);

実行結果(Chromium 141):

[1, 1, 1]
[1, 1, 1]

abort() の後はどのリスナーも呼ばれず、回数が増えていません。無名関数のまま解除できる ため、removeEventListener が効かない3つの原因をまとめて避けられます。

なお、abort 済みの signal を渡して登録した場合、そのリスナーは登録されませんでした(クリックしても 0 回)。画面を開き直すたびに新しい AbortController を作ります。

解除の方法 向いている場面
{ once: true } 1回実行すれば役目が終わる
removeEventListener 1つのリスナーを個別に解除する
AbortController 複数のリスナーをまとめて解除する(モーダルを閉じる、画面を切り替える、コンポーネントを破棄する)

React の useEffect などでも、クリーンアップ関数で controller.abort() を呼ぶ形にすると、登録が何個あっても解除は1行で済みます。

preventDefault と stopPropagation の違い

名前が似ていますが、止める対象が違います。

メソッド 止めるもの 止めないもの
e.preventDefault() ブラウザの既定の動作(リンクの移動、フォーム送信、チェックの切り替えなど) 親要素へのイベントの伝わり
e.stopPropagation() 親要素(次の要素)へのイベントの伝わり 既定の動作、同じ要素の他のリスナー
e.stopImmediatePropagation() 親への伝わり+同じ要素の残りのリスナー 既定の動作

Chromium 141 で確認した結果です。

操作 結果(実測)
リンクの click で preventDefault() URL は変わらない。body のリスナーには届き、e.defaultPrevented は true
チェックボックスの click で preventDefault() checked は false のまま
ボタンの click で stopPropagation() 親のリスナーは呼ばれない(0回)。外すと呼ばれる(1回)
1つ目のリスナーで stopImmediatePropagation() 同じボタンの2つ目のリスナーも、親のリスナーも呼ばれない
// フォーム送信でページが再読み込みされるのを止め、JavaScript で処理する
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(form);
  console.log(data.get('title'));
});

stopPropagation は必要な場面を絞って使う のがおすすめです。親への伝わりを止めると、ページ全体でクリックを見ている処理(メニューの外側クリックで閉じる、計測など)にも届かなくなります。「この要素のクリックでは親の処理をしない」という目的なら、親のリスナー側で e.target.closest(...) を使って判定する方法もあります。

イベント委譲(closest を使ったコピペ用コード)

イベント委譲は、子要素1つ1つではなく、共通の親要素にリスナーを1つだけ登録し、e.target を見てどの子が操作されたかを判定する方法 です。バブリングを利用します。

比べる点 子要素ごとに登録 イベント委譲
リスナーの数 子要素の数だけ 1つ
後から追加した要素 追加のたびに登録が必要 登録なしで反応する
要素を削除したとき 解除を考える必要がある 何もしなくてよい
使えるイベント すべて バブリングするイベント(しないものは capture を使う)

closest() を使う理由

ボタンの中に <b> やアイコンがあると、e.target はその内側の要素になります。e.target.matches('.del') では判定できず、closest() なら祖先をたどって見つけられます。

クリックした場所 e.target e.target.matches('.del') e.target.closest('.del')
ボタン内の <i> 要素 I false ボタンが見つかる
ボタンそのもの BUTTON true ボタンが見つかる
ボタン以外(項目名) SPAN false null

基本形(これをコピペして使えます)

const list = document.getElementById('list');

list.addEventListener('click', (e) => {
  const btn = e.target.closest('.del');        // 1. 対象を祖先方向に探す
  if (!btn || !list.contains(btn)) return;     // 2. 対象外なら何もしない
  const li = btn.closest('li');                // 3. 必要な親要素を取得
  console.log('delete id=' + li.dataset.id);
  li.remove();
});

後から追加した項目(id=3)の <i> をクリック → 項目名をクリック → 既存の項目(id=1)のボタンをクリック、の順に操作した結果です。

delete id=3 target=I
delete id=1 target=BUTTON
remaining=2

後から追加した項目にも反応し、項目名のクリックでは何も起きていません。list.contains(btn) は、list の外側にある同じクラスの要素を closest() が拾った場合に備えた確認です。

完成形:追加・完了・削除・一括解除

フォーム送信(preventDefault)、イベント委譲(closest と data 属性)、キーボード、AbortController による一括解除を1つにまとめたサンプルです。ファイルに保存してブラウザで開くと動きます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>イベント委譲のサンプル</title>
</head>
<body>
<form id="add-form">
  <input id="title" name="title" placeholder="タスク名" required>
  <button type="submit">追加</button>
</form>
<ul id="tasks">
  <li data-id="1"><span class="title">資料を作る</span>
    <button type="button" data-action="done"><b>完了</b></button>
    <button type="button" data-action="delete"><b>削除</b></button></li>
</ul>
<button type="button" id="stop">すべてのリスナーを解除</button>
<p id="log"></p>

<script>
const controller = new AbortController();
const { signal } = controller;
const list = document.getElementById('tasks');
const form = document.getElementById('add-form');
const log = document.getElementById('log');
let nextId = 2;

// 1. フォーム送信:ページ遷移を止めて、項目を追加する
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const title = new FormData(form).get('title').trim();
  if (title === '') return;
  const li = document.createElement('li');
  li.dataset.id = String(nextId++);
  const span = document.createElement('span');
  span.className = 'title';
  span.textContent = title;
  li.append(span, ' ');
  for (const [action, label] of [['done', '完了'], ['delete', '削除']]) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.dataset.action = action;
    btn.innerHTML = `<b>${label}</b>`;
    li.append(btn, ' ');
  }
  list.append(li);
  form.reset();
}, { signal });

// 2. イベント委譲:親の ul にリスナーを1つだけ付ける
list.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-action]');
  if (!btn || !list.contains(btn)) return;      // ボタン以外のクリックは何もしない
  const li = btn.closest('li');
  if (btn.dataset.action === 'delete') {
    li.remove();
    log.textContent = `id=${li.dataset.id} を削除`;
  } else if (btn.dataset.action === 'done') {
    li.classList.toggle('done');
    log.textContent = `id=${li.dataset.id} done=${li.classList.contains('done')}`;
  }
}, { signal });

// 3. キーボード:Escape で入力欄を空にする
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') form.reset();
}, { signal });

// 4. 一括解除:abort() で上の3つがまとめて外れる
document.getElementById('stop').addEventListener('click', () => {
  controller.abort();
  log.textContent = 'リスナーを解除しました';
}, { once: true });
</script>
</body>
</html>

Playwright で操作して確認した結果です(Chromium 141)。

操作 結果
「会議室を予約」と入力して追加 id=2 の項目が増え、ページは再読み込みされない
追加した項目の「完了」(内側の <b>)をクリック id=2 done=true
id=1 の「削除」をクリック id=1 が消える
項目名をクリック 何も起きない
入力中に Escape 入力欄が空になる
「すべてのリスナーを解除」をクリック 以後、削除ボタンも Escape も反応しない

data-action 属性で処理を振り分けているので、ボタンの種類が増えても、リスナーを増やさずに else if を足すだけで対応できます。

DOMContentLoaded と load の違い

イベント 登録先 発火タイミング
DOMContentLoaded document HTML の解析が終わり、DOM が使えるようになったとき(画像の読み込みは待たない)
load window 画像・スタイルシートなども含めて読み込みが終わったとき

<head> 内のスクリプトで順番を記録した結果です。

// <head> 内の <script> で実行
console.log('script in head, body=' + document.body, document.readyState);
document.addEventListener('DOMContentLoaded', () => console.log('DOMContentLoaded', document.readyState));
window.addEventListener('load', () => console.log('load', document.readyState));
script in head, body=null loading
DOMContentLoaded interactive
load complete

<head> の時点では document.body が null で、要素はまだ取得できません。要素を操作する処理は DOMContentLoaded 以降に行い、画像のサイズなど読み込み結果が必要な処理は load を使います。

もう1つ確認したのは、読み込みが終わった後に DOMContentLoaded のリスナーを登録しても呼ばれない ことです(readyState が complete の状態で登録し、0 回でした)。後から読み込まれるスクリプトでは、次の書き方にするとどちらの場合も動きます。

function ready(fn) {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', fn, { once: true });
  } else {
    fn();   // すでに解析済みなら、すぐ実行する
  }
}

ready(() => {
  document.getElementById('btn').addEventListener('click', onClick);
});

<script defer> は、HTML の解析後・DOMContentLoaded の前に実行されます。<head> に置いた defer スクリプトから body 内のボタンを取得できること(readyState は interactive)を Chromium 141 で確認しました。<script type="module"> も同じタイミングで実行されると HTML の仕様で定められています(こちらは今回実行していません)。

onclick 属性・onclick プロパティとの違い

イベントの登録方法は3つあります。

<button onclick="doSomething()">A</button>   <!-- 1. HTML の属性 -->
btn.onclick = () => { ... };                  // 2. プロパティ
btn.addEventListener('click', () => { ... }); // 3. addEventListener
比べる点 onclick(属性・プロパティ) addEventListener
同じイベントに複数登録 1つだけ(後から代入すると置き換わる) いくつでも登録できる
once・passive・capture・signal 使えない 使える
解除 btn.onclick = null removeEventListener・abort()
HTML と JavaScript の分離 属性は HTML に処理が混ざる 分けて書ける

置き換わる様子を確認しました。

btn.onclick = () => console.log('onclick 1');
btn.onclick = () => console.log('onclick 2');
btn.addEventListener('click', () => console.log('listener 1'));
btn.addEventListener('click', () => console.log('listener 2'));
btn.click();
onclick 2
listener 1
listener 2

onclick 1 は上書きされて呼ばれていません。他のスクリプトやライブラリと同じ要素を扱う可能性があるなら、互いの登録を上書きしない addEventListener が向いています。

よくあるつまずきと確認ポイント

症状 原因 対応
クリックしても反応しない イベント名が 'onclick' になっている/要素の取得前に登録している 'click' と書く/DOMContentLoaded 以降に登録
読み込み直後に1回だけ実行される addEventListener('click', fn()) と括弧を付けている fn を渡す。引数が必要なら () => fn(id)
Cannot read properties of null セレクタに一致する要素がない id・クラス名と実行タイミングを確認
後から追加した要素が反応しない 追加前の要素にしか登録していない イベント委譲にする
処理が2回・3回と増えていく 登録処理が繰り返し実行されている 登録は1回にする/signal で前の登録を解除してから登録
removeEventListener で外れない 無名関数・bind・capture の不一致 同じ参照を渡す/AbortController を使う
preventDefault が効かない passive なリスナーになっている { passive: false } を明示
ボタン内のアイコンを押すと判定から外れる e.target が内側の要素になっている e.target.closest() または e.currentTarget を使う

querySelectorAll で複数の要素に登録するときは、NodeList の forEach を使います。

document.querySelectorAll('.tab').forEach((tab) => {
  tab.addEventListener('click', (e) => {
    console.log(e.currentTarget.dataset.tab);
  });
});

要素数が多い・後から増える場合は、前の章のイベント委譲にすると登録が1つで済みます。

よくある質問

Q. addEventListener の第3引数の true / false は何ですか? A. capture(キャプチャ段階で呼ぶかどうか)の指定です。true は { capture: true } と同じ意味です。once や passive も使う場合は、オブジェクトの形で書きます。

Q. リスナーに引数を渡すにはどうすればよいですか? A. アロー関数で包みます。btn.addEventListener('click', (e) => remove(id, e)) のように書くと、クリック時に remove(id, e) が呼ばれます。後で removeEventListener を使う場合は、包んだ関数を変数に入れておくか、signal で解除します。

Q. 要素を DOM から削除したら、リスナーも解除する必要がありますか? A. 削除した要素とリスナーがどこからも参照されなくなれば、メモリは自動で回収されます。一方、window や document に登録したリスナーは、ページがある限り残ります。画面を切り替えるタイプのアプリでは、AbortController でまとめて解除する形にしておくと管理しやすくなります。

Q. click と pointerdown のどちらを使えばよいですか? A. ボタンやリンクの操作は click が向いています。キーボード(Enter・Space)での操作でも発火するためです。ドラッグや描画のように、押した瞬間や動きを扱う処理は pointerdown・pointermove・pointerup を使います。

まとめ

  • 要素.addEventListener('click', 関数) が基本形。イベント名に on は付けず、関数は呼び出さずに渡します。
  • 多くのイベントはバブリングしますが、focus・blur・mouseenter・mouseleave・scroll・load・resize などはしません(Chromium 141 で実測)。親で受けたいときは focusin・focusout や capture を使います。
  • オプションは once(1回だけ)、capture(キャプチャ段階)、passive(preventDefault を呼ばない宣言)、signal(abort で解除) の4つです。
  • removeEventListener が効かない原因は、無名関数・bind・capture の不一致。同じ参照を渡すと解除できます。
  • AbortController の signal を使うと、無名関数のまま複数のリスナーをまとめて解除 できます。
  • e.target は操作された要素、e.currentTarget はリスナーを付けた要素。イベント委譲では e.target.closest() で判定します。
  • preventDefault は既定の動作を、stopPropagation は親への伝わりを止めます。DOM を使う処理は DOMContentLoaded 以降に行います。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「addEventListenerの使い方|イベント一覧と解除・イベント委譲」

株式会社RJCでは、addEventListener をはじめとするイベント処理の考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?